From 18f31a5ed4dcf7d2adc2f7d61ff8b60cbcb634a1 Mon Sep 17 00:00:00 2001 From: Paul Thurlow Date: Mon, 16 Mar 2026 14:06:44 -0700 Subject: [PATCH 1/7] Add connection creation list command --- src/command.rs | 37 ++++++++++------------ src/connections.rs | 78 ++++++++++++++++++++++++++++++++++++++++++++-- src/datasets.rs | 14 +++------ src/main.rs | 8 ++++- src/util.rs | 7 +++++ 5 files changed, 111 insertions(+), 33 deletions(-) diff --git a/src/command.rs b/src/command.rs index 2f2f6eb..f2b4dc7 100644 --- a/src/command.rs +++ b/src/command.rs @@ -285,6 +285,20 @@ pub enum WorkspaceCommands { }, } +#[derive(Subcommand)] +pub enum ConnectionsCreateCommands { + /// List available connection types + List { + /// Workspace ID (defaults to first workspace from login) + #[arg(long)] + workspace_id: Option, + + /// Output format + #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] + format: String, + }, +} + #[derive(Subcommand)] pub enum ConnectionsCommands { /// List all connections for a workspace @@ -312,27 +326,10 @@ pub enum ConnectionsCommands { format: String, }, - /// Create a new connection in a workspace + /// Create a new connection Create { - /// Workspace ID (defaults to first workspace from login) - #[arg(long)] - workspace_id: Option, - - /// Connection name - #[arg(long)] - name: String, - - /// Connection type - #[arg(long = "type")] - conn_type: String, - - /// Connection config as JSON string - #[arg(long)] - config: String, - - /// Output format - #[arg(long, default_value = "yaml", value_parser = ["table", "json", "yaml"])] - format: String, + #[command(subcommand)] + command: ConnectionsCreateCommands, }, /// Update a connection in a workspace diff --git a/src/connections.rs b/src/connections.rs index c07db78..8937775 100644 --- a/src/connections.rs +++ b/src/connections.rs @@ -1,6 +1,79 @@ use crate::config; use serde::{Deserialize, Serialize}; +#[derive(Deserialize, Serialize)] +struct ConnectionType { + name: String, + label: String, +} + +#[derive(Deserialize)] +struct ListConnectionTypesResponse { + connection_types: Vec, +} + +pub fn types_list(workspace_id: &str, format: &str) { + let profile_config = match config::load("default") { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + + let api_key = match &profile_config.api_key { + Some(key) if key != "PLACEHOLDER" => key.clone(), + _ => { + eprintln!("error: not authenticated. Run 'hotdata auth login' to log in."); + std::process::exit(1); + } + }; + + let url = format!("{}/connection-types", profile_config.api_url); + let client = reqwest::blocking::Client::new(); + + let resp = match client + .get(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("X-Workspace-Id", workspace_id) + .send() + { + Ok(r) => r, + Err(e) => { + eprintln!("error connecting to API: {e}"); + std::process::exit(1); + } + }; + + if !resp.status().is_success() { + use crossterm::style::Stylize; + eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); + std::process::exit(1); + } + + let body: ListConnectionTypesResponse = match resp.json() { + Ok(b) => b, + Err(e) => { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + } + }; + + match format { + "json" => println!("{}", serde_json::to_string_pretty(&body.connection_types).unwrap()), + "yaml" => print!("{}", serde_yaml::to_string(&body.connection_types).unwrap()), + "table" => { + let mut table = crate::util::make_table(); + table.set_header(["NAME", "LABEL"]); + for ct in &body.connection_types { + table.add_row([&ct.name, &ct.label]); + } + println!("{table}"); + } + _ => unreachable!(), + } +} + #[derive(Deserialize, Serialize)] struct Connection { id: String, @@ -47,7 +120,8 @@ pub fn list(workspace_id: &str, format: &str) { }; if !resp.status().is_success() { - eprintln!("error: HTTP {}", resp.status()); + use crossterm::style::Stylize; + eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); std::process::exit(1); } @@ -68,7 +142,7 @@ pub fn list(workspace_id: &str, format: &str) { } "table" => { let mut table = crate::util::make_table(); - table.set_header(["ID", "NAME", "SOURCE_TYPE"]); + table.set_header(["ID", "NAME", "SOURCE TYPE"]); for c in &body.connections { table.add_row([&c.id, &c.name, &c.source_type]); } diff --git a/src/datasets.rs b/src/datasets.rs index 15d993c..970de1c 100644 --- a/src/datasets.rs +++ b/src/datasets.rs @@ -97,12 +97,6 @@ fn stdin_redirect_filename() -> Option { } } -fn api_error(body: String) -> String { - serde_json::from_str::(&body) - .ok() - .and_then(|v| v["error"]["message"].as_str().map(str::to_string)) - .unwrap_or(body) -} fn make_progress_bar(total: u64) -> ProgressBar { let pb = ProgressBar::new(total); @@ -147,7 +141,7 @@ fn do_upload( if !resp.status().is_success() { use crossterm::style::Stylize; - eprintln!("{}", api_error(resp.text().unwrap_or_default()).red()); + eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); std::process::exit(1); } @@ -324,7 +318,7 @@ pub fn create( if !resp.status().is_success() { use crossterm::style::Stylize; - eprintln!("{}", api_error(resp.text().unwrap_or_default()).red()); + eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); // Only show the resume hint when the upload_id came from a fresh upload if upload_id_was_uploaded { eprintln!( @@ -392,7 +386,7 @@ pub fn list(workspace_id: &str, limit: Option, offset: Option, format: if !resp.status().is_success() { use crossterm::style::Stylize; - eprintln!("{}", api_error(resp.text().unwrap_or_default()).red()); + eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); std::process::exit(1); } @@ -464,7 +458,7 @@ pub fn get(dataset_id: &str, workspace_id: &str, format: &str) { if !resp.status().is_success() { use crossterm::style::Stylize; - eprintln!("{}", api_error(resp.text().unwrap_or_default()).red()); + eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); std::process::exit(1); } diff --git a/src/main.rs b/src/main.rs index 01d3aa2..249fbce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ mod workspace; use anstyle::AnsiColor; use clap::{Parser, builder::Styles}; -use command::{AuthCommands, Commands, ConnectionsCommands, DatasetsCommands, SkillCommands, TablesCommands, WorkspaceCommands}; +use command::{AuthCommands, Commands, ConnectionsCommands, ConnectionsCreateCommands, DatasetsCommands, SkillCommands, TablesCommands, WorkspaceCommands}; #[derive(Parser)] #[command(name = "hotdata", version, about = concat!("HotData CLI - Command line interface for HotData (v", env!("CARGO_PKG_VERSION"), ")"), long_about = None, disable_version_flag = true)] @@ -91,6 +91,12 @@ fn main() { _ => eprintln!("not yet implemented"), }, Commands::Connections { command } => match command { + ConnectionsCommands::Create { command } => match command { + ConnectionsCreateCommands::List { workspace_id, format } => { + let workspace_id = resolve_workspace(workspace_id); + connections::types_list(&workspace_id, &format) + }, + }, ConnectionsCommands::List { workspace_id, format } => { let workspace_id = resolve_workspace(workspace_id); connections::list(&workspace_id, &format) diff --git a/src/util.rs b/src/util.rs index c37e78d..4a0dce3 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,3 +1,10 @@ +pub fn api_error(body: String) -> String { + serde_json::from_str::(&body) + .ok() + .and_then(|v| v["error"]["message"].as_str().map(str::to_string)) + .unwrap_or(body) +} + pub fn make_table() -> comfy_table::Table { let mut table = comfy_table::Table::new(); table.load_preset(comfy_table::presets::UTF8_FULL_CONDENSED); From bc1a0fee50f5f42c743901a5512c127f0d1294aa Mon Sep 17 00:00:00 2001 From: Paul Thurlow Date: Mon, 16 Mar 2026 14:10:22 -0700 Subject: [PATCH 2/7] Add single connection creation details --- src/command.rs | 5 +++- src/connections.rs | 72 ++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 7 +++-- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/src/command.rs b/src/command.rs index f2b4dc7..df9a4e8 100644 --- a/src/command.rs +++ b/src/command.rs @@ -287,8 +287,11 @@ pub enum WorkspaceCommands { #[derive(Subcommand)] pub enum ConnectionsCreateCommands { - /// List available connection types + /// List available connection types, or get details for a specific type List { + /// Connection type name (e.g. postgres, mysql); omit to list all + name: Option, + /// Workspace ID (defaults to first workspace from login) #[arg(long)] workspace_id: Option, diff --git a/src/connections.rs b/src/connections.rs index 8937775..50a7304 100644 --- a/src/connections.rs +++ b/src/connections.rs @@ -12,6 +12,14 @@ struct ListConnectionTypesResponse { connection_types: Vec, } +#[derive(Deserialize, Serialize)] +struct ConnectionTypeDetail { + name: String, + label: String, + config_schema: Option, + auth: Option, +} + pub fn types_list(workspace_id: &str, format: &str) { let profile_config = match config::load("default") { Ok(c) => c, @@ -74,6 +82,70 @@ pub fn types_list(workspace_id: &str, format: &str) { } } +pub fn types_get(workspace_id: &str, name: &str, format: &str) { + let profile_config = match config::load("default") { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + + let api_key = match &profile_config.api_key { + Some(key) if key != "PLACEHOLDER" => key.clone(), + _ => { + eprintln!("error: not authenticated. Run 'hotdata auth login' to log in."); + std::process::exit(1); + } + }; + + let url = format!("{}/connection-types/{name}", profile_config.api_url); + let client = reqwest::blocking::Client::new(); + + let resp = match client + .get(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("X-Workspace-Id", workspace_id) + .send() + { + Ok(r) => r, + Err(e) => { + eprintln!("error connecting to API: {e}"); + std::process::exit(1); + } + }; + + if !resp.status().is_success() { + use crossterm::style::Stylize; + eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); + std::process::exit(1); + } + + let detail: ConnectionTypeDetail = match resp.json() { + Ok(d) => d, + Err(e) => { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + } + }; + + match format { + "json" => println!("{}", serde_json::to_string_pretty(&detail).unwrap()), + "yaml" => print!("{}", serde_yaml::to_string(&detail).unwrap()), + "table" => { + println!("name: {}", detail.name); + println!("label: {}", detail.label); + if let Some(schema) = &detail.config_schema { + println!("config: {}", serde_json::to_string_pretty(schema).unwrap()); + } + if let Some(auth) = &detail.auth { + println!("auth: {}", serde_json::to_string_pretty(auth).unwrap()); + } + } + _ => unreachable!(), + } +} + #[derive(Deserialize, Serialize)] struct Connection { id: String, diff --git a/src/main.rs b/src/main.rs index 249fbce..3a2c6a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -92,9 +92,12 @@ fn main() { }, Commands::Connections { command } => match command { ConnectionsCommands::Create { command } => match command { - ConnectionsCreateCommands::List { workspace_id, format } => { + ConnectionsCreateCommands::List { name, workspace_id, format } => { let workspace_id = resolve_workspace(workspace_id); - connections::types_list(&workspace_id, &format) + match name.as_deref() { + Some(name) => connections::types_get(&workspace_id, name, &format), + None => connections::types_list(&workspace_id, &format), + } }, }, ConnectionsCommands::List { workspace_id, format } => { From 150ef55627d1d452b99cd7bd85cf4a33161bf645 Mon Sep 17 00:00:00 2001 From: Paul Thurlow Date: Mon, 16 Mar 2026 14:56:38 -0700 Subject: [PATCH 3/7] Add connection creation command --- src/command.rs | 32 +++++++++++++- src/connections.rs | 107 +++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 37 +++++++++++++--- 3 files changed, 167 insertions(+), 9 deletions(-) diff --git a/src/command.rs b/src/command.rs index df9a4e8..10ff5e8 100644 --- a/src/command.rs +++ b/src/command.rs @@ -329,10 +329,38 @@ pub enum ConnectionsCommands { format: String, }, - /// Create a new connection + /// Create a new connection, or list/inspect available connection types Create { #[command(subcommand)] - command: ConnectionsCreateCommands, + command: Option, + + /// Workspace ID (defaults to first workspace from login) + #[arg(long)] + workspace_id: Option, + + /// Connection name + #[arg(long)] + name: Option, + + /// Connection source type (e.g. postgres, mysql, snowflake) + #[arg(long = "type")] + source_type: Option, + + /// Connection config as a JSON object + #[arg(long)] + config: Option, + + /// Reference to a secret by ID for authentication + #[arg(long, conflicts_with = "secret_name")] + secret_id: Option, + + /// Reference to a secret by name for authentication + #[arg(long, conflicts_with = "secret_id")] + secret_name: Option, + + /// Output format + #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] + format: String, }, /// Update a connection in a workspace diff --git a/src/connections.rs b/src/connections.rs index 50a7304..92fa20a 100644 --- a/src/connections.rs +++ b/src/connections.rs @@ -158,6 +158,113 @@ struct ListResponse { connections: Vec, } +pub fn create( + workspace_id: &str, + name: &str, + source_type: &str, + config: &str, + secret_id: Option<&str>, + secret_name: Option<&str>, + format: &str, +) { + let profile_config = match crate::config::load("default") { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + + let api_key = match &profile_config.api_key { + Some(key) if key != "PLACEHOLDER" => key.clone(), + _ => { + eprintln!("error: not authenticated. Run 'hotdata auth login' to log in."); + std::process::exit(1); + } + }; + + let config_value: serde_json::Value = match serde_json::from_str(config) { + Ok(v) => v, + Err(e) => { + eprintln!("error: --config must be a valid JSON object: {e}"); + std::process::exit(1); + } + }; + + let mut body = serde_json::json!({ + "name": name, + "source_type": source_type, + "config": config_value, + }); + if let Some(id) = secret_id { + body["secret_id"] = serde_json::json!(id); + } + if let Some(sn) = secret_name { + body["secret_name"] = serde_json::json!(sn); + } + + let url = format!("{}/connections", profile_config.api_url); + let client = reqwest::blocking::Client::new(); + + let resp = match client + .post(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("X-Workspace-Id", workspace_id) + .json(&body) + .send() + { + Ok(r) => r, + Err(e) => { + eprintln!("error connecting to API: {e}"); + std::process::exit(1); + } + }; + + if !resp.status().is_success() { + use crossterm::style::Stylize; + eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); + std::process::exit(1); + } + + #[derive(Deserialize, Serialize)] + struct CreateResponse { + id: String, + name: String, + source_type: String, + tables_discovered: u64, + discovery_status: String, + discovery_error: Option, + } + + let result: CreateResponse = match resp.json() { + Ok(v) => v, + Err(e) => { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + } + }; + + match format { + "json" => println!("{}", serde_json::to_string_pretty(&result).unwrap()), + "yaml" => print!("{}", serde_yaml::to_string(&result).unwrap()), + "table" => { + use crossterm::style::Stylize; + println!("{}", "Connection created".green()); + println!("id: {}", result.id); + println!("name: {}", result.name); + println!("source_type: {}", result.source_type); + println!("tables_discovered: {}", result.tables_discovered); + let status_colored = match result.discovery_status.as_str() { + "success" => result.discovery_status.green().to_string(), + "failed" => result.discovery_error.as_deref().unwrap_or("failed").red().to_string(), + _ => result.discovery_status.yellow().to_string(), + }; + println!("discovery_status: {status_colored}"); + } + _ => unreachable!(), + } +} + pub fn list(workspace_id: &str, format: &str) { let profile_config = match config::load("default") { Ok(c) => c, diff --git a/src/main.rs b/src/main.rs index 3a2c6a5..1199735 100644 --- a/src/main.rs +++ b/src/main.rs @@ -91,14 +91,37 @@ fn main() { _ => eprintln!("not yet implemented"), }, Commands::Connections { command } => match command { - ConnectionsCommands::Create { command } => match command { - ConnectionsCreateCommands::List { name, workspace_id, format } => { - let workspace_id = resolve_workspace(workspace_id); - match name.as_deref() { - Some(name) => connections::types_get(&workspace_id, name, &format), - None => connections::types_list(&workspace_id, &format), + ConnectionsCommands::Create { command, workspace_id, name, source_type, config, secret_id, secret_name, format } => { + match command { + Some(ConnectionsCreateCommands::List { name, workspace_id, format }) => { + let workspace_id = resolve_workspace(workspace_id); + match name.as_deref() { + Some(name) => connections::types_get(&workspace_id, name, &format), + None => connections::types_list(&workspace_id, &format), + } + } + None => { + let missing: Vec<&str> = [ + name.is_none().then_some("--name"), + source_type.is_none().then_some("--type"), + config.is_none().then_some("--config"), + ].into_iter().flatten().collect(); + if !missing.is_empty() { + eprintln!("error: missing required arguments: {}", missing.join(", ")); + std::process::exit(1); + } + let workspace_id = resolve_workspace(workspace_id); + connections::create( + &workspace_id, + &name.unwrap(), + &source_type.unwrap(), + &config.unwrap(), + secret_id.as_deref(), + secret_name.as_deref(), + &format, + ) } - }, + } }, ConnectionsCommands::List { workspace_id, format } => { let workspace_id = resolve_workspace(workspace_id); From 893c706f4aa51f215f3a2524bb10f9a96ab9b28d Mon Sep 17 00:00:00 2001 From: Paul Thurlow Date: Mon, 16 Mar 2026 15:32:19 -0700 Subject: [PATCH 4/7] Add interactive connection creation flow --- Cargo.lock | 83 ++++++++++ Cargo.toml | 1 + src/command.rs | 7 + src/connections_new.rs | 336 +++++++++++++++++++++++++++++++++++++++++ src/main.rs | 5 + 5 files changed, 432 insertions(+) create mode 100644 src/connections_new.rs diff --git a/Cargo.lock b/Cargo.lock index 8041168..475ff31 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -207,6 +207,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -275,9 +284,13 @@ checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ "bitflags", "crossterm_winapi", + "derive_more", "document-features", + "mio", "parking_lot", "rustix 1.1.4", + "signal-hook", + "signal-hook-mio", "winapi", ] @@ -300,6 +313,28 @@ dependencies = [ "typenum", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -357,6 +392,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -506,6 +547,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -593,6 +643,7 @@ dependencies = [ "dotenvy", "flate2", "indicatif", + "inquire", "nix", "open", "rand", @@ -863,6 +914,20 @@ dependencies = [ "web-time", ] +[[package]] +name = "inquire" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" +dependencies = [ + "bitflags", + "crossterm 0.29.0", + "dyn-clone", + "fuzzy-matcher", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1357,6 +1422,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -1730,6 +1804,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tiny_http" version = "0.12.0" diff --git a/Cargo.toml b/Cargo.toml index 45916de..d1cd1e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ rand = "0.8" sha2 = "0.10" tiny_http = "0.12" comfy-table = "7" +inquire = "0.9.4" indicatif = "0.17" nix = { version = "0.29", features = ["fs"] } flate2 = "1" diff --git a/src/command.rs b/src/command.rs index 10ff5e8..431fb65 100644 --- a/src/command.rs +++ b/src/command.rs @@ -304,6 +304,13 @@ pub enum ConnectionsCreateCommands { #[derive(Subcommand)] pub enum ConnectionsCommands { + /// Interactively create a new connection + New { + /// Workspace ID (defaults to first workspace from login) + #[arg(long)] + workspace_id: Option, + }, + /// List all connections for a workspace List { /// Workspace ID (defaults to first workspace from login) diff --git a/src/connections_new.rs b/src/connections_new.rs new file mode 100644 index 0000000..64c9e6a --- /dev/null +++ b/src/connections_new.rs @@ -0,0 +1,336 @@ +use inquire::{Confirm, Password, Select, Text}; +use inquire::validator::Validation; +use serde_json::{Map, Number, Value}; + +// ── HTTP helpers ────────────────────────────────────────────────────────────── + +struct ConnectionTypeSummary { + name: String, + label: String, +} + +struct ConnectionTypeDetail { + name: String, + config_schema: Option, + auth: Option, +} + +fn load_client() -> (reqwest::blocking::Client, String, String) { + let profile = match crate::config::load("default") { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + let api_key = match &profile.api_key { + Some(k) if k != "PLACEHOLDER" => k.clone(), + _ => { + eprintln!("error: not authenticated. Run 'hotdata auth login' to log in."); + std::process::exit(1); + } + }; + (reqwest::blocking::Client::new(), api_key, profile.api_url.to_string()) +} + +fn fetch_types(workspace_id: &str) -> Vec { + let (client, api_key, api_url) = load_client(); + let url = format!("{api_url}/connection-types"); + let resp = client + .get(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("X-Workspace-Id", workspace_id) + .send() + .unwrap_or_else(|e| { eprintln!("error: {e}"); std::process::exit(1) }); + if !resp.status().is_success() { + eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default())); + std::process::exit(1); + } + let body: Value = resp.json().unwrap_or_else(|e| { eprintln!("error: {e}"); std::process::exit(1) }); + body["connection_types"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|v| { + Some(ConnectionTypeSummary { + name: v["name"].as_str()?.to_string(), + label: v["label"].as_str()?.to_string(), + }) + }) + .collect() +} + +fn fetch_detail(workspace_id: &str, name: &str) -> ConnectionTypeDetail { + let (client, api_key, api_url) = load_client(); + let url = format!("{api_url}/connection-types/{name}"); + let resp = client + .get(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("X-Workspace-Id", workspace_id) + .send() + .unwrap_or_else(|e| { eprintln!("error: {e}"); std::process::exit(1) }); + if !resp.status().is_success() { + eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default())); + std::process::exit(1); + } + let body: Value = resp.json().unwrap_or_else(|e| { eprintln!("error: {e}"); std::process::exit(1) }); + ConnectionTypeDetail { + name: body["name"].as_str().unwrap_or(name).to_string(), + config_schema: if body["config_schema"].is_null() { None } else { Some(body["config_schema"].clone()) }, + auth: if body["auth"].is_null() { None } else { Some(body["auth"].clone()) }, + } +} + +// ── Schema walkers ──────────────────────────────────────────────────────────── + +/// Walk a flat JSON Schema object and return collected field values. +fn walk_properties(schema: &Value) -> Map { + let mut out = Map::new(); + let required: Vec<&str> = schema["required"] + .as_array() + .map(|a| a.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + + let Some(props) = schema["properties"].as_object() else { return out }; + + for (key, field) in props { + let is_required = required.contains(&key.as_str()); + if let Some(val) = prompt_field(key, field, is_required) { + out.insert(key.clone(), val); + } + } + out +} + +/// Walk a oneOf variant — same as walk_properties but auto-injects `const` fields. +fn walk_variant(schema: &Value) -> Map { + let mut out = Map::new(); + let required: Vec<&str> = schema["required"] + .as_array() + .map(|a| a.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + + let Some(props) = schema["properties"].as_object() else { return out }; + + for (key, field) in props { + // Auto-inject const fields without prompting + if let Some(const_val) = field.get("const") { + out.insert(key.clone(), const_val.clone()); + continue; + } + let is_required = required.contains(&key.as_str()); + if let Some(val) = prompt_field(key, field, is_required) { + out.insert(key.clone(), val); + } + } + out +} + +fn prompt_field(key: &str, field: &Value, is_required: bool) -> Option { + // Field is itself a oneOf (e.g. iceberg's catalog_type) + if let Some(one_of) = field["oneOf"].as_array() { + let titles: Vec = one_of + .iter() + .filter_map(|v| v["title"].as_str().map(str::to_string)) + .collect(); + let selected = Select::new(&format!("{key}:"), titles.clone()) + .prompt() + .unwrap_or_else(|_| std::process::exit(0)); + let idx = titles.iter().position(|t| t == &selected).unwrap(); + let nested = walk_variant(&one_of[idx]); + return Some(Value::Object(nested)); + } + + let field_type = field["type"].as_str().unwrap_or("string"); + let format = field["format"].as_str().unwrap_or(""); + let opt_hint = "optional — press Enter to skip"; + + match (field_type, format) { + ("string", "password") => { + let label = format!("{key}:"); + let mut p = Password::new(&label).without_confirmation(); + if !is_required { + p = p.with_help_message(opt_hint); + } + let val = p.prompt().unwrap_or_else(|_| std::process::exit(0)); + if val.is_empty() && !is_required { None } else { Some(Value::String(val)) } + } + + ("string", _) => { + let label = format!("{key}:"); + let mut t = Text::new(&label); + if let Some(default) = field["default"].as_str() { + t = t.with_default(default); + } + if !is_required { + t = t.with_help_message(opt_hint); + } + let val = t.prompt().unwrap_or_else(|_| std::process::exit(0)); + if val.is_empty() && !is_required { None } else { Some(Value::String(val)) } + } + + ("integer", _) => { + let label = format!("{key}:"); + let t = Text::new(&label) + .with_validator(|input: &str| { + if input.is_empty() || input.parse::().is_ok() { + Ok(Validation::Valid) + } else { + Ok(Validation::Invalid("Must be a whole number".into())) + } + }); + let help_t; + let t = if !is_required { + help_t = t.with_help_message(opt_hint); + help_t + } else { + t + }; + let val = t.prompt().unwrap_or_else(|_| std::process::exit(0)); + if val.is_empty() && !is_required { + None + } else { + val.parse::().ok().map(|n| Value::Number(Number::from(n))) + } + } + + ("boolean", _) => { + let label = format!("{key}:"); + let default = field["default"].as_bool().unwrap_or(false); + let val = Confirm::new(&label) + .with_default(default) + .prompt() + .unwrap_or_else(|_| std::process::exit(0)); + Some(Value::Bool(val)) + } + + ("array", _) => { + let label = format!("{key}:"); + let help = if is_required { + "Enter values separated by commas" + } else { + "Enter values separated by commas — optional, press Enter to skip" + }; + let val = Text::new(&label) + .with_placeholder("value1, value2, ...") + .with_help_message(help) + .prompt() + .unwrap_or_else(|_| std::process::exit(0)); + if val.is_empty() && !is_required { + None + } else { + let items = val + .split(',') + .map(|s| Value::String(s.trim().to_string())) + .collect(); + Some(Value::Array(items)) + } + } + + _ => None, + } +} + +fn walk_auth(schema: &Value) -> Map { + // Multiple auth methods + if let Some(one_of) = schema["oneOf"].as_array() { + let titles: Vec = one_of + .iter() + .filter_map(|v| v["title"].as_str().map(str::to_string)) + .collect(); + let selected = Select::new("Authentication method:", titles.clone()) + .prompt() + .unwrap_or_else(|_| std::process::exit(0)); + let idx = titles.iter().position(|t| t == &selected).unwrap(); + return walk_properties(&one_of[idx]); + } + // Single auth method + walk_properties(schema) +} + +// ── Entry point ─────────────────────────────────────────────────────────────── + +pub fn run(workspace_id: &str) { + // Phase 1: Select connection type + let types = fetch_types(workspace_id); + if types.is_empty() { + eprintln!("error: no connection types available"); + std::process::exit(1); + } + let displays: Vec = types.iter().map(|t| format!("{} ({})", t.label, t.name)).collect(); + let names: Vec = types.iter().map(|t| t.name.clone()).collect(); + + let selected_display = Select::new("Connection type:", displays.clone()) + .prompt() + .unwrap_or_else(|_| std::process::exit(0)); + let idx = displays.iter().position(|d| d == &selected_display).unwrap(); + let source_type = &names[idx]; + + // Phase 2: Fetch schema for selected type + let detail = fetch_detail(workspace_id, source_type); + + // Phase 3: Connection name + let conn_name = Text::new("Connection name:") + .prompt() + .unwrap_or_else(|_| std::process::exit(0)); + + // Phase 4: Config properties + let mut config: Map = Map::new(); + if let Some(schema) = &detail.config_schema { + config.extend(walk_properties(schema)); + } + + // Phase 5: Auth properties + if let Some(auth_schema) = &detail.auth { + config.extend(walk_auth(auth_schema)); + } + + // Phase 6: Submit + let (client, api_key, api_url) = load_client(); + let body = serde_json::json!({ + "name": conn_name, + "source_type": source_type, + "config": Value::Object(config), + }); + + let url = format!("{api_url}/connections"); + let resp = client + .post(&url) + .header("Authorization", format!("Bearer {api_key}")) + .header("X-Workspace-Id", workspace_id) + .json(&body) + .send() + .unwrap_or_else(|e| { eprintln!("error connecting to API: {e}"); std::process::exit(1) }); + + if !resp.status().is_success() { + use crossterm::style::Stylize; + eprintln!("{}", crate::util::api_error(resp.text().unwrap_or_default()).red()); + std::process::exit(1); + } + + #[derive(serde::Deserialize)] + struct CreateResponse { + id: String, + name: String, + source_type: String, + tables_discovered: u64, + discovery_status: String, + discovery_error: Option, + } + + let result: CreateResponse = resp.json() + .unwrap_or_else(|e| { eprintln!("error parsing response: {e}"); std::process::exit(1) }); + + use crossterm::style::Stylize; + println!("{}", "Connection created".green()); + println!("id: {}", result.id); + println!("name: {}", result.name); + println!("source_type: {}", result.source_type); + println!("tables_discovered: {}", result.tables_discovered); + let status = match result.discovery_status.as_str() { + "success" => result.discovery_status.green().to_string(), + "failed" => result.discovery_error.as_deref().unwrap_or("failed").red().to_string(), + _ => result.discovery_status.yellow().to_string(), + }; + println!("discovery_status: {status}"); +} diff --git a/src/main.rs b/src/main.rs index 1199735..4fd98db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod auth; mod command; mod config; mod connections; +mod connections_new; mod datasets; mod init; mod query; @@ -91,6 +92,10 @@ fn main() { _ => eprintln!("not yet implemented"), }, Commands::Connections { command } => match command { + ConnectionsCommands::New { workspace_id } => { + let workspace_id = resolve_workspace(workspace_id); + connections_new::run(&workspace_id) + } ConnectionsCommands::Create { command, workspace_id, name, source_type, config, secret_id, secret_name, format } => { match command { Some(ConnectionsCreateCommands::List { name, workspace_id, format }) => { From e5358261397a2deda2fa03e4575dfc2c61a50387 Mon Sep 17 00:00:00 2001 From: Paul Thurlow Date: Mon, 16 Mar 2026 15:57:20 -0700 Subject: [PATCH 5/7] Add skill descriptions for connection commands --- skills/hotdata-cli/SKILL.md | 79 ++++++++++++++++++++++++++++++++++++- src/command.rs | 8 ---- src/connections.rs | 10 +---- src/connections_new.rs | 2 - src/main.rs | 4 +- 5 files changed, 79 insertions(+), 24 deletions(-) diff --git a/skills/hotdata-cli/SKILL.md b/skills/hotdata-cli/SKILL.md index 102b180..40a3a9e 100644 --- a/skills/hotdata-cli/SKILL.md +++ b/skills/hotdata-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: hotdata-cli -description: Use this skill when the user wants to run hotdata CLI commands, query the HotData API, list workspaces, list connections, list tables, manage datasets, execute SQL queries, or interact with the hotdata service. Activate when the user says "run hotdata", "query hotdata", "list workspaces", "list connections", "list tables", "list datasets", "create a dataset", "upload a dataset", "execute a query", or asks you to use the hotdata CLI. +description: Use this skill when the user wants to run hotdata CLI commands, query the HotData API, list workspaces, list connections, create connections, list tables, manage datasets, execute SQL queries, or interact with the hotdata service. Activate when the user says "run hotdata", "query hotdata", "list workspaces", "list connections", "create a connection", "list tables", "list datasets", "create a dataset", "upload a dataset", "execute a query", or asks you to use the hotdata CLI. version: 0.1.3 --- @@ -36,7 +36,66 @@ Returns workspaces with `public_id`, `name`, `active`, `favorite`, `provision_st ``` hotdata connections list [--workspace-id ] [--format table|json|yaml] ``` -Routes via API gateway using `X-Workspace-Id` header. +Returns `id`, `name`, `source_type` for each connection in the workspace. + +### Create a Connection + +#### Step 1 — Discover available connection types +``` +hotdata connections create list [--workspace-id ] [--format table|json|yaml] +``` +Returns all available connection types with `name` and `label`. + +#### Step 2 — Inspect the schema for a specific type +``` +hotdata connections create list [--workspace-id ] [--format json] +``` +Returns `config` and `auth` JSON Schema objects describing all required and optional fields for that connection type. Use `--format json` to get the full schema detail. + +- `config` — connection configuration fields (host, port, database, etc.). May be `null` for services that need no configuration. +- `auth` — authentication fields (password, token, credentials, etc.). May be `null` for services that need no authentication. May be a `oneOf` with multiple authentication method options. + +#### Step 3 — Create the connection +``` +hotdata connections create \ + --name "my-connection" \ + --type \ + --config '' \ + [--workspace-id ] +``` + +The `--config` JSON object must contain all **required** fields from `config` plus the **auth fields** merged in at the top level. Auth fields are not nested — they sit alongside config fields in the same object. + +Example for PostgreSQL (required: `host`, `port`, `user`, `database` + auth field `password`): +``` +hotdata connections create \ + --name "my-postgres" \ + --type postgres \ + --config '{"host":"db.example.com","port":5432,"user":"myuser","database":"mydb","password":"..."}' +``` + +**Security: never expose credentials in plain text.** Passwords, tokens, API keys, and any field with `"format": "password"` in the schema must never be hardcoded as literal strings in CLI commands. Always use one of these safe approaches: + +- Read from an environment variable: + ``` + --config "{\"host\":\"db.example.com\",\"port\":5432,\"user\":\"myuser\",\"database\":\"mydb\",\"password\":\"$DB_PASSWORD\"}" + ``` +- Read a credential from a file and inject it: + ``` + --config "{\"token\":\"$(cat ~/.secrets/my-token)\"}" + ``` + +**Field-building rules from the schema:** + +- Include all fields listed in `config.required` — these are mandatory. +- Include optional config fields only if the user provides values for them. +- For `auth` with a single method (no `oneOf`): include all `auth.required` fields in the config object. +- For `auth` with `oneOf`: pick one authentication method and include only its required fields. +- Fields with `"format": "password"` are credentials — apply the security rules above. +- Fields with `"type": "integer"` must be JSON numbers, not strings (e.g. `"port": 5432` not `"port": "5432"`). +- Fields with `"type": "boolean"` must be JSON booleans (e.g. `"use_tls": true`). +- Fields with `"type": "array"` must be JSON arrays (e.g. `"spreadsheet_ids": ["abc", "def"]`). +- Nested `oneOf` fields must be a JSON object including a `"type"` discriminator field matching the chosen variant's `const` value. ### List Tables and Columns ``` @@ -136,3 +195,19 @@ hotdata init # Create ~/.hotdata/config.yml ``` hotdata query "SELECT 1" ``` + +## Workflow: Creating a Connection + +1. List available connection types: + ``` + hotdata connections create list + ``` +2. Inspect the schema for the desired type: + ``` + hotdata connections create list --format json + ``` +3. Collect required config and auth field values from the user or environment. **Never hardcode credentials — use env vars or files.** +4. Create the connection: + ``` + hotdata connections create --name "my-connection" --type --config '' + ``` diff --git a/src/command.rs b/src/command.rs index 431fb65..e22f160 100644 --- a/src/command.rs +++ b/src/command.rs @@ -357,14 +357,6 @@ pub enum ConnectionsCommands { #[arg(long)] config: Option, - /// Reference to a secret by ID for authentication - #[arg(long, conflicts_with = "secret_name")] - secret_id: Option, - - /// Reference to a secret by name for authentication - #[arg(long, conflicts_with = "secret_id")] - secret_name: Option, - /// Output format #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] format: String, diff --git a/src/connections.rs b/src/connections.rs index 92fa20a..cc26394 100644 --- a/src/connections.rs +++ b/src/connections.rs @@ -163,8 +163,6 @@ pub fn create( name: &str, source_type: &str, config: &str, - secret_id: Option<&str>, - secret_name: Option<&str>, format: &str, ) { let profile_config = match crate::config::load("default") { @@ -191,17 +189,11 @@ pub fn create( } }; - let mut body = serde_json::json!({ + let body = serde_json::json!({ "name": name, "source_type": source_type, "config": config_value, }); - if let Some(id) = secret_id { - body["secret_id"] = serde_json::json!(id); - } - if let Some(sn) = secret_name { - body["secret_name"] = serde_json::json!(sn); - } let url = format!("{}/connections", profile_config.api_url); let client = reqwest::blocking::Client::new(); diff --git a/src/connections_new.rs b/src/connections_new.rs index 64c9e6a..277843a 100644 --- a/src/connections_new.rs +++ b/src/connections_new.rs @@ -10,7 +10,6 @@ struct ConnectionTypeSummary { } struct ConnectionTypeDetail { - name: String, config_schema: Option, auth: Option, } @@ -75,7 +74,6 @@ fn fetch_detail(workspace_id: &str, name: &str) -> ConnectionTypeDetail { } let body: Value = resp.json().unwrap_or_else(|e| { eprintln!("error: {e}"); std::process::exit(1) }); ConnectionTypeDetail { - name: body["name"].as_str().unwrap_or(name).to_string(), config_schema: if body["config_schema"].is_null() { None } else { Some(body["config_schema"].clone()) }, auth: if body["auth"].is_null() { None } else { Some(body["auth"].clone()) }, } diff --git a/src/main.rs b/src/main.rs index 4fd98db..fbf3165 100644 --- a/src/main.rs +++ b/src/main.rs @@ -96,7 +96,7 @@ fn main() { let workspace_id = resolve_workspace(workspace_id); connections_new::run(&workspace_id) } - ConnectionsCommands::Create { command, workspace_id, name, source_type, config, secret_id, secret_name, format } => { + ConnectionsCommands::Create { command, workspace_id, name, source_type, config, format } => { match command { Some(ConnectionsCreateCommands::List { name, workspace_id, format }) => { let workspace_id = resolve_workspace(workspace_id); @@ -121,8 +121,6 @@ fn main() { &name.unwrap(), &source_type.unwrap(), &config.unwrap(), - secret_id.as_deref(), - secret_name.as_deref(), &format, ) } From d14f348dab709bda2616505cc4acaee7a90fc0e6 Mon Sep 17 00:00:00 2001 From: Paul Thurlow Date: Mon, 16 Mar 2026 19:50:36 -0700 Subject: [PATCH 6/7] Clean up flags and fix empty required values --- src/command.rs | 36 ++++------------------- src/connections_new.rs | 10 +++++-- src/main.rs | 67 ++++++++++++++++++++---------------------- 3 files changed, 46 insertions(+), 67 deletions(-) diff --git a/src/command.rs b/src/command.rs index e22f160..c3931cf 100644 --- a/src/command.rs +++ b/src/command.rs @@ -20,7 +20,7 @@ pub enum Commands { id: Option, /// Workspace ID (defaults to first workspace from login) - #[arg(long)] + #[arg(long, global = true)] workspace_id: Option, /// Output format (used with dataset ID) @@ -63,6 +63,10 @@ pub enum Commands { /// Manage workspace connections Connections { + /// Workspace ID (defaults to first workspace from login) + #[arg(long, global = true)] + workspace_id: Option, + #[command(subcommand)] command: ConnectionsCommands, }, @@ -292,10 +296,6 @@ pub enum ConnectionsCreateCommands { /// Connection type name (e.g. postgres, mysql); omit to list all name: Option, - /// Workspace ID (defaults to first workspace from login) - #[arg(long)] - workspace_id: Option, - /// Output format #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] format: String, @@ -305,18 +305,10 @@ pub enum ConnectionsCreateCommands { #[derive(Subcommand)] pub enum ConnectionsCommands { /// Interactively create a new connection - New { - /// Workspace ID (defaults to first workspace from login) - #[arg(long)] - workspace_id: Option, - }, + New, /// List all connections for a workspace List { - /// Workspace ID (defaults to first workspace from login) - #[arg(long)] - workspace_id: Option, - /// Output format #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] format: String, @@ -324,10 +316,6 @@ pub enum ConnectionsCommands { /// Get details for a specific connection Get { - /// Workspace ID (defaults to first workspace from login) - #[arg(long)] - workspace_id: Option, - /// Connection ID connection_id: String, @@ -341,10 +329,6 @@ pub enum ConnectionsCommands { #[command(subcommand)] command: Option, - /// Workspace ID (defaults to first workspace from login) - #[arg(long)] - workspace_id: Option, - /// Connection name #[arg(long)] name: Option, @@ -364,10 +348,6 @@ pub enum ConnectionsCommands { /// Update a connection in a workspace Update { - /// Workspace ID (defaults to first workspace from login) - #[arg(long)] - workspace_id: Option, - /// Connection ID connection_id: String, @@ -390,10 +370,6 @@ pub enum ConnectionsCommands { /// Delete a connection from a workspace Delete { - /// Workspace ID (defaults to first workspace from login) - #[arg(long)] - workspace_id: Option, - /// Connection ID connection_id: String, }, diff --git a/src/connections_new.rs b/src/connections_new.rs index 277843a..9099dc9 100644 --- a/src/connections_new.rs +++ b/src/connections_new.rs @@ -170,8 +170,14 @@ fn prompt_field(key: &str, field: &Value, is_required: bool) -> Option { ("integer", _) => { let label = format!("{key}:"); let t = Text::new(&label) - .with_validator(|input: &str| { - if input.is_empty() || input.parse::().is_ok() { + .with_validator(move |input: &str| { + if input.is_empty() { + if is_required { + return Ok(Validation::Invalid("This field is required".into())); + } + return Ok(Validation::Valid); + } + if input.parse::().is_ok() { Ok(Validation::Valid) } else { Ok(Validation::Invalid("Must be a whole number".into())) diff --git a/src/main.rs b/src/main.rs index fbf3165..e93a070 100644 --- a/src/main.rs +++ b/src/main.rs @@ -91,46 +91,43 @@ fn main() { WorkspaceCommands::List { format } => workspace::list(&format), _ => eprintln!("not yet implemented"), }, - Commands::Connections { command } => match command { - ConnectionsCommands::New { workspace_id } => { - let workspace_id = resolve_workspace(workspace_id); - connections_new::run(&workspace_id) - } - ConnectionsCommands::Create { command, workspace_id, name, source_type, config, format } => { - match command { - Some(ConnectionsCreateCommands::List { name, workspace_id, format }) => { - let workspace_id = resolve_workspace(workspace_id); - match name.as_deref() { - Some(name) => connections::types_get(&workspace_id, name, &format), - None => connections::types_list(&workspace_id, &format), + Commands::Connections { workspace_id, command } => { + let workspace_id = resolve_workspace(workspace_id); + match command { + ConnectionsCommands::New => connections_new::run(&workspace_id), + ConnectionsCommands::List { format } => { + connections::list(&workspace_id, &format) + } + ConnectionsCommands::Create { command, name, source_type, config, format } => { + match command { + Some(ConnectionsCreateCommands::List { name, format }) => { + match name.as_deref() { + Some(name) => connections::types_get(&workspace_id, name, &format), + None => connections::types_list(&workspace_id, &format), + } } - } - None => { - let missing: Vec<&str> = [ - name.is_none().then_some("--name"), - source_type.is_none().then_some("--type"), - config.is_none().then_some("--config"), - ].into_iter().flatten().collect(); - if !missing.is_empty() { - eprintln!("error: missing required arguments: {}", missing.join(", ")); - std::process::exit(1); + None => { + let missing: Vec<&str> = [ + name.is_none().then_some("--name"), + source_type.is_none().then_some("--type"), + config.is_none().then_some("--config"), + ].into_iter().flatten().collect(); + if !missing.is_empty() { + eprintln!("error: missing required arguments: {}", missing.join(", ")); + std::process::exit(1); + } + connections::create( + &workspace_id, + &name.unwrap(), + &source_type.unwrap(), + &config.unwrap(), + &format, + ) } - let workspace_id = resolve_workspace(workspace_id); - connections::create( - &workspace_id, - &name.unwrap(), - &source_type.unwrap(), - &config.unwrap(), - &format, - ) } } - }, - ConnectionsCommands::List { workspace_id, format } => { - let workspace_id = resolve_workspace(workspace_id); - connections::list(&workspace_id, &format) + _ => eprintln!("not yet implemented"), } - _ => eprintln!("not yet implemented"), }, Commands::Tables { command } => match command { TablesCommands::List { workspace_id, connection_id, schema, table, limit, cursor, format } => { From f0c5215c77ef695a6626ddd62a6677ac32382b9a Mon Sep 17 00:00:00 2001 From: Paul Thurlow Date: Tue, 17 Mar 2026 09:34:01 -0700 Subject: [PATCH 7/7] recursive walk of objects in auth config for connections --- src/connections_new.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/connections_new.rs b/src/connections_new.rs index 9099dc9..ed3b064 100644 --- a/src/connections_new.rs +++ b/src/connections_new.rs @@ -246,7 +246,7 @@ fn walk_auth(schema: &Value) -> Map { .prompt() .unwrap_or_else(|_| std::process::exit(0)); let idx = titles.iter().position(|t| t == &selected).unwrap(); - return walk_properties(&one_of[idx]); + return walk_variant(&one_of[idx]); } // Single auth method walk_properties(schema)