[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
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:
@@ -51,9 +51,12 @@ flate2 = { workspace = true }
|
||||
# WebSocket client (for notifier integration)
|
||||
tokio-tungstenite = { workspace = true }
|
||||
|
||||
# Hashing
|
||||
sha2 = { workspace = true }
|
||||
|
||||
# Terminal UI
|
||||
colored = "3.1"
|
||||
comfy-table = "7.2"
|
||||
comfy-table = { version = "7.2", features = ["custom_styling"] }
|
||||
dialoguer = "0.12"
|
||||
|
||||
# Authentication
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use anyhow::{Context, Result};
|
||||
use reqwest::{multipart, Client as HttpClient, Method, RequestBuilder, StatusCode};
|
||||
use reqwest::{header, multipart, Client as HttpClient, Method, RequestBuilder, StatusCode};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
@@ -347,6 +347,80 @@ impl ApiClient {
|
||||
.await
|
||||
}
|
||||
|
||||
/// GET request that returns raw bytes and optional filename from Content-Disposition.
|
||||
///
|
||||
/// Used for downloading binary content (e.g., artifact files).
|
||||
/// Returns `(bytes, content_type, optional_filename)`.
|
||||
pub async fn download_bytes(
|
||||
&mut self,
|
||||
path: &str,
|
||||
) -> Result<(Vec<u8>, String, Option<String>)> {
|
||||
// First attempt
|
||||
let req = self.build_request(Method::GET, path);
|
||||
let response = req.send().await.context("Failed to send request to API")?;
|
||||
|
||||
if response.status() == StatusCode::UNAUTHORIZED
|
||||
&& self.refresh_token.is_some()
|
||||
&& self.refresh_auth_token().await?
|
||||
{
|
||||
// Retry with new token
|
||||
let req = self.build_request(Method::GET, path);
|
||||
let response = req
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send request to API (retry)")?;
|
||||
return self.handle_bytes_response(response).await;
|
||||
}
|
||||
|
||||
self.handle_bytes_response(response).await
|
||||
}
|
||||
|
||||
/// Parse a binary response, extracting content type and optional filename.
|
||||
async fn handle_bytes_response(
|
||||
&self,
|
||||
response: reqwest::Response,
|
||||
) -> Result<(Vec<u8>, String, Option<String>)> {
|
||||
let status = response.status();
|
||||
|
||||
if status.is_success() {
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
|
||||
let filename = response
|
||||
.headers()
|
||||
.get(header::CONTENT_DISPOSITION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| {
|
||||
// Parse filename from Content-Disposition: attachment; filename="name.ext"
|
||||
v.split("filename=")
|
||||
.nth(1)
|
||||
.map(|f| f.trim_matches('"').trim_matches('\'').to_string())
|
||||
});
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("Failed to read response bytes")?;
|
||||
|
||||
Ok((bytes.to_vec(), content_type, filename))
|
||||
} else {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
|
||||
if let Ok(api_error) = serde_json::from_str::<ApiError>(&error_text) {
|
||||
anyhow::bail!("API error ({}): {}", status, api_error.error);
|
||||
} else {
|
||||
anyhow::bail!("API error ({}): {}", status, error_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// POST a multipart/form-data request with a file field and optional text fields.
|
||||
///
|
||||
/// - `file_field_name`: the multipart field name for the file
|
||||
|
||||
@@ -241,7 +241,7 @@ async fn handle_list(
|
||||
let mut table = output::create_table();
|
||||
output::add_header(
|
||||
&mut table,
|
||||
vec!["ID", "Pack", "Name", "Runner", "Enabled", "Description"],
|
||||
vec!["ID", "Pack", "Name", "Runner", "Description"],
|
||||
);
|
||||
|
||||
for action in actions {
|
||||
@@ -253,7 +253,6 @@ async fn handle_list(
|
||||
.runtime
|
||||
.map(|r| r.to_string())
|
||||
.unwrap_or_else(|| "none".to_string()),
|
||||
"✓".to_string(),
|
||||
output::truncate(&action.description, 40),
|
||||
]);
|
||||
}
|
||||
|
||||
1299
crates/cli/src/commands/artifact.rs
Normal file
1299
crates/cli/src/commands/artifact.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -175,7 +175,7 @@ async fn handle_current(output_format: OutputFormat) -> Result<()> {
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let result = serde_json::json!({
|
||||
"current_profile": config.current_profile
|
||||
"profile": config.current_profile
|
||||
});
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
@@ -194,7 +194,7 @@ async fn handle_use(name: String, output_format: OutputFormat) -> Result<()> {
|
||||
match output_format {
|
||||
OutputFormat::Json | OutputFormat::Yaml => {
|
||||
let result = serde_json::json!({
|
||||
"current_profile": name,
|
||||
"profile": name,
|
||||
"message": "Switched profile"
|
||||
});
|
||||
output::print_output(&result, output_format)?;
|
||||
@@ -299,10 +299,6 @@ async fn handle_show_profile(name: String, output_format: OutputFormat) -> Resul
|
||||
),
|
||||
];
|
||||
|
||||
if let Some(output_format) = &profile.output_format {
|
||||
pairs.push(("Output Format", output_format.clone()));
|
||||
}
|
||||
|
||||
if let Some(description) = &profile.description {
|
||||
pairs.push(("Description", description.clone()));
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ pub enum ExecutionCommands {
|
||||
execution_id: i64,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(short = 'y', long)]
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
/// Get raw execution result
|
||||
|
||||
605
crates/cli/src/commands/key.rs
Normal file
605
crates/cli/src/commands/key.rs
Normal 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)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod action;
|
||||
pub mod artifact;
|
||||
pub mod auth;
|
||||
pub mod config;
|
||||
pub mod execution;
|
||||
pub mod key;
|
||||
pub mod pack;
|
||||
pub mod pack_index;
|
||||
pub mod rule;
|
||||
|
||||
@@ -95,10 +95,6 @@ pub enum PackCommands {
|
||||
/// Update version
|
||||
#[arg(long)]
|
||||
version: Option<String>,
|
||||
|
||||
/// Update enabled status
|
||||
#[arg(long)]
|
||||
enabled: Option<bool>,
|
||||
},
|
||||
/// Uninstall a pack
|
||||
Uninstall {
|
||||
@@ -246,8 +242,6 @@ struct Pack {
|
||||
#[serde(default)]
|
||||
keywords: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
metadata: Option<serde_json::Value>,
|
||||
created: String,
|
||||
updated: String,
|
||||
@@ -273,8 +267,6 @@ struct PackDetail {
|
||||
#[serde(default)]
|
||||
keywords: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
metadata: Option<serde_json::Value>,
|
||||
created: String,
|
||||
updated: String,
|
||||
@@ -404,7 +396,6 @@ pub async fn handle_pack_command(
|
||||
label,
|
||||
description,
|
||||
version,
|
||||
enabled,
|
||||
} => {
|
||||
handle_update(
|
||||
profile,
|
||||
@@ -412,7 +403,6 @@ pub async fn handle_pack_command(
|
||||
label,
|
||||
description,
|
||||
version,
|
||||
enabled,
|
||||
api_url,
|
||||
output_format,
|
||||
)
|
||||
@@ -651,17 +641,13 @@ async fn handle_list(
|
||||
output::print_info("No packs found");
|
||||
} else {
|
||||
let mut table = output::create_table();
|
||||
output::add_header(
|
||||
&mut table,
|
||||
vec!["ID", "Name", "Version", "Enabled", "Description"],
|
||||
);
|
||||
output::add_header(&mut table, vec!["ID", "Name", "Version", "Description"]);
|
||||
|
||||
for pack in packs {
|
||||
table.add_row(vec![
|
||||
pack.id.to_string(),
|
||||
pack.pack_ref,
|
||||
pack.version,
|
||||
output::format_bool(pack.enabled.unwrap_or(true)),
|
||||
output::truncate(&pack.description.unwrap_or_default(), 50),
|
||||
]);
|
||||
}
|
||||
@@ -705,7 +691,6 @@ async fn handle_show(
|
||||
"Description",
|
||||
pack.description.unwrap_or_else(|| "None".to_string()),
|
||||
),
|
||||
("Enabled", output::format_bool(pack.enabled.unwrap_or(true))),
|
||||
("Actions", pack.action_count.unwrap_or(0).to_string()),
|
||||
("Triggers", pack.trigger_count.unwrap_or(0).to_string()),
|
||||
("Rules", pack.rule_count.unwrap_or(0).to_string()),
|
||||
@@ -1779,7 +1764,6 @@ async fn handle_update(
|
||||
label: Option<String>,
|
||||
description: Option<String>,
|
||||
version: Option<String>,
|
||||
enabled: Option<bool>,
|
||||
api_url: &Option<String>,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
@@ -1787,7 +1771,7 @@ async fn handle_update(
|
||||
let mut client = ApiClient::from_config(&config, api_url);
|
||||
|
||||
// Check that at least one field is provided
|
||||
if label.is_none() && description.is_none() && version.is_none() && enabled.is_none() {
|
||||
if label.is_none() && description.is_none() && version.is_none() {
|
||||
anyhow::bail!("At least one field must be provided to update");
|
||||
}
|
||||
|
||||
@@ -1799,15 +1783,12 @@ async fn handle_update(
|
||||
description: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
let request = UpdatePackRequest {
|
||||
label,
|
||||
description,
|
||||
version,
|
||||
enabled,
|
||||
};
|
||||
|
||||
let path = format!("/packs/{}", pack_ref);
|
||||
@@ -1824,7 +1805,6 @@ async fn handle_update(
|
||||
("Ref", pack.pack_ref.clone()),
|
||||
("Label", pack.label.clone()),
|
||||
("Version", pack.version.clone()),
|
||||
("Enabled", output::format_bool(pack.enabled.unwrap_or(true))),
|
||||
("Updated", output::format_timestamp(&pack.updated)),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ pub enum RuleCommands {
|
||||
rule_ref: String,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(short = 'y', long)]
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
},
|
||||
}
|
||||
@@ -275,12 +275,13 @@ async fn handle_list(
|
||||
let mut table = output::create_table();
|
||||
output::add_header(
|
||||
&mut table,
|
||||
vec!["ID", "Pack", "Name", "Trigger", "Action", "Enabled"],
|
||||
vec!["ID", "Ref", "Pack", "Label", "Trigger", "Action", "Enabled"],
|
||||
);
|
||||
|
||||
for rule in rules {
|
||||
table.add_row(vec![
|
||||
rule.id.to_string(),
|
||||
rule.rule_ref.clone(),
|
||||
rule.pack_ref.clone(),
|
||||
rule.label.clone(),
|
||||
rule.trigger_ref.clone(),
|
||||
|
||||
@@ -5,25 +5,35 @@ use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::output::OutputFormat;
|
||||
|
||||
/// CLI configuration stored in user's home directory
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CliConfig {
|
||||
/// Current active profile name
|
||||
#[serde(default = "default_profile_name")]
|
||||
#[serde(
|
||||
default = "default_profile_name",
|
||||
rename = "profile",
|
||||
alias = "current_profile"
|
||||
)]
|
||||
pub current_profile: String,
|
||||
/// Named profiles (like SSH hosts)
|
||||
#[serde(default)]
|
||||
pub profiles: HashMap<String, Profile>,
|
||||
/// Default output format (can be overridden per-profile)
|
||||
#[serde(default = "default_output_format")]
|
||||
pub default_output_format: String,
|
||||
/// Output format (table, json, yaml)
|
||||
#[serde(
|
||||
default = "default_format",
|
||||
rename = "format",
|
||||
alias = "default_output_format"
|
||||
)]
|
||||
pub format: String,
|
||||
}
|
||||
|
||||
fn default_profile_name() -> String {
|
||||
"default".to_string()
|
||||
}
|
||||
|
||||
fn default_output_format() -> String {
|
||||
fn default_format() -> String {
|
||||
"table".to_string()
|
||||
}
|
||||
|
||||
@@ -38,8 +48,9 @@ pub struct Profile {
|
||||
/// Refresh token
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub refresh_token: Option<String>,
|
||||
/// Output format override for this profile
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
/// Output format override for this profile (deprecated — ignored, kept for deserialization compat)
|
||||
#[serde(skip_serializing)]
|
||||
#[allow(dead_code)]
|
||||
pub output_format: Option<String>,
|
||||
/// Optional description
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -63,7 +74,7 @@ impl Default for CliConfig {
|
||||
Self {
|
||||
current_profile: "default".to_string(),
|
||||
profiles,
|
||||
default_output_format: default_output_format(),
|
||||
format: default_format(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,6 +204,29 @@ impl CliConfig {
|
||||
self.save()
|
||||
}
|
||||
|
||||
/// Resolve the effective output format.
|
||||
///
|
||||
/// Priority (highest to lowest):
|
||||
/// 1. Explicit CLI flag (`--json`, `--yaml`, `--output`)
|
||||
/// 2. Config `format` field
|
||||
///
|
||||
/// The `cli_flag` parameter should be `None` when the user did not pass an
|
||||
/// explicit flag (i.e. clap returned the default value `table` *without*
|
||||
/// the user typing it). Callers should pass `Some(format)` only when the
|
||||
/// user actually supplied the flag.
|
||||
pub fn effective_format(&self, cli_override: Option<OutputFormat>) -> OutputFormat {
|
||||
if let Some(fmt) = cli_override {
|
||||
return fmt;
|
||||
}
|
||||
|
||||
// Fall back to config value
|
||||
match self.format.to_lowercase().as_str() {
|
||||
"json" => OutputFormat::Json,
|
||||
"yaml" => OutputFormat::Yaml,
|
||||
_ => OutputFormat::Table,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a configuration value by key
|
||||
pub fn set_value(&mut self, key: &str, value: String) -> Result<()> {
|
||||
match key {
|
||||
@@ -200,14 +234,18 @@ impl CliConfig {
|
||||
let profile = self.current_profile_mut()?;
|
||||
profile.api_url = value;
|
||||
}
|
||||
"output_format" => {
|
||||
let profile = self.current_profile_mut()?;
|
||||
profile.output_format = Some(value);
|
||||
"format" | "output_format" | "default_output_format" => {
|
||||
// Validate the value
|
||||
match value.to_lowercase().as_str() {
|
||||
"table" | "json" | "yaml" => {}
|
||||
_ => anyhow::bail!(
|
||||
"Invalid format '{}'. Must be one of: table, json, yaml",
|
||||
value
|
||||
),
|
||||
}
|
||||
self.format = value.to_lowercase();
|
||||
}
|
||||
"default_output_format" => {
|
||||
self.default_output_format = value;
|
||||
}
|
||||
"current_profile" => {
|
||||
"profile" | "current_profile" => {
|
||||
self.switch_profile(value)?;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -223,15 +261,8 @@ impl CliConfig {
|
||||
let profile = self.current_profile()?;
|
||||
Ok(profile.api_url.clone())
|
||||
}
|
||||
"output_format" => {
|
||||
let profile = self.current_profile()?;
|
||||
Ok(profile
|
||||
.output_format
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.default_output_format.clone()))
|
||||
}
|
||||
"default_output_format" => Ok(self.default_output_format.clone()),
|
||||
"current_profile" => Ok(self.current_profile.clone()),
|
||||
"format" | "output_format" | "default_output_format" => Ok(self.format.clone()),
|
||||
"profile" | "current_profile" => Ok(self.current_profile.clone()),
|
||||
"auth_token" => {
|
||||
let profile = self.current_profile()?;
|
||||
Ok(profile
|
||||
@@ -262,19 +293,9 @@ impl CliConfig {
|
||||
};
|
||||
|
||||
vec![
|
||||
("current_profile".to_string(), self.current_profile.clone()),
|
||||
("profile".to_string(), self.current_profile.clone()),
|
||||
("format".to_string(), self.format.clone()),
|
||||
("api_url".to_string(), profile.api_url.clone()),
|
||||
(
|
||||
"output_format".to_string(),
|
||||
profile
|
||||
.output_format
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.default_output_format.clone()),
|
||||
),
|
||||
(
|
||||
"default_output_format".to_string(),
|
||||
self.default_output_format.clone(),
|
||||
),
|
||||
(
|
||||
"auth_token".to_string(),
|
||||
profile
|
||||
@@ -354,7 +375,7 @@ mod tests {
|
||||
fn test_default_config() {
|
||||
let config = CliConfig::default();
|
||||
assert_eq!(config.current_profile, "default");
|
||||
assert_eq!(config.default_output_format, "table");
|
||||
assert_eq!(config.format, "table");
|
||||
assert!(config.profiles.contains_key("default"));
|
||||
|
||||
let profile = config.current_profile().unwrap();
|
||||
@@ -378,6 +399,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_format_defaults_to_config() {
|
||||
let mut config = CliConfig::default();
|
||||
config.format = "json".to_string();
|
||||
|
||||
// No CLI override → uses config
|
||||
assert_eq!(config.effective_format(None), OutputFormat::Json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_format_cli_overrides_config() {
|
||||
let mut config = CliConfig::default();
|
||||
config.format = "json".to_string();
|
||||
|
||||
// CLI override wins
|
||||
assert_eq!(
|
||||
config.effective_format(Some(OutputFormat::Yaml)),
|
||||
OutputFormat::Yaml
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_effective_format_default_table() {
|
||||
let config = CliConfig::default();
|
||||
assert_eq!(config.effective_format(None), OutputFormat::Table);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_management() {
|
||||
let mut config = CliConfig::default();
|
||||
@@ -387,7 +435,7 @@ mod tests {
|
||||
api_url: "https://staging.example.com".to_string(),
|
||||
auth_token: None,
|
||||
refresh_token: None,
|
||||
output_format: Some("json".to_string()),
|
||||
output_format: None,
|
||||
description: Some("Staging environment".to_string()),
|
||||
};
|
||||
config
|
||||
@@ -442,7 +490,7 @@ mod tests {
|
||||
config.get_value("api_url").unwrap(),
|
||||
"http://localhost:8080"
|
||||
);
|
||||
assert_eq!(config.get_value("output_format").unwrap(), "table");
|
||||
assert_eq!(config.get_value("format").unwrap(), "table");
|
||||
|
||||
// Set API URL for current profile
|
||||
config
|
||||
@@ -450,10 +498,53 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(config.get_value("api_url").unwrap(), "http://test.com");
|
||||
|
||||
// Set output format for current profile
|
||||
config
|
||||
// Set format
|
||||
config.set_value("format", "json".to_string()).unwrap();
|
||||
assert_eq!(config.get_value("format").unwrap(), "json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_value_validates_format() {
|
||||
let mut config = CliConfig::default();
|
||||
|
||||
// Valid values
|
||||
assert!(config.set_value("format", "table".to_string()).is_ok());
|
||||
assert!(config.set_value("format", "json".to_string()).is_ok());
|
||||
assert!(config.set_value("format", "yaml".to_string()).is_ok());
|
||||
assert!(config.set_value("format", "JSON".to_string()).is_ok()); // case-insensitive
|
||||
|
||||
// Invalid value
|
||||
assert!(config.set_value("format", "xml".to_string()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_backward_compat_aliases() {
|
||||
let mut config = CliConfig::default();
|
||||
|
||||
// Old key names should still work for get/set
|
||||
assert!(config
|
||||
.set_value("output_format", "json".to_string())
|
||||
.unwrap();
|
||||
.is_ok());
|
||||
assert_eq!(config.get_value("output_format").unwrap(), "json");
|
||||
assert_eq!(config.get_value("format").unwrap(), "json");
|
||||
|
||||
assert!(config
|
||||
.set_value("default_output_format", "yaml".to_string())
|
||||
.is_ok());
|
||||
assert_eq!(config.get_value("default_output_format").unwrap(), "yaml");
|
||||
assert_eq!(config.get_value("format").unwrap(), "yaml");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_legacy_default_output_format() {
|
||||
let yaml = r#"
|
||||
profile: default
|
||||
default_output_format: json
|
||||
profiles:
|
||||
default:
|
||||
api_url: http://localhost:8080
|
||||
"#;
|
||||
let config: CliConfig = serde_yaml_ng::from_str(yaml).unwrap();
|
||||
assert_eq!(config.format, "json");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ mod wait;
|
||||
|
||||
use commands::{
|
||||
action::{handle_action_command, ActionCommands},
|
||||
artifact::ArtifactCommands,
|
||||
auth::AuthCommands,
|
||||
config::ConfigCommands,
|
||||
execution::ExecutionCommands,
|
||||
key::KeyCommands,
|
||||
pack::PackCommands,
|
||||
rule::RuleCommands,
|
||||
sensor::SensorCommands,
|
||||
@@ -33,8 +35,8 @@ struct Cli {
|
||||
api_url: Option<String>,
|
||||
|
||||
/// Output format
|
||||
#[arg(long, value_enum, default_value = "table", global = true, conflicts_with_all = ["json", "yaml"])]
|
||||
output: output::OutputFormat,
|
||||
#[arg(long, value_enum, global = true, conflicts_with_all = ["json", "yaml"])]
|
||||
output: Option<output::OutputFormat>,
|
||||
|
||||
/// Output as JSON (shorthand for --output json)
|
||||
#[arg(short = 'j', long, global = true, conflicts_with_all = ["output", "yaml"])]
|
||||
@@ -74,6 +76,11 @@ enum Commands {
|
||||
#[command(subcommand)]
|
||||
command: RuleCommands,
|
||||
},
|
||||
/// Key/secret management
|
||||
Key {
|
||||
#[command(subcommand)]
|
||||
command: KeyCommands,
|
||||
},
|
||||
/// Execution monitoring
|
||||
Execution {
|
||||
#[command(subcommand)]
|
||||
@@ -94,6 +101,11 @@ enum Commands {
|
||||
#[command(subcommand)]
|
||||
command: SensorCommands,
|
||||
},
|
||||
/// Artifact management (list, upload, download, delete)
|
||||
Artifact {
|
||||
#[command(subcommand)]
|
||||
command: ArtifactCommands,
|
||||
},
|
||||
/// Configuration management
|
||||
Config {
|
||||
#[command(subcommand)]
|
||||
@@ -129,6 +141,9 @@ enum Commands {
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Install HMAC-only JWT crypto provider (must be before any token operations)
|
||||
attune_common::auth::install_crypto_provider();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
// Initialize logging
|
||||
@@ -138,14 +153,17 @@ async fn main() {
|
||||
.init();
|
||||
}
|
||||
|
||||
// Determine output format from flags
|
||||
let output_format = if cli.json {
|
||||
output::OutputFormat::Json
|
||||
// Determine output format: explicit CLI flags > config file > default (table)
|
||||
let cli_override = if cli.json {
|
||||
Some(output::OutputFormat::Json)
|
||||
} else if cli.yaml {
|
||||
output::OutputFormat::Yaml
|
||||
Some(output::OutputFormat::Yaml)
|
||||
} else {
|
||||
cli.output
|
||||
};
|
||||
let config_for_format =
|
||||
config::CliConfig::load_with_profile(cli.profile.as_deref()).unwrap_or_default();
|
||||
let output_format = config_for_format.effective_format(cli_override);
|
||||
|
||||
let result = match cli.command {
|
||||
Commands::Auth { command } => {
|
||||
@@ -169,6 +187,10 @@ async fn main() {
|
||||
commands::rule::handle_rule_command(&cli.profile, command, &cli.api_url, output_format)
|
||||
.await
|
||||
}
|
||||
Commands::Key { command } => {
|
||||
commands::key::handle_key_command(&cli.profile, command, &cli.api_url, output_format)
|
||||
.await
|
||||
}
|
||||
Commands::Execution { command } => {
|
||||
commands::execution::handle_execution_command(
|
||||
&cli.profile,
|
||||
@@ -205,6 +227,15 @@ async fn main() {
|
||||
)
|
||||
.await
|
||||
}
|
||||
Commands::Artifact { command } => {
|
||||
commands::artifact::handle_artifact_command(
|
||||
&cli.profile,
|
||||
command,
|
||||
&cli.api_url,
|
||||
output_format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Commands::Config { command } => {
|
||||
commands::config::handle_config_command(&cli.profile, command, output_format).await
|
||||
}
|
||||
|
||||
@@ -115,11 +115,33 @@ fn create_test_index(packs: &[(&str, &str)]) -> TempDir {
|
||||
temp_dir
|
||||
}
|
||||
|
||||
/// Create an isolated CLI command that never touches the user's real config.
|
||||
///
|
||||
/// Returns `(Command, TempDir)` — the `TempDir` must be kept alive for the
|
||||
/// duration of the test so the config directory isn't deleted prematurely.
|
||||
fn isolated_cmd() -> (Command, TempDir) {
|
||||
let config_dir = TempDir::new().expect("Failed to create temp config dir");
|
||||
|
||||
// Write a minimal default config so the CLI doesn't try to create one
|
||||
let attune_dir = config_dir.path().join("attune");
|
||||
fs::create_dir_all(&attune_dir).expect("Failed to create attune config dir");
|
||||
fs::write(
|
||||
attune_dir.join("config.yaml"),
|
||||
"profile: default\nformat: table\nprofiles:\n default:\n api_url: http://localhost:8080\n",
|
||||
)
|
||||
.expect("Failed to write test config");
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", config_dir.path())
|
||||
.env("HOME", config_dir.path());
|
||||
(cmd, config_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pack_checksum_directory() {
|
||||
let pack_dir = create_test_pack("checksum-test", "1.0.0", &[]);
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
let (mut cmd, _config_dir) = isolated_cmd();
|
||||
cmd.arg("--output")
|
||||
.arg("table")
|
||||
.arg("pack")
|
||||
@@ -135,7 +157,7 @@ fn test_pack_checksum_directory() {
|
||||
fn test_pack_checksum_json_output() {
|
||||
let pack_dir = create_test_pack("checksum-json", "1.0.0", &[]);
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
let (mut cmd, _config_dir) = isolated_cmd();
|
||||
cmd.arg("--output")
|
||||
.arg("json")
|
||||
.arg("pack")
|
||||
@@ -153,7 +175,7 @@ fn test_pack_checksum_json_output() {
|
||||
|
||||
#[test]
|
||||
fn test_pack_checksum_nonexistent_path() {
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
let (mut cmd, _config_dir) = isolated_cmd();
|
||||
cmd.arg("pack").arg("checksum").arg("/nonexistent/path");
|
||||
|
||||
cmd.assert().failure().stderr(
|
||||
@@ -165,7 +187,7 @@ fn test_pack_checksum_nonexistent_path() {
|
||||
fn test_pack_index_entry_generates_valid_json() {
|
||||
let pack_dir = create_test_pack("index-entry-test", "1.2.3", &[]);
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
let (mut cmd, _config_dir) = isolated_cmd();
|
||||
cmd.arg("--output")
|
||||
.arg("json")
|
||||
.arg("pack")
|
||||
@@ -199,7 +221,7 @@ fn test_pack_index_entry_generates_valid_json() {
|
||||
fn test_pack_index_entry_with_archive_url() {
|
||||
let pack_dir = create_test_pack("archive-test", "2.0.0", &[]);
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
let (mut cmd, _config_dir) = isolated_cmd();
|
||||
cmd.arg("--output")
|
||||
.arg("json")
|
||||
.arg("pack")
|
||||
@@ -227,7 +249,7 @@ fn test_pack_index_entry_missing_pack_yaml() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
fs::write(temp_dir.path().join("readme.txt"), "No pack.yaml here").unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
let (mut cmd, _config_dir) = isolated_cmd();
|
||||
cmd.arg("pack")
|
||||
.arg("index-entry")
|
||||
.arg(temp_dir.path().to_str().unwrap());
|
||||
@@ -244,7 +266,7 @@ fn test_pack_index_update_adds_new_entry() {
|
||||
|
||||
let pack_dir = create_test_pack("new-pack", "1.0.0", &[]);
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
let (mut cmd, _config_dir) = isolated_cmd();
|
||||
cmd.arg("pack")
|
||||
.arg("index-update")
|
||||
.arg("--index")
|
||||
@@ -273,7 +295,7 @@ fn test_pack_index_update_prevents_duplicate_without_flag() {
|
||||
|
||||
let pack_dir = create_test_pack("existing-pack", "1.0.0", &[]);
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
let (mut cmd, _config_dir) = isolated_cmd();
|
||||
cmd.arg("pack")
|
||||
.arg("index-update")
|
||||
.arg("--index")
|
||||
@@ -294,7 +316,7 @@ fn test_pack_index_update_with_update_flag() {
|
||||
|
||||
let pack_dir = create_test_pack("existing-pack", "2.0.0", &[]);
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
let (mut cmd, _config_dir) = isolated_cmd();
|
||||
cmd.arg("pack")
|
||||
.arg("index-update")
|
||||
.arg("--index")
|
||||
@@ -327,7 +349,7 @@ fn test_pack_index_update_invalid_index_file() {
|
||||
|
||||
let pack_dir = create_test_pack("test-pack", "1.0.0", &[]);
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
let (mut cmd, _config_dir) = isolated_cmd();
|
||||
cmd.arg("pack")
|
||||
.arg("index-update")
|
||||
.arg("--index")
|
||||
@@ -345,8 +367,10 @@ fn test_pack_index_merge_combines_indexes() {
|
||||
let output_dir = TempDir::new().unwrap();
|
||||
let output_path = output_dir.path().join("merged.json");
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.arg("pack")
|
||||
let (mut cmd, _config_dir) = isolated_cmd();
|
||||
cmd.arg("--output")
|
||||
.arg("table")
|
||||
.arg("pack")
|
||||
.arg("index-merge")
|
||||
.arg("--file")
|
||||
.arg(output_path.to_str().unwrap())
|
||||
@@ -372,8 +396,10 @@ fn test_pack_index_merge_deduplicates() {
|
||||
let output_dir = TempDir::new().unwrap();
|
||||
let output_path = output_dir.path().join("merged.json");
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.arg("pack")
|
||||
let (mut cmd, _config_dir) = isolated_cmd();
|
||||
cmd.arg("--output")
|
||||
.arg("table")
|
||||
.arg("pack")
|
||||
.arg("index-merge")
|
||||
.arg("--file")
|
||||
.arg(output_path.to_str().unwrap())
|
||||
@@ -403,7 +429,7 @@ fn test_pack_index_merge_output_exists_without_force() {
|
||||
let output_path = output_dir.path().join("merged.json");
|
||||
fs::write(&output_path, "existing content").unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
let (mut cmd, _config_dir) = isolated_cmd();
|
||||
cmd.arg("pack")
|
||||
.arg("index-merge")
|
||||
.arg("--file")
|
||||
@@ -423,7 +449,7 @@ fn test_pack_index_merge_with_force_flag() {
|
||||
let output_path = output_dir.path().join("merged.json");
|
||||
fs::write(&output_path, "existing content").unwrap();
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
let (mut cmd, _config_dir) = isolated_cmd();
|
||||
cmd.arg("pack")
|
||||
.arg("index-merge")
|
||||
.arg("--file")
|
||||
@@ -443,7 +469,7 @@ fn test_pack_index_merge_empty_input_list() {
|
||||
let output_dir = TempDir::new().unwrap();
|
||||
let output_path = output_dir.path().join("merged.json");
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
let (mut cmd, _config_dir) = isolated_cmd();
|
||||
cmd.arg("pack")
|
||||
.arg("index-merge")
|
||||
.arg("--file")
|
||||
@@ -459,8 +485,10 @@ fn test_pack_index_merge_missing_input_file() {
|
||||
let output_dir = TempDir::new().unwrap();
|
||||
let output_path = output_dir.path().join("merged.json");
|
||||
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.arg("pack")
|
||||
let (mut cmd, _config_dir) = isolated_cmd();
|
||||
cmd.arg("--output")
|
||||
.arg("table")
|
||||
.arg("pack")
|
||||
.arg("index-merge")
|
||||
.arg("--file")
|
||||
.arg(output_path.to_str().unwrap())
|
||||
@@ -483,7 +511,7 @@ fn test_pack_commands_help() {
|
||||
];
|
||||
|
||||
for args in commands {
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
let (mut cmd, _config_dir) = isolated_cmd();
|
||||
for arg in &args {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ async fn test_config_show_default() {
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("current_profile"))
|
||||
.stdout(predicate::str::contains("profile"))
|
||||
.stdout(predicate::str::contains("api_url"));
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ async fn test_config_show_json_output() {
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(r#""current_profile""#))
|
||||
.stdout(predicate::str::contains(r#""profile""#))
|
||||
.stdout(predicate::str::contains(r#""api_url""#));
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ async fn test_config_show_yaml_output() {
|
||||
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("current_profile:"))
|
||||
.stdout(predicate::str::contains("profile:"))
|
||||
.stdout(predicate::str::contains("api_url:"));
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ async fn test_config_set_api_url() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_config_set_output_format() {
|
||||
async fn test_config_set_format() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_default_config();
|
||||
|
||||
@@ -127,7 +127,7 @@ async fn test_config_set_output_format() {
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("set")
|
||||
.arg("output_format")
|
||||
.arg("format")
|
||||
.arg("json");
|
||||
|
||||
cmd.assert()
|
||||
@@ -137,7 +137,7 @@ async fn test_config_set_output_format() {
|
||||
// Verify the change was persisted
|
||||
let config_content =
|
||||
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
|
||||
assert!(config_content.contains("output_format: json"));
|
||||
assert!(config_content.contains("format: json"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -273,7 +273,7 @@ async fn test_profile_use_switch() {
|
||||
// Verify the current profile was changed
|
||||
let config_content =
|
||||
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
|
||||
assert!(config_content.contains("current_profile: staging"));
|
||||
assert!(config_content.contains("profile: staging"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -384,7 +384,7 @@ async fn test_profile_override_with_flag() {
|
||||
// Verify current profile wasn't changed in the config file
|
||||
let config_content =
|
||||
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
|
||||
assert!(config_content.contains("current_profile: default"));
|
||||
assert!(config_content.contains("profile: default"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -405,28 +405,35 @@ async fn test_profile_override_with_env_var() {
|
||||
// Verify current profile wasn't changed in the config file
|
||||
let config_content =
|
||||
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
|
||||
assert!(config_content.contains("current_profile: default"));
|
||||
assert!(config_content.contains("profile: default"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_with_custom_output_format() {
|
||||
async fn test_config_format_respected_by_commands() {
|
||||
let fixture = TestFixture::new().await;
|
||||
fixture.write_multi_profile_config();
|
||||
// Write a config with format set to json
|
||||
let config = format!(
|
||||
r#"
|
||||
profile: default
|
||||
format: json
|
||||
profiles:
|
||||
default:
|
||||
api_url: {}
|
||||
description: Test server
|
||||
"#,
|
||||
fixture.server_url()
|
||||
);
|
||||
fixture.write_config(&config);
|
||||
|
||||
// Switch to production which has json output format
|
||||
// Run config list without --json flag; should output JSON because config says so
|
||||
let mut cmd = Command::cargo_bin("attune").unwrap();
|
||||
cmd.env("XDG_CONFIG_HOME", fixture.config_dir_path())
|
||||
.env("HOME", fixture.config_dir_path())
|
||||
.arg("config")
|
||||
.arg("use")
|
||||
.arg("production");
|
||||
.arg("list");
|
||||
|
||||
cmd.assert().success();
|
||||
|
||||
// Verify the profile has custom output format
|
||||
let config_content =
|
||||
std::fs::read_to_string(&fixture.config_path).expect("Failed to read config");
|
||||
assert!(config_content.contains("output_format: json"));
|
||||
// JSON output contains curly braces
|
||||
cmd.assert().success().stdout(predicate::str::contains("{"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -443,7 +450,7 @@ async fn test_config_list_all_keys() {
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("api_url"))
|
||||
.stdout(predicate::str::contains("output_format"))
|
||||
.stdout(predicate::str::contains("format"))
|
||||
.stdout(predicate::str::contains("auth_token"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user