From 6084e0cc144460605f9c4abb1c081037c8c01fba Mon Sep 17 00:00:00 2001 From: Konstantinos Stefanidis Vozikis Date: Thu, 2 Apr 2026 17:38:02 +0200 Subject: [PATCH 1/3] feat: introduce catalog cli commands --- crates/tower-cmd/src/api.rs | 66 +++++++++++ crates/tower-cmd/src/catalogs.rs | 190 +++++++++++++++++++++++++++++++ crates/tower-cmd/src/lib.rs | 14 +++ 3 files changed, 270 insertions(+) create mode 100644 crates/tower-cmd/src/catalogs.rs diff --git a/crates/tower-cmd/src/api.rs b/crates/tower-cmd/src/api.rs index 778523e4..0297bf11 100644 --- a/crates/tower-cmd/src/api.rs +++ b/crates/tower-cmd/src/api.rs @@ -220,6 +220,50 @@ pub async fn export_catalogs( .await } +pub async fn list_catalogs( + config: &Config, + env: &str, + all: bool, +) -> Result< + tower_api::models::ListCatalogsResponse, + Error, +> { + let api_config = &config.into(); + + let params = tower_api::apis::default_api::ListCatalogsParams { + environment: Some(env.to_string()), + all: Some(all), + page: None, + page_size: None, + }; + + unwrap_api_response(tower_api::apis::default_api::list_catalogs( + api_config, params, + )) + .await +} + +pub async fn describe_catalog( + config: &Config, + name: &str, + env: &str, +) -> Result< + tower_api::models::DescribeCatalogResponse, + Error, +> { + let api_config = &config.into(); + + let params = tower_api::apis::default_api::DescribeCatalogParams { + name: name.to_string(), + environment: Some(env.to_string()), + }; + + unwrap_api_response(tower_api::apis::default_api::describe_catalog( + api_config, params, + )) + .await +} + pub async fn list_secrets( config: &Config, env: &str, @@ -609,6 +653,28 @@ impl ResponseEntity for tower_api::apis::default_api::ExportCatalogsSuccess { } } +impl ResponseEntity for tower_api::apis::default_api::ListCatalogsSuccess { + type Data = tower_api::models::ListCatalogsResponse; + + fn extract_data(self) -> Option { + match self { + Self::Status200(data) => Some(data), + Self::UnknownValue(_) => None, + } + } +} + +impl ResponseEntity for tower_api::apis::default_api::DescribeCatalogSuccess { + type Data = tower_api::models::DescribeCatalogResponse; + + fn extract_data(self) -> Option { + match self { + Self::Status200(data) => Some(data), + Self::UnknownValue(_) => None, + } + } +} + impl ResponseEntity for tower_api::apis::default_api::CreateSecretSuccess { type Data = tower_api::models::CreateSecretResponse; diff --git a/crates/tower-cmd/src/catalogs.rs b/crates/tower-cmd/src/catalogs.rs new file mode 100644 index 00000000..17854be3 --- /dev/null +++ b/crates/tower-cmd/src/catalogs.rs @@ -0,0 +1,190 @@ +use clap::{value_parser, Arg, ArgMatches, Command}; +use colored::Colorize; +use config::Config; + +use crate::{api, output, util::cmd}; + +pub fn catalogs_cmd() -> Command { + Command::new("catalogs") + .about("Interact with the catalogs in your Tower account") + .arg_required_else_help(true) + .subcommand( + Command::new("list") + .arg( + Arg::new("environment") + .short('e') + .long("environment") + .default_value("default") + .value_parser(value_parser!(String)) + .help("List catalogs in this environment") + .action(clap::ArgAction::Set), + ) + .arg( + Arg::new("all") + .short('a') + .long("all") + .help("List catalogs across all environments") + .action(clap::ArgAction::SetTrue), + ) + .about("List all of your catalogs"), + ) + .subcommand( + Command::new("show") + .arg( + Arg::new("catalog_name") + .value_parser(value_parser!(String)) + .index(1) + .required(true) + .help("Name of the catalog"), + ) + .arg( + Arg::new("environment") + .short('e') + .long("environment") + .default_value("default") + .value_parser(value_parser!(String)) + .help("Environment the catalog belongs to") + .action(clap::ArgAction::Set), + ) + .about("Show the details of a catalog, including its property names"), + ) +} + +pub async fn do_list(config: Config, args: &ArgMatches) { + let all = cmd::get_bool_flag(args, "all"); + let env = cmd::get_string_flag(args, "environment"); + + let list_response = + output::with_spinner("Listing catalogs", api::list_catalogs(&config, &env, all)).await; + + let headers = vec![ + "Name".bold().yellow().to_string(), + "Type".bold().yellow().to_string(), + "Environment".bold().yellow().to_string(), + ]; + let data = list_response + .catalogs + .iter() + .map(|catalog| { + vec![ + catalog.name.clone(), + catalog.r#type.clone(), + catalog.environment.clone(), + ] + }) + .collect(); + output::table(headers, data, Some(&list_response.catalogs)); +} + +pub async fn do_show(config: Config, args: &ArgMatches) { + let name = args + .get_one::("catalog_name") + .expect("catalog_name is required"); + let env = cmd::get_string_flag(args, "environment"); + + match api::describe_catalog(&config, name, &env).await { + Ok(response) => { + if output::get_output_mode().is_json() { + output::json(&response); + return; + } + + let catalog = &response.catalog; + + output::detail("Catalog", &catalog.name); + output::detail("Type", &catalog.r#type); + output::detail("Environment", &catalog.environment); + + if !catalog.properties.is_empty() { + output::newline(); + output::header("Properties"); + + let headers = vec![ + "Name".bold().yellow().to_string(), + "Preview".bold().yellow().to_string(), + ]; + let data = catalog + .properties + .iter() + .map(|prop| { + vec![ + prop.name.clone(), + prop.preview.dimmed().to_string(), + ] + }) + .collect(); + output::table(headers, data, Some(&response)); + } + } + Err(err) => output::tower_error_and_die(err, "Fetching catalog details failed"), + } +} + +#[cfg(test)] +mod tests { + use super::catalogs_cmd; + + #[test] + fn list_defaults_to_default_environment() { + let matches = catalogs_cmd() + .try_get_matches_from(["catalogs", "list"]) + .expect("list should parse with no args"); + + let (_, list_args) = matches.subcommand().expect("expected list subcommand"); + + assert_eq!(list_args.get_one::("environment").unwrap(), "default"); + assert_eq!(list_args.get_one::("all").copied(), Some(false)); + } + + #[test] + fn list_accepts_environment_flag() { + let matches = catalogs_cmd() + .try_get_matches_from(["catalogs", "list", "-e", "production"]) + .expect("list -e should parse"); + + let (_, list_args) = matches.subcommand().expect("expected list subcommand"); + + assert_eq!(list_args.get_one::("environment").unwrap(), "production"); + } + + #[test] + fn list_accepts_all_flag() { + let matches = catalogs_cmd() + .try_get_matches_from(["catalogs", "list", "--all"]) + .expect("list --all should parse"); + + let (_, list_args) = matches.subcommand().expect("expected list subcommand"); + + assert_eq!(list_args.get_one::("all").copied(), Some(true)); + } + + #[test] + fn show_requires_catalog_name() { + let result = catalogs_cmd().try_get_matches_from(["catalogs", "show"]); + assert!(result.is_err()); + } + + #[test] + fn show_accepts_catalog_name() { + let matches = catalogs_cmd() + .try_get_matches_from(["catalogs", "show", "my-catalog"]) + .expect("show with name should parse"); + + let (_, show_args) = matches.subcommand().expect("expected show subcommand"); + + assert_eq!(show_args.get_one::("catalog_name").unwrap(), "my-catalog"); + assert_eq!(show_args.get_one::("environment").unwrap(), "default"); + } + + #[test] + fn show_accepts_environment_override() { + let matches = catalogs_cmd() + .try_get_matches_from(["catalogs", "show", "my-catalog", "-e", "production"]) + .expect("show with -e should parse"); + + let (_, show_args) = matches.subcommand().expect("expected show subcommand"); + + assert_eq!(show_args.get_one::("catalog_name").unwrap(), "my-catalog"); + assert_eq!(show_args.get_one::("environment").unwrap(), "production"); + } +} diff --git a/crates/tower-cmd/src/lib.rs b/crates/tower-cmd/src/lib.rs index 3af7c0be..2d668f5a 100644 --- a/crates/tower-cmd/src/lib.rs +++ b/crates/tower-cmd/src/lib.rs @@ -3,6 +3,7 @@ use config::{Config, Session}; pub mod api; mod apps; +mod catalogs; mod deploy; mod environments; pub mod error; @@ -133,6 +134,18 @@ impl App { } } } + Some(("catalogs", sub_matches)) => { + let catalogs_command = sub_matches.subcommand(); + + match catalogs_command { + Some(("list", args)) => catalogs::do_list(sessionized_config, args).await, + Some(("show", args)) => catalogs::do_show(sessionized_config, args).await, + _ => { + catalogs::catalogs_cmd().print_help().unwrap(); + std::process::exit(2); + } + } + } Some(("secrets", sub_matches)) => { let secrets_command = sub_matches.subcommand(); @@ -232,6 +245,7 @@ fn root_cmd() -> Command { .arg_required_else_help(false) .subcommand(session::login_cmd()) .subcommand(apps::apps_cmd()) + .subcommand(catalogs::catalogs_cmd()) .subcommand(schedules::schedules_cmd()) .subcommand(secrets::secrets_cmd()) .subcommand(environments::environments_cmd()) From 97c7b1a0454f51217e729c011f6c0a626a34bba7 Mon Sep 17 00:00:00 2001 From: Konstantinos Stefanidis Vozikis Date: Mon, 6 Apr 2026 18:52:35 +0200 Subject: [PATCH 2/3] consolidate header style --- crates/tower-cmd/src/apps.rs | 2 +- crates/tower-cmd/src/catalogs.rs | 17 ++++++++--------- crates/tower-cmd/src/environments.rs | 6 +----- crates/tower-cmd/src/output.rs | 2 +- crates/tower-cmd/src/schedules.rs | 11 ++++------- crates/tower-cmd/src/secrets.rs | 18 ++++++++---------- crates/tower-cmd/src/teams.rs | 10 ++-------- 7 files changed, 25 insertions(+), 41 deletions(-) diff --git a/crates/tower-cmd/src/apps.rs b/crates/tower-cmd/src/apps.rs index b9d71576..9b90ec35 100644 --- a/crates/tower-cmd/src/apps.rs +++ b/crates/tower-cmd/src/apps.rs @@ -145,7 +145,7 @@ pub async fn do_show(config: Config, cmd: &ArgMatches) { let headers = vec!["#", "Status", "Start Time", "Elapsed Time"] .into_iter() - .map(|h| h.to_string()) + .map(str::to_string) .collect(); let rows = runs diff --git a/crates/tower-cmd/src/catalogs.rs b/crates/tower-cmd/src/catalogs.rs index 17854be3..5eaedaf0 100644 --- a/crates/tower-cmd/src/catalogs.rs +++ b/crates/tower-cmd/src/catalogs.rs @@ -57,11 +57,10 @@ pub async fn do_list(config: Config, args: &ArgMatches) { let list_response = output::with_spinner("Listing catalogs", api::list_catalogs(&config, &env, all)).await; - let headers = vec![ - "Name".bold().yellow().to_string(), - "Type".bold().yellow().to_string(), - "Environment".bold().yellow().to_string(), - ]; + let headers = vec!["Name", "Type", "Environment"] + .into_iter() + .map(str::to_string) + .collect(); let data = list_response .catalogs .iter() @@ -99,10 +98,10 @@ pub async fn do_show(config: Config, args: &ArgMatches) { output::newline(); output::header("Properties"); - let headers = vec![ - "Name".bold().yellow().to_string(), - "Preview".bold().yellow().to_string(), - ]; + let headers = vec!["Name", "Preview"] + .into_iter() + .map(str::to_string) + .collect(); let data = catalog .properties .iter() diff --git a/crates/tower-cmd/src/environments.rs b/crates/tower-cmd/src/environments.rs index 57609e5f..970677e4 100644 --- a/crates/tower-cmd/src/environments.rs +++ b/crates/tower-cmd/src/environments.rs @@ -1,5 +1,4 @@ use clap::{value_parser, Arg, ArgMatches, Command}; -use colored::Colorize; use config::Config; use crate::{api, output}; @@ -26,10 +25,7 @@ pub fn environments_cmd() -> Command { pub async fn do_list(config: Config) { let resp = output::with_spinner("Listing environments", api::list_environments(&config)).await; - let headers = vec!["Name"] - .into_iter() - .map(|h| h.yellow().to_string()) - .collect(); + let headers = vec!["Name".to_string()]; let envs_data: Vec> = resp .environments diff --git a/crates/tower-cmd/src/output.rs b/crates/tower-cmd/src/output.rs index 27af7e05..eb324ba6 100644 --- a/crates/tower-cmd/src/output.rs +++ b/crates/tower-cmd/src/output.rs @@ -458,7 +458,7 @@ pub fn table(headers: Vec, data: Vec>, json_da .table() .border(Border::builder().build()) .separator(separator) - .title(headers.iter().map(|h| h.yellow().to_string())); + .title(headers.iter().map(|h| h.bold().yellow().to_string())); if let Err(err) = print_stdout(table) { if err.kind() == io::ErrorKind::BrokenPipe { diff --git a/crates/tower-cmd/src/schedules.rs b/crates/tower-cmd/src/schedules.rs index bfdd4d13..59562cf2 100644 --- a/crates/tower-cmd/src/schedules.rs +++ b/crates/tower-cmd/src/schedules.rs @@ -125,13 +125,10 @@ pub async fn do_list(config: Config, args: &ArgMatches) { return; } - let headers = vec![ - "ID".yellow().to_string(), - "App".yellow().to_string(), - "Environment".yellow().to_string(), - "Cron".yellow().to_string(), - "Status".yellow().to_string(), - ]; + let headers = vec!["ID", "App", "Environment", "Cron", "Status"] + .into_iter() + .map(str::to_string) + .collect(); let rows: Vec> = response .schedules diff --git a/crates/tower-cmd/src/secrets.rs b/crates/tower-cmd/src/secrets.rs index 71198856..53c63596 100644 --- a/crates/tower-cmd/src/secrets.rs +++ b/crates/tower-cmd/src/secrets.rs @@ -112,11 +112,10 @@ pub async fn do_list(config: Config, args: &ArgMatches) { ) .await; - let headers = vec![ - "Secret".bold().yellow().to_string(), - "Environment".bold().yellow().to_string(), - "Value".bold().yellow().to_string(), - ]; + let headers = vec!["Secret", "Environment", "Value"] + .into_iter() + .map(str::to_string) + .collect(); let data = list_response .secrets .iter() @@ -137,11 +136,10 @@ pub async fn do_list(config: Config, args: &ArgMatches) { let list_response = output::with_spinner("Listing secrets", api::list_secrets(&config, &env, all)).await; - let headers = vec![ - "Secret".bold().yellow().to_string(), - "Environment".bold().yellow().to_string(), - "Preview".bold().yellow().to_string(), - ]; + let headers = vec!["Secret", "Environment", "Preview"] + .into_iter() + .map(str::to_string) + .collect(); let data = list_response .secrets .iter() diff --git a/crates/tower-cmd/src/teams.rs b/crates/tower-cmd/src/teams.rs index a667baf4..d0cc2413 100644 --- a/crates/tower-cmd/src/teams.rs +++ b/crates/tower-cmd/src/teams.rs @@ -58,10 +58,7 @@ pub async fn do_list(config: Config) { async fn do_list_via_api(config: &Config) { let resp = output::with_spinner("Fetching teams", api::list_teams(config)).await; - let headers = vec!["Name"] - .into_iter() - .map(|h| h.yellow().to_string()) - .collect(); + let headers = vec!["Name".to_string()]; let teams_data: Vec> = resp .teams @@ -83,10 +80,7 @@ async fn do_list_via_session(config: &Config) { let active_team_name = active_team.map(|team| team.name.clone()); // Create headers for the table - let headers = vec!["", "Name"] - .into_iter() - .map(|h| h.yellow().to_string()) - .collect(); + let headers = vec!["".to_string(), "Name".to_string()]; // Format the teams data for the table let teams = session.teams.clone(); From 6323464e95c12b1091b270bbac78d709806addfe Mon Sep 17 00:00:00 2001 From: Konstantinos Stefanidis Vozikis Date: Tue, 7 Apr 2026 01:07:55 +0200 Subject: [PATCH 3/3] fix cli_steps --- tests/integration/features/steps/cli_steps.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/integration/features/steps/cli_steps.py b/tests/integration/features/steps/cli_steps.py index 92d421a4..fb6d463f 100644 --- a/tests/integration/features/steps/cli_steps.py +++ b/tests/integration/features/steps/cli_steps.py @@ -314,12 +314,12 @@ def step_output_should_show_header_in_green_bold(context, header): @step("the table headers should be yellow colored") def step_table_headers_should_be_yellow(context): - """Verify table headers appear in yellow""" + """Verify table headers appear in bold yellow""" output = context.cli_output - yellow_code = "\x1b[33m" # Yellow text + yellow_bold_code = "\x1b[1;33m" assert ( - yellow_code in output - ), f"Expected yellow color codes in table headers, got: {output}" + yellow_bold_code in output + ), f"Expected bold yellow color codes in table headers, got: {output}" @step('the table should show columns "{column_list}"')