diff --git a/src/commands/images.rs b/src/commands/images.rs index f188b85..1b4ab63 100644 --- a/src/commands/images.rs +++ b/src/commands/images.rs @@ -135,25 +135,11 @@ async fn images_list(args: ImagesListArgs) -> Result<()> { let response = client.get(&path).await?; let body = response.bytes().await?; + let result = utils::handle_paginated_response::(&body, args.json)?; + if args.json { - utils::print_json_bytes(&body)?; return Ok(()); } - - #[derive(serde::Deserialize)] - #[serde(rename_all = "camelCase")] - struct ListResponse { - data: Vec, - pagination: PaginationInfo, - } - - #[derive(serde::Deserialize)] - #[serde(rename_all = "camelCase")] - struct PaginationInfo { - total_items: i64, - } - - let result: ListResponse = serde_json::from_slice(&body).context("failed to parse response")?; let headers = ["ID", "REPOSITORY:TAG", "SIZE", "IN USE"]; let rows = result .data diff --git a/src/commands/interactive.rs b/src/commands/interactive.rs new file mode 100644 index 0000000..c7d55d1 --- /dev/null +++ b/src/commands/interactive.rs @@ -0,0 +1,215 @@ +use anyhow::Result; +use clap::{Args, Parser}; +use dialoguer::{Input, Select, theme::ColorfulTheme}; +use std::future::Future; +use std::io::IsTerminal; +use std::pin::Pin; + +use crate::core::{output, utils}; + +use super::{Cli, run}; + +#[derive(Args, Debug)] +pub struct InteractiveCmd {} + +impl InteractiveCmd { + pub async fn run(self) -> Result<()> { + interactive_main().await + } +} + +async fn interactive_main() -> Result<()> { + if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { + anyhow::bail!("interactive mode requires a TTY"); + } + + loop { + let items = vec![ + "Auth", + "Config", + "Environments", + "Resources (list)", + "System", + "Exit", + ]; + + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Arcane CLI — Interactive") + .items(&items) + .default(0) + .interact_opt()?; + + match selection { + Some(0) => auth_menu().await?, + Some(1) => config_menu().await?, + Some(2) => environments_menu().await?, + Some(3) => resources_menu().await?, + Some(4) => system_menu().await?, + _ => break, + } + } + + Ok(()) +} + +async fn auth_menu() -> Result<()> { + let items = vec!["Login", "Logout", "Who am I", "Back"]; + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Auth") + .items(&items) + .default(0) + .interact_opt()?; + + match selection { + Some(0) => run_command(vec!["auth".into(), "login".into()]).await?, + Some(1) => run_command(vec!["auth".into(), "logout".into()]).await?, + Some(2) => run_command(vec!["auth".into(), "me".into()]).await?, + _ => {} + } + + Ok(()) +} + +async fn config_menu() -> Result<()> { + let items = vec![ + "Open config wizard", + "Show config", + "Test connection", + "Show config path", + "Back", + ]; + + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Config") + .items(&items) + .default(0) + .interact_opt()?; + + match selection { + Some(0) => run_command(vec!["config".into()]).await?, + Some(1) => run_command(vec!["config".into(), "show".into()]).await?, + Some(2) => run_command(vec!["config".into(), "test".into()]).await?, + Some(3) => run_command(vec!["config".into(), "path".into()]).await?, + _ => {} + } + + Ok(()) +} + +async fn environments_menu() -> Result<()> { + let items = vec![ + "List", + "Switch (interactive)", + "Get by ID", + "Test by ID", + "Back", + ]; + + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Environments") + .items(&items) + .default(0) + .interact_opt()?; + + match selection { + Some(0) => run_command(vec!["environments".into(), "list".into()]).await?, + Some(1) => run_command(vec!["environments".into(), "switch".into()]).await?, + Some(2) => { + if let Some(id) = prompt_id("Environment ID")? { + run_command(vec!["environments".into(), "get".into(), id]).await?; + } + } + Some(3) => { + if let Some(id) = prompt_id("Environment ID")? { + run_command(vec!["environments".into(), "test".into(), id]).await?; + } + } + _ => {} + } + + Ok(()) +} + +async fn resources_menu() -> Result<()> { + let items = vec![ + "Projects", + "Containers", + "Images", + "Networks", + "Volumes", + "Environments", + "Back", + ]; + + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Resources — List") + .items(&items) + .default(0) + .interact_opt()?; + + match selection { + Some(0) => run_command(vec!["projects".into(), "list".into()]).await?, + Some(1) => run_command(vec!["containers".into(), "list".into()]).await?, + Some(2) => run_command(vec!["images".into(), "list".into()]).await?, + Some(3) => run_command(vec!["networks".into(), "list".into()]).await?, + Some(4) => run_command(vec!["volumes".into(), "list".into()]).await?, + Some(5) => run_command(vec!["environments".into(), "list".into()]).await?, + _ => {} + } + + Ok(()) +} + +async fn system_menu() -> Result<()> { + let items = vec!["Docker info", "Upgrade check", "Prune system", "Back"]; + + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("System") + .items(&items) + .default(0) + .interact_opt()?; + + match selection { + Some(0) => run_command(vec!["system".into(), "docker-info".into()]).await?, + Some(1) => run_command(vec!["system".into(), "upgrade-check".into()]).await?, + Some(2) => { + let confirmed = utils::confirm( + "Prune unused system resources? This may remove unused images and data. (y/N): ", + )?; + if confirmed { + run_command(vec!["system".into(), "prune".into()]).await?; + } else { + output::info("Cancelled"); + } + } + _ => {} + } + + Ok(()) +} + +fn prompt_id(prompt: &str) -> Result> { + let input: String = Input::with_theme(&ColorfulTheme::default()) + .with_prompt(prompt) + .allow_empty(true) + .interact_text()?; + + let trimmed = input.trim(); + if trimmed.is_empty() { + Ok(None) + } else { + Ok(Some(trimmed.to_string())) + } +} + +fn run_command(args: Vec) -> Pin>>> { + Box::pin(async move { + let mut full_args = Vec::with_capacity(args.len() + 1); + full_args.push("arcane-cli".to_string()); + full_args.extend(args); + + let cli = Cli::try_parse_from(full_args).map_err(|err| anyhow::anyhow!(err.to_string()))?; + + run(cli).await + }) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index bef092d..6ff5dc4 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -11,6 +11,7 @@ pub mod generate; pub mod gitops; pub mod image_updates; pub mod images; +pub mod interactive; pub mod job_schedules; pub mod networks; pub mod notifications; @@ -56,6 +57,17 @@ pub struct Cli { help = "Enable detailed operation logging" )] pub verbose: bool, + #[arg(long = "no-color", global = true, help = "Disable colored output")] + pub no_color: bool, + #[arg(long = "no-spinner", global = true, help = "Disable progress spinners")] + pub no_spinner: bool, + #[arg( + short = 'y', + long = "yes", + global = true, + help = "Automatically answer yes to prompts" + )] + pub yes: bool, #[command(subcommand)] pub command: Option, } @@ -87,6 +99,8 @@ pub enum Command { ImageUpdates(image_updates::ImageUpdatesCmd), #[command(visible_aliases = ["image", "i"])] Images(images::ImagesCmd), + #[command(visible_aliases = ["menu", "ui"])] + Interactive(interactive::InteractiveCmd), #[command(name = "job-schedules", visible_aliases = ["jobs", "job-schedule", "schedules"])] JobSchedules(job_schedules::JobSchedulesCmd), #[command(visible_aliases = ["network", "net", "n"])] @@ -126,6 +140,7 @@ pub async fn run(cli: Cli) -> Result<()> { Command::GitOps(cmd) => cmd.run().await, Command::ImageUpdates(cmd) => cmd.run().await, Command::Images(cmd) => cmd.run().await, + Command::Interactive(cmd) => cmd.run().await, Command::JobSchedules(cmd) => cmd.run().await, Command::Networks(cmd) => cmd.run().await, Command::Notifications(cmd) => cmd.run().await, diff --git a/src/core/context.rs b/src/core/context.rs index e6a8fac..6d22b9a 100644 --- a/src/core/context.rs +++ b/src/core/context.rs @@ -1,3 +1,4 @@ +use std::io::IsTerminal; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; @@ -6,11 +7,28 @@ use indicatif::{ProgressBar, ProgressStyle}; /// Global output context controlling verbosity levels static QUIET_MODE: AtomicBool = AtomicBool::new(false); static VERBOSE_MODE: AtomicBool = AtomicBool::new(false); +static COLOR_MODE: AtomicBool = AtomicBool::new(true); +static SPINNER_MODE: AtomicBool = AtomicBool::new(true); +static ASSUME_YES: AtomicBool = AtomicBool::new(false); /// Initialize the output context with the given flags -pub fn init(quiet: bool, verbose: bool) { +pub fn init(quiet: bool, verbose: bool, no_color: bool, no_spinner: bool, assume_yes: bool) { QUIET_MODE.store(quiet, Ordering::SeqCst); VERBOSE_MODE.store(verbose, Ordering::SeqCst); + ASSUME_YES.store(assume_yes, Ordering::SeqCst); + + let stdout_is_tty = std::io::stdout().is_terminal(); + let color_enabled = !no_color + && stdout_is_tty + && std::env::var_os("NO_COLOR").is_none(); + COLOR_MODE.store(color_enabled, Ordering::SeqCst); + colored::control::set_override(color_enabled); + + let spinner_enabled = !no_spinner + && stdout_is_tty + && std::env::var_os("CI").is_none() + && std::env::var_os("NO_COLOR").is_none(); + SPINNER_MODE.store(spinner_enabled, Ordering::SeqCst); } /// Returns true if quiet mode is enabled @@ -23,10 +41,25 @@ pub fn is_verbose() -> bool { VERBOSE_MODE.load(Ordering::SeqCst) } +/// Returns true if colored output is enabled +pub fn use_color() -> bool { + COLOR_MODE.load(Ordering::SeqCst) +} + +/// Returns true if spinners are enabled +pub fn use_spinner() -> bool { + SPINNER_MODE.load(Ordering::SeqCst) +} + +/// Returns true if prompts should be auto-confirmed +pub fn assume_yes() -> bool { + ASSUME_YES.load(Ordering::SeqCst) +} + /// Create a spinner progress bar for long-running operations /// Returns None if quiet mode is enabled pub fn spinner(message: &str) -> Option { - if is_quiet() { + if is_quiet() || !use_spinner() { return None; } diff --git a/src/core/output.rs b/src/core/output.rs index 7795eb2..5dcf1cb 100644 --- a/src/core/output.rs +++ b/src/core/output.rs @@ -61,24 +61,32 @@ pub fn table(headers: &[&str], rows: &[Vec]) { return; } - println!(); - if headers.is_empty() { return; } + if rows.is_empty() { + info("No results."); + return; + } + + println!(); + let mut table = Table::new(); + let use_color = context::use_color(); table.set_header(headers.iter().map(|header| { - Cell::new(*header) - .add_attribute(Attribute::Bold) - .fg(Color::Cyan) + let mut cell = Cell::new(*header); + if use_color { + cell = cell.add_attribute(Attribute::Bold).fg(Color::Cyan); + } + cell })); for row in rows { let mut cells = Vec::with_capacity(row.len()); for (idx, value) in row.iter().enumerate() { let cell = Cell::new(value); - if idx == 0 { + if use_color && idx == 0 { cells.push(cell.fg(Color::Yellow)); } else { cells.push(cell); diff --git a/src/core/utils.rs b/src/core/utils.rs index 50307ae..71a292b 100644 --- a/src/core/utils.rs +++ b/src/core/utils.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use serde::Serialize; use std::io::{self, Write}; +use super::context; pub fn mask_if_empty(value: &str, fallback: &str) -> String { if value.trim().is_empty() { @@ -22,6 +23,9 @@ pub fn mask_api_key(key: &str) -> String { } pub fn confirm(prompt: &str) -> Result { + if context::assume_yes() { + return Ok(true); + } let response = read_line(prompt)?; let normalized = response.trim().to_lowercase(); Ok(normalized == "y" || normalized == "yes") @@ -80,15 +84,48 @@ pub fn print_json_bytes(bytes: &[u8]) -> Result<()> { Ok(()) } +fn extract_error_message(value: &serde_json::Value) -> Option { + value + .get("error") + .and_then(|v| v.as_str()) + .or_else(|| value.get("message").and_then(|v| v.as_str())) + .or_else(|| value.get("detail").and_then(|v| v.as_str())) + .map(|message| message.to_string()) +} + +fn response_error(value: &serde_json::Value) -> Option { + if let Some(success) = value.get("success").and_then(|s| s.as_bool()) { + if !success { + let err_msg = extract_error_message(value) + .unwrap_or_else(|| "unknown error occurred".to_string()); + return Some(anyhow::anyhow!(err_msg)); + } + } else if let Some(status) = value.get("status").and_then(|s| s.as_u64()) { + if status >= 400 { + let title = value.get("title").and_then(|t| t.as_str()).unwrap_or("Error"); + let detail = value.get("detail").and_then(|d| d.as_str()); + + if let Some(d) = detail { + return Some(anyhow::anyhow!("{}: {}", title, d)); + } + return Some(anyhow::anyhow!("API Error ({}): {}", status, title)); + } + } + + None +} + /// Handle JSON output for commands - parse typed response and output data field pub fn handle_json_response( bytes: &[u8], json_flag: bool, ) -> Result { - let body_str = String::from_utf8_lossy(bytes); - tracing::debug!("Parsing response: {}", body_str); + if tracing::enabled!(tracing::Level::DEBUG) { + tracing::debug!("Parsing response: {}", String::from_utf8_lossy(bytes)); + } let v: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| { + let body_str = String::from_utf8_lossy(bytes); anyhow::anyhow!( "failed to parse response as JSON: {}\nBody: {}", e, @@ -96,26 +133,8 @@ pub fn handle_json_response( ) })?; - if let Some(success) = v.get("success").and_then(|s| s.as_bool()) { - if !success { - let err_msg = v - .get("error") - .and_then(|e| e.as_str()) - .unwrap_or("unknown error occurred"); - anyhow::bail!("{}", err_msg); - } - } else if let Some(status) = v.get("status").and_then(|s| s.as_u64()) { - // Handle ASP.NET Style ErrorModel (status, title, detail) - if status >= 400 { - let title = v.get("title").and_then(|t| t.as_str()).unwrap_or("Error"); - let detail = v.get("detail").and_then(|d| d.as_str()); - - if let Some(d) = detail { - anyhow::bail!("{}: {}", title, d); - } else { - anyhow::bail!("API Error ({}): {}", status, title); - } - } + if let Some(err) = response_error(&v) { + return Err(err); } let data_val = match v.get("data") { @@ -128,8 +147,9 @@ pub fn handle_json_response( &v } }; - let data_str = serde_json::to_string_pretty(data_val).unwrap_or_default(); let data: T = serde_json::from_value(data_val.clone()).map_err(|e| { + let data_str = serde_json::to_string_pretty(data_val) + .unwrap_or_else(|_| data_val.to_string()); anyhow::anyhow!( "failed to parse response data: {}\nData being parsed: {}", e, @@ -149,8 +169,33 @@ pub fn handle_paginated_response( bytes: &[u8], json_flag: bool, ) -> Result> { + if tracing::enabled!(tracing::Level::DEBUG) { + tracing::debug!("Parsing response: {}", String::from_utf8_lossy(bytes)); + } + + let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| { + let body_str = String::from_utf8_lossy(bytes); + anyhow::anyhow!( + "failed to parse response as JSON: {}\nBody: {}", + e, + body_str + ) + })?; + + if let Some(err) = response_error(&value) { + return Err(err); + } + let result: crate::types::base::Paginated = - serde_json::from_slice(bytes).context("failed to parse response")?; + serde_json::from_value(value.clone()).map_err(|e| { + let data_str = serde_json::to_string_pretty(&value) + .unwrap_or_else(|_| value.to_string()); + anyhow::anyhow!( + "failed to parse response data: {}\nData being parsed: {}", + e, + data_str + ) + })?; if json_flag { print_json(&result)?; @@ -164,5 +209,11 @@ pub fn handle_generic_response(bytes: &[u8], json_flag: bool) -> Result<()> { if json_flag { print_json_bytes(bytes)?; } + + if let Ok(value) = serde_json::from_slice::(bytes) { + if let Some(err) = response_error(&value) { + return Err(err); + } + } Ok(()) } diff --git a/src/main.rs b/src/main.rs index b7feded..d0f6844 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,13 @@ async fn main() -> anyhow::Result<()> { } // Initialize output context (quiet/verbose modes) - core::context::init(cli.quiet, cli.verbose); + core::context::init( + cli.quiet, + cli.verbose, + cli.no_color, + cli.no_spinner, + cli.yes, + ); // Determine log level: verbose flag sets debug, quiet flag sets warn let log_level = if cli.verbose {