use anyhow::Result; use clap::Subcommand; use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; use sha2::{Digest, Sha256}; use crate::client::ApiClient; use crate::config::CliConfig; use crate::output::{self, OutputFormat}; #[derive(Subcommand)] pub enum KeyCommands { /// List all keys (values redacted) List { /// Filter by owner type (system, identity, pack, action, sensor) #[arg(long)] owner_type: Option, /// Filter by owner string #[arg(long)] owner: Option, /// Page number #[arg(long, default_value = "1")] page: u32, /// Items per page #[arg(long, default_value = "50")] per_page: u32, }, /// Show details of a specific key Show { /// Key reference identifier key_ref: String, /// Decrypt and display the actual value (otherwise a SHA-256 hash is shown) #[arg(short = 'd', long)] decrypt: bool, }, /// Create a new key/secret Create { /// Unique reference for the key (e.g., "github_token") #[arg(long)] r#ref: String, /// Human-readable name for the key #[arg(long)] name: String, /// The secret value to store. Plain strings are stored as JSON strings. /// Use JSON syntax for structured values (e.g., '{"user":"admin","pass":"s3cret"}'). #[arg(long)] value: String, /// Owner type (system, identity, pack, action, sensor) #[arg(long, default_value = "system")] owner_type: String, /// Owner string identifier #[arg(long)] owner: Option, /// Owner pack reference (auto-resolves pack ID) #[arg(long)] owner_pack_ref: Option, /// Owner action reference (auto-resolves action ID) #[arg(long)] owner_action_ref: Option, /// Owner sensor reference (auto-resolves sensor ID) #[arg(long)] owner_sensor_ref: Option, /// Encrypt the value before storing (default: unencrypted) #[arg(short = 'e', long)] encrypt: bool, }, /// Update an existing key/secret Update { /// Key reference identifier key_ref: String, /// Update the human-readable name #[arg(long)] name: Option, /// Update the secret value. Plain strings are stored as JSON strings. /// Use JSON syntax for structured values (e.g., '{"user":"admin","pass":"s3cret"}'). #[arg(long)] value: Option, /// Update encryption status #[arg(long)] encrypted: Option, }, /// Delete a key/secret Delete { /// Key reference identifier key_ref: String, /// Skip confirmation prompt #[arg(long)] yes: bool, }, } // ── Response / request types used for (de)serialization against the API ──── #[derive(Debug, Serialize, Deserialize)] struct KeyResponse { id: i64, #[serde(rename = "ref")] key_ref: String, owner_type: String, #[serde(default)] owner: Option, #[serde(default)] owner_identity: Option, #[serde(default)] owner_pack: Option, #[serde(default)] owner_pack_ref: Option, #[serde(default)] owner_action: Option, #[serde(default)] owner_action_ref: Option, #[serde(default)] owner_sensor: Option, #[serde(default)] owner_sensor_ref: Option, name: String, encrypted: bool, #[serde(default)] value: JsonValue, created: String, updated: String, } #[derive(Debug, Serialize, Deserialize)] struct KeySummary { id: i64, #[serde(rename = "ref")] key_ref: String, owner_type: String, #[serde(default)] owner: Option, name: String, encrypted: bool, created: String, } #[derive(Debug, Serialize)] struct CreateKeyRequestBody { r#ref: String, owner_type: String, #[serde(skip_serializing_if = "Option::is_none")] owner: Option, #[serde(skip_serializing_if = "Option::is_none")] owner_pack_ref: Option, #[serde(skip_serializing_if = "Option::is_none")] owner_action_ref: Option, #[serde(skip_serializing_if = "Option::is_none")] owner_sensor_ref: Option, name: String, value: JsonValue, encrypted: bool, } #[derive(Debug, Serialize)] struct UpdateKeyRequestBody { #[serde(skip_serializing_if = "Option::is_none")] name: Option, #[serde(skip_serializing_if = "Option::is_none")] value: Option, #[serde(skip_serializing_if = "Option::is_none")] encrypted: Option, } // ── Command dispatch ─────────────────────────────────────────────────────── pub async fn handle_key_command( profile: &Option, command: KeyCommands, api_url: &Option, output_format: OutputFormat, ) -> Result<()> { match command { KeyCommands::List { owner_type, owner, page, per_page, } => { handle_list( profile, owner_type, owner, page, per_page, api_url, output_format, ) .await } KeyCommands::Show { key_ref, decrypt } => { handle_show(profile, key_ref, decrypt, api_url, output_format).await } KeyCommands::Create { r#ref, name, value, owner_type, owner, owner_pack_ref, owner_action_ref, owner_sensor_ref, encrypt, } => { handle_create( profile, r#ref, name, value, owner_type, owner, owner_pack_ref, owner_action_ref, owner_sensor_ref, encrypt, api_url, output_format, ) .await } KeyCommands::Update { key_ref, name, value, encrypted, } => { handle_update( profile, key_ref, name, value, encrypted, api_url, output_format, ) .await } KeyCommands::Delete { key_ref, yes } => { handle_delete(profile, key_ref, yes, api_url, output_format).await } } } // ── Handlers ─────────────────────────────────────────────────────────────── #[allow(clippy::too_many_arguments)] async fn handle_list( profile: &Option, owner_type: Option, owner: Option, page: u32, per_page: u32, api_url: &Option, output_format: OutputFormat, ) -> Result<()> { let config = CliConfig::load_with_profile(profile.as_deref())?; let mut client = ApiClient::from_config(&config, api_url); let mut query_params = vec![format!("page={}", page), format!("per_page={}", per_page)]; if let Some(ot) = owner_type { query_params.push(format!("owner_type={}", ot)); } if let Some(o) = owner { query_params.push(format!("owner={}", o)); } let path = format!("/keys?{}", query_params.join("&")); let keys: Vec = client.get(&path).await?; match output_format { OutputFormat::Json | OutputFormat::Yaml => { output::print_output(&keys, output_format)?; } OutputFormat::Table => { if keys.is_empty() { output::print_info("No keys found"); } else { let mut table = output::create_table(); output::add_header( &mut table, vec![ "ID", "Ref", "Name", "Owner Type", "Owner", "Encrypted", "Created", ], ); for key in keys { table.add_row(vec![ key.id.to_string(), key.key_ref.clone(), key.name.clone(), key.owner_type.clone(), key.owner.clone().unwrap_or_else(|| "-".to_string()), output::format_bool(key.encrypted), output::format_timestamp(&key.created), ]); } println!("{}", table); } } } Ok(()) } async fn handle_show( profile: &Option, key_ref: String, decrypt: bool, api_url: &Option, output_format: OutputFormat, ) -> Result<()> { let config = CliConfig::load_with_profile(profile.as_deref())?; let mut client = ApiClient::from_config(&config, api_url); let path = format!("/keys/{}", urlencoding::encode(&key_ref)); let key: KeyResponse = client.get(&path).await?; match output_format { OutputFormat::Json | OutputFormat::Yaml => { if decrypt { output::print_output(&key, output_format)?; } else { // Redact value — replace with hash let mut redacted = serde_json::to_value(&key)?; if let Some(obj) = redacted.as_object_mut() { obj.insert( "value".to_string(), JsonValue::String(hash_value_for_display(&key.value)), ); } output::print_output(&redacted, output_format)?; } } OutputFormat::Table => { output::print_section(&format!("Key: {}", key.key_ref)); let mut pairs = vec![ ("ID", key.id.to_string()), ("Reference", key.key_ref.clone()), ("Name", key.name.clone()), ("Owner Type", key.owner_type.clone()), ( "Owner", key.owner.clone().unwrap_or_else(|| "-".to_string()), ), ]; if let Some(ref pack_ref) = key.owner_pack_ref { pairs.push(("Owner Pack", pack_ref.clone())); } if let Some(ref action_ref) = key.owner_action_ref { pairs.push(("Owner Action", action_ref.clone())); } if let Some(ref sensor_ref) = key.owner_sensor_ref { pairs.push(("Owner Sensor", sensor_ref.clone())); } pairs.push(("Encrypted", output::format_bool(key.encrypted))); if decrypt { pairs.push(("Value", format_value_for_display(&key.value))); } else { pairs.push(("Value (SHA-256)", hash_value_for_display(&key.value))); pairs.push(( "", "(use --decrypt / -d to reveal the actual value)".to_string(), )); } pairs.push(("Created", output::format_timestamp(&key.created))); pairs.push(("Updated", output::format_timestamp(&key.updated))); output::print_key_value_table(pairs); } } Ok(()) } #[allow(clippy::too_many_arguments)] async fn handle_create( profile: &Option, key_ref: String, name: String, value: String, owner_type: String, owner: Option, owner_pack_ref: Option, owner_action_ref: Option, owner_sensor_ref: Option, encrypted: bool, api_url: &Option, output_format: OutputFormat, ) -> Result<()> { // Validate owner_type before sending validate_owner_type(&owner_type)?; let config = CliConfig::load_with_profile(profile.as_deref())?; let mut client = ApiClient::from_config(&config, api_url); let json_value = parse_value_as_json(&value); let request = CreateKeyRequestBody { r#ref: key_ref, owner_type, owner, owner_pack_ref, owner_action_ref, owner_sensor_ref, name, value: json_value, encrypted, }; let key: KeyResponse = client.post("/keys", &request).await?; match output_format { OutputFormat::Json | OutputFormat::Yaml => { output::print_output(&key, output_format)?; } OutputFormat::Table => { output::print_success(&format!("Key '{}' created successfully", key.key_ref)); output::print_key_value_table(vec![ ("ID", key.id.to_string()), ("Reference", key.key_ref.clone()), ("Name", key.name.clone()), ("Owner Type", key.owner_type.clone()), ( "Owner", key.owner.clone().unwrap_or_else(|| "-".to_string()), ), ("Encrypted", output::format_bool(key.encrypted)), ("Created", output::format_timestamp(&key.created)), ]); } } Ok(()) } async fn handle_update( profile: &Option, key_ref: String, name: Option, value: Option, encrypted: Option, api_url: &Option, output_format: OutputFormat, ) -> Result<()> { if name.is_none() && value.is_none() && encrypted.is_none() { anyhow::bail!( "At least one field must be provided to update (--name, --value, or --encrypted)" ); } let config = CliConfig::load_with_profile(profile.as_deref())?; let mut client = ApiClient::from_config(&config, api_url); let json_value = value.map(|v| parse_value_as_json(&v)); let request = UpdateKeyRequestBody { name, value: json_value, encrypted, }; let path = format!("/keys/{}", urlencoding::encode(&key_ref)); let key: KeyResponse = client.put(&path, &request).await?; match output_format { OutputFormat::Json | OutputFormat::Yaml => { output::print_output(&key, output_format)?; } OutputFormat::Table => { output::print_success(&format!("Key '{}' updated successfully", key.key_ref)); output::print_key_value_table(vec![ ("ID", key.id.to_string()), ("Reference", key.key_ref.clone()), ("Name", key.name.clone()), ("Owner Type", key.owner_type.clone()), ( "Owner", key.owner.clone().unwrap_or_else(|| "-".to_string()), ), ("Encrypted", output::format_bool(key.encrypted)), ("Updated", output::format_timestamp(&key.updated)), ]); } } Ok(()) } async fn handle_delete( profile: &Option, key_ref: String, yes: bool, api_url: &Option, output_format: OutputFormat, ) -> Result<()> { let config = CliConfig::load_with_profile(profile.as_deref())?; let mut client = ApiClient::from_config(&config, api_url); // Confirm deletion unless --yes is provided if !yes && matches!(output_format, OutputFormat::Table) { let confirm = dialoguer::Confirm::new() .with_prompt(format!( "Are you sure you want to delete key '{}'?", key_ref )) .default(false) .interact()?; if !confirm { output::print_info("Deletion cancelled"); return Ok(()); } } let path = format!("/keys/{}", urlencoding::encode(&key_ref)); client.delete_no_response(&path).await?; match output_format { OutputFormat::Json | OutputFormat::Yaml => { let msg = serde_json::json!({"message": format!("Key '{}' deleted successfully", key_ref)}); output::print_output(&msg, output_format)?; } OutputFormat::Table => { output::print_success(&format!("Key '{}' deleted successfully", key_ref)); } } Ok(()) } // ── Helpers ──────────────────────────────────────────────────────────────── /// Validate that the owner_type string is one of the accepted values. fn validate_owner_type(owner_type: &str) -> Result<()> { const VALID: &[&str] = &["system", "identity", "pack", "action", "sensor"]; if !VALID.contains(&owner_type) { anyhow::bail!( "Invalid owner type '{}'. Must be one of: {}", owner_type, VALID.join(", ") ); } Ok(()) } /// Parse a CLI string value into a [`JsonValue`]. /// /// If the input is valid JSON (object, array, number, boolean, null, or /// quoted string), it is used as-is. Otherwise, it is treated as a plain /// string and wrapped in a JSON string value. fn parse_value_as_json(input: &str) -> JsonValue { match serde_json::from_str::(input) { Ok(v) => v, Err(_) => JsonValue::String(input.to_string()), } } /// Format a [`JsonValue`] for table display. fn format_value_for_display(value: &JsonValue) -> String { match value { JsonValue::String(s) => s.clone(), other => serde_json::to_string_pretty(other).unwrap_or_else(|_| other.to_string()), } } /// Compute a SHA-256 hash of the JSON value for display purposes. /// /// This lets users verify a value matches expectations without revealing /// the actual content (e.g., to confirm it hasn't changed). fn hash_value_for_display(value: &JsonValue) -> String { let serialized = serde_json::to_string(value).unwrap_or_default(); let mut hasher = Sha256::new(); hasher.update(serialized.as_bytes()); let result = hasher.finalize(); format!("sha256:{:x}", result) }