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/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 2f2f6eb..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, }, @@ -285,14 +289,26 @@ pub enum WorkspaceCommands { }, } +#[derive(Subcommand)] +pub enum ConnectionsCreateCommands { + /// 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, + + /// Output format + #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] + format: String, + }, +} + #[derive(Subcommand)] pub enum ConnectionsCommands { + /// Interactively create a new connection + 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, @@ -300,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, @@ -312,35 +324,30 @@ pub enum ConnectionsCommands { format: String, }, - /// Create a new connection in a workspace + /// Create a new connection, or list/inspect available connection types Create { - /// Workspace ID (defaults to first workspace from login) - #[arg(long)] - workspace_id: Option, + #[command(subcommand)] + command: Option, /// Connection name #[arg(long)] - name: String, + name: Option, - /// Connection type + /// Connection source type (e.g. postgres, mysql, snowflake) #[arg(long = "type")] - conn_type: String, + source_type: Option, - /// Connection config as JSON string + /// Connection config as a JSON object #[arg(long)] - config: String, + config: Option, /// Output format - #[arg(long, default_value = "yaml", value_parser = ["table", "json", "yaml"])] + #[arg(long, default_value = "table", value_parser = ["table", "json", "yaml"])] format: String, }, /// 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, @@ -363,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.rs b/src/connections.rs index c07db78..cc26394 100644 --- a/src/connections.rs +++ b/src/connections.rs @@ -1,6 +1,151 @@ use crate::config; use serde::{Deserialize, Serialize}; +#[derive(Deserialize, Serialize)] +struct ConnectionType { + name: String, + label: String, +} + +#[derive(Deserialize)] +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, + 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!(), + } +} + +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, @@ -13,6 +158,105 @@ struct ListResponse { connections: Vec, } +pub fn create( + workspace_id: &str, + name: &str, + source_type: &str, + config: &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 body = serde_json::json!({ + "name": name, + "source_type": source_type, + "config": config_value, + }); + + 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, @@ -47,7 +291,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 +313,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/connections_new.rs b/src/connections_new.rs new file mode 100644 index 0000000..ed3b064 --- /dev/null +++ b/src/connections_new.rs @@ -0,0 +1,340 @@ +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 { + 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 { + 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(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())) + } + }); + 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_variant(&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/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..e93a070 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; @@ -13,7 +14,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)] @@ -90,12 +91,43 @@ fn main() { WorkspaceCommands::List { format } => workspace::list(&format), _ => eprintln!("not yet implemented"), }, - Commands::Connections { command } => match command { - ConnectionsCommands::List { workspace_id, format } => { - let workspace_id = resolve_workspace(workspace_id); - connections::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); + } + connections::create( + &workspace_id, + &name.unwrap(), + &source_type.unwrap(), + &config.unwrap(), + &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 } => { 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);