[wip] cli capability parity
Some checks failed
CI / Rustfmt (push) Successful in 23s
CI / Cargo Audit & Deny (push) Successful in 30s
CI / Web Blocking Checks (push) Successful in 48s
CI / Security Blocking Checks (push) Successful in 8s
CI / Clippy (push) Failing after 1m55s
CI / Web Advisory Checks (push) Successful in 35s
CI / Security Advisory Checks (push) Successful in 37s
CI / Tests (push) Successful in 8m5s

This commit is contained in:
2026-03-06 16:58:50 -06:00
parent 48b6ca6bd7
commit 87d830f952
94 changed files with 3694 additions and 734 deletions

View File

@@ -0,0 +1,605 @@
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<String>,
/// Filter by owner string
#[arg(long)]
owner: Option<String>,
/// 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<String>,
/// Owner pack reference (auto-resolves pack ID)
#[arg(long)]
owner_pack_ref: Option<String>,
/// Owner action reference (auto-resolves action ID)
#[arg(long)]
owner_action_ref: Option<String>,
/// Owner sensor reference (auto-resolves sensor ID)
#[arg(long)]
owner_sensor_ref: Option<String>,
/// 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<String>,
/// 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<String>,
/// Update encryption status
#[arg(long)]
encrypted: Option<bool>,
},
/// 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<String>,
#[serde(default)]
owner_identity: Option<i64>,
#[serde(default)]
owner_pack: Option<i64>,
#[serde(default)]
owner_pack_ref: Option<String>,
#[serde(default)]
owner_action: Option<i64>,
#[serde(default)]
owner_action_ref: Option<String>,
#[serde(default)]
owner_sensor: Option<i64>,
#[serde(default)]
owner_sensor_ref: Option<String>,
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<String>,
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
owner_pack_ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
owner_action_ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
owner_sensor_ref: Option<String>,
name: String,
value: JsonValue,
encrypted: bool,
}
#[derive(Debug, Serialize)]
struct UpdateKeyRequestBody {
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<JsonValue>,
#[serde(skip_serializing_if = "Option::is_none")]
encrypted: Option<bool>,
}
// ── Command dispatch ───────────────────────────────────────────────────────
pub async fn handle_key_command(
profile: &Option<String>,
command: KeyCommands,
api_url: &Option<String>,
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<String>,
owner_type: Option<String>,
owner: Option<String>,
page: u32,
per_page: u32,
api_url: &Option<String>,
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<KeySummary> = 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<String>,
key_ref: String,
decrypt: bool,
api_url: &Option<String>,
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<String>,
key_ref: String,
name: String,
value: String,
owner_type: String,
owner: Option<String>,
owner_pack_ref: Option<String>,
owner_action_ref: Option<String>,
owner_sensor_ref: Option<String>,
encrypted: bool,
api_url: &Option<String>,
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<String>,
key_ref: String,
name: Option<String>,
value: Option<String>,
encrypted: Option<bool>,
api_url: &Option<String>,
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<String>,
key_ref: String,
yes: bool,
api_url: &Option<String>,
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::<JsonValue>(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)
}