diff --git a/README.md b/README.md index cb9a547..cb5fb6d 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,8 @@ hotdata databases list [-w ] [-o table|json|yaml] hotdata databases create --name [--table ...] [--schema public] [-o table|json|yaml] hotdata databases [-o table|json|yaml] hotdata databases delete +hotdata databases run [--database ] [--description
...] [--expires-at ] [args...] +hotdata databases run [args...] hotdata databases tables list [--schema ] [-o table|json|yaml] hotdata databases tables load
--file ./data.parquet [--schema public] @@ -146,6 +148,7 @@ hotdata databases tables delete
[--schema public] - `create` registers a managed connection (`source_type: managed`) with no external credentials. Use `--table` to declare tables up front (required before `tables load` on the current API). - `tables load` uploads a **parquet** file (or uses a staged `upload_id` from `POST /v1/files`) and publishes it as the table generation (`replace` mode). +- `run` mints a database-scoped JWT and execs `` with `HOTDATA_DATABASE_TOKEN`, `HOTDATA_DATABASE_REFRESH_TOKEN`, `HOTDATA_DATABASE`, `HOTDATA_WORKSPACE`, and `HOTDATA_API_URL` injected into its environment. Pass a database id (group-positional `` like `sandbox run`, or `--database `) to scope an existing database; omit both to auto-create a scratch one using `--description` / `--schema` / `--table` / `--expires-at`. Useful for launching an agent or child process whose API access is restricted to a single database. - For CSV/JSON uploads without a managed database, use `hotdata datasets create` instead (`datasets.main.*`). Example: diff --git a/skills/hotdata/SKILL.md b/skills/hotdata/SKILL.md index f7ead0d..43684a8 100644 --- a/skills/hotdata/SKILL.md +++ b/skills/hotdata/SKILL.md @@ -191,6 +191,8 @@ hotdata databases create [--description
...] [--schema hotdata databases set hotdata databases [--workspace-id ] [--output table|json|yaml] hotdata databases delete [--workspace-id ] +hotdata databases run [--database ] [--description
...] [--expires-at ] [--workspace-id ] [args...] +hotdata databases run [args...] # Dot-notation shorthand for load: database.table or database.schema.table hotdata databases load [--file ./data.parquet] [--url ] [--upload-id ] [--workspace-id ] @@ -209,6 +211,7 @@ hotdata databases tables delete
[--database ] [--schema publ - `tables list` — lists tables with `TABLE` (`..
`), `SYNCED`, `LAST_SYNC`. Uses active database when `--database` is omitted. - `tables load` — uploads a local parquet file (`--file`), a remote parquet URL (`--url`), or a pre-staged upload (`--upload-id`) and publishes with **replace** mode. - `tables delete` — drops a table from the managed database. +- `run` — mints a database-scoped JWT (via `POST /v1/auth/database`) and execs `` with `HOTDATA_DATABASE_TOKEN`, `HOTDATA_DATABASE_REFRESH_TOKEN`, `HOTDATA_DATABASE`, `HOTDATA_WORKSPACE`, and `HOTDATA_API_URL` injected. Pass a database id as a group positional (`hotdata databases run ...`, sandbox-style) or via `--database `; omit both to auto-create a scratch database using `--description` / `--schema` / `--table` / `--expires-at`. Use this to launch an agent or child process whose API access is scoped to a single database. The minted JWT carries `database`, `workspaces`, `permissions:["read","write"]`, `source:"database_token"`. The session is persisted at `~/.hotdata/database_session.json` (mode `0600`); the child's exit code is propagated. Example: diff --git a/src/api.rs b/src/api.rs index 1cef44d..e888f53 100644 --- a/src/api.rs +++ b/src/api.rs @@ -50,20 +50,33 @@ impl ApiClient { // Auth source precedence: // - // 1. `HOTDATA_SANDBOX_TOKEN` env var — a `sandbox run` child + // 1. `HOTDATA_DATABASE_TOKEN` env var — a `databases run` child + // is executing with the parent's credentials scrubbed and a + // database-scoped JWT injected. Refresh in-memory via + // `HOTDATA_DATABASE_REFRESH_TOKEN` near expiry; never write + // to disk (the child's FS may not be writable). + // 2. `HOTDATA_SANDBOX_TOKEN` env var — a `sandbox run` child // is executing with the parent's credentials scrubbed. // Refresh in-memory via `HOTDATA_SANDBOX_REFRESH_TOKEN` if // the JWT is close to expiry; never write to disk (the // child's FS may not be writable). - // 2. `~/.hotdata/sandbox_session.json` — the user ran + // 3. `~/.hotdata/sandbox_session.json` — the user ran // `hotdata sandbox set ` (or `sandbox new` / `sandbox // run` in the parent shell). The sandbox JWT is the active // bearer for *every* command until `sandbox set` (with no // id) clears the file. - // 3. `~/.hotdata/session.json` + optional api_key fallback — + // 4. `~/.hotdata/session.json` + optional api_key fallback — // normal user-scoped CLI session. let api_url = profile_config.api_url.to_string(); - let access_token = if std::env::var("HOTDATA_SANDBOX_TOKEN").is_ok() { + let access_token = if std::env::var("HOTDATA_DATABASE_TOKEN").is_ok() { + match crate::database_session::refresh_from_env(&api_url) { + Some(t) => t, + None => { + eprintln!("{}", "error: HOTDATA_DATABASE_TOKEN is empty".red()); + std::process::exit(1); + } + } + } else if std::env::var("HOTDATA_SANDBOX_TOKEN").is_ok() { match crate::sandbox_session::refresh_from_env(&api_url) { Some(t) => t, None => { @@ -118,7 +131,9 @@ impl ApiClient { } profile_config.sandbox }), - database_id: workspace_id.and_then(|ws| crate::config::load_current_database("default", ws)), + database_id: std::env::var("HOTDATA_DATABASE").ok().or_else(|| { + workspace_id.and_then(|ws| crate::config::load_current_database("default", ws)) + }), } } diff --git a/src/command.rs b/src/command.rs index 12aab8d..82d15ae 100644 --- a/src/command.rs +++ b/src/command.rs @@ -632,6 +632,34 @@ pub enum DatabasesCommands { #[command(subcommand)] command: Option, }, + + /// Run a command with a database-scoped token. Creates a new database unless --database is given. + Run { + /// Existing database id to scope the token to (omit to auto-create a database) + #[arg(long)] + database: Option, + + /// Description for the auto-created database (only used when --database is omitted) + #[arg(long)] + description: Option, + + /// Schema for tables declared in the auto-created database (default: public) + #[arg(long, default_value = "public")] + schema: String, + + /// Table to declare in the auto-created database (repeatable) + #[arg(long = "table")] + tables: Vec, + + /// When the auto-created database expires. Accepts a relative duration + /// (e.g. 24h, 7d, 90m) or an RFC 3339 timestamp. Defaults to 24h when omitted. + #[arg(long)] + expires_at: Option, + + /// Command to execute (everything after `--`) + #[arg(trailing_var_arg = true, required = true)] + cmd: Vec, + }, } #[derive(Subcommand)] diff --git a/src/database_session.rs b/src/database_session.rs new file mode 100644 index 0000000..732e1c8 --- /dev/null +++ b/src/database_session.rs @@ -0,0 +1,282 @@ +//! Persisted database-scoped JWT session. +//! +//! Minted by `POST /v1/auth/database` (grant_type=existing_database + +//! database_id), refreshed via the same endpoint with +//! grant_type=refresh_token. Bound to a single database + workspace; +//! the JWT carries workspace + database read/write scope. The server +//! does not rotate the refresh token. +//! +//! Stored at `~/.hotdata/database_session.json` (mode 0600). + +use crate::config; +use crate::util; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Refresh ahead of expiry to avoid racing it. +const REFRESH_LEEWAY_SECONDS: u64 = 60; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct DatabaseSession { + pub access_token: String, + pub refresh_token: String, + pub database_id: String, + pub workspace_id: String, + pub access_expires_at: u64, + pub refresh_expires_at: u64, +} + +pub fn session_path() -> Option { + config::config_dir().ok().map(|d| d.join("database_session.json")) +} + +#[allow(dead_code)] // Reserved for flows that re-use a cached database session. +pub fn load() -> Option { + let path = session_path()?; + let raw = fs::read_to_string(&path).ok()?; + serde_json::from_str(&raw).ok() +} + +pub fn save(session: &DatabaseSession) -> Result<(), String> { + let path = session_path().ok_or_else(|| "no database session path available".to_string())?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("mkdir failed: {e}"))?; + } + let json = serde_json::to_string_pretty(session) + .map_err(|e| format!("serialize failed: {e}"))?; + + use std::os::unix::fs::OpenOptionsExt; + let mut f = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .mode(0o600) + .open(&path) + .map_err(|e| format!("open failed: {e}"))?; + f.write_all(json.as_bytes()) + .map_err(|e| format!("write failed: {e}"))?; + Ok(()) +} + +#[allow(dead_code)] // Reserved for flows that re-use a cached database session. +pub fn clear() { + if let Some(path) = session_path() { + let _ = fs::remove_file(path); + } +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[derive(Deserialize)] +pub(crate) struct MintResponse { + token: String, + refresh_token: String, + database_id: String, + expires_in: u64, + refresh_expires_in: u64, +} + +fn redact(s: &str) -> String { + util::mask_credential(s) +} + +/// Trade a refresh token for a fresh database JWT (no rotation). Same +/// endpoint as the new-mint path: `POST /v1/auth/database` with +/// grant_type=refresh_token. +pub fn refresh(api_url: &str, refresh_token: &str) -> Result { + let url = format!("{}/auth/database", api_url.trim_end_matches('/')); + let body = serde_json::json!({ + "grant_type": "refresh_token", + "refresh_token": refresh_token, + }); + let body_log = serde_json::json!({ + "grant_type": "refresh_token", + "refresh_token": redact(refresh_token), + }); + + let client = reqwest::blocking::Client::new(); + let req = client.post(&url).json(&body); + let (status, body_text) = util::send_debug_with_redaction( + &client, + req, + Some(&body_log), + &["token", "refresh_token"], + ) + .map_err(|e| format!("connection error: {e}"))?; + if !status.is_success() { + return Err(format!("database refresh failed: HTTP {status}: {body_text}")); + } + let resp: MintResponse = serde_json::from_str(&body_text) + .map_err(|e| format!("malformed refresh response: {e}"))?; + Ok(session_from_response(resp, String::new())) +} + +/// Build a [`DatabaseSession`] from a mint/refresh response. The mint +/// response doesn't carry the workspace public_id, so the caller passes +/// it in (it's what the JWT's `workspaces` claim restricts the bearer +/// to). For refresh, `workspace_id` is left blank — the caller fills it +/// from the prior session, since the database-id ↔ workspace mapping is +/// invariant across refreshes. +pub(crate) fn session_from_response(resp: MintResponse, workspace_id: String) -> DatabaseSession { + let now = now_unix(); + DatabaseSession { + access_token: resp.token, + refresh_token: resp.refresh_token, + database_id: resp.database_id, + workspace_id, + access_expires_at: now + resp.expires_in, + refresh_expires_at: now + resp.refresh_expires_in, + } +} + +/// Decode a JWT's payload (without verifying the signature) and pull +/// out the named string claim. Returns `None` if the token is +/// unparseable or the claim is missing. +fn jwt_string_claim(token: &str, claim: &str) -> Option { + use base64::Engine; + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() < 2 { + return None; + } + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[1].as_bytes()) + .ok()?; + let value: serde_json::Value = serde_json::from_slice(&payload).ok()?; + value.get(claim).and_then(|v| v.as_str()).map(String::from) +} + +/// Decode the `exp` claim out of a JWT without verifying the signature. +/// Returns `None` if the token is unparseable; in that case the caller +/// should treat it as expired (force-refresh or fail). +fn jwt_exp(token: &str) -> Option { + use base64::Engine; + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() < 2 { + return None; + } + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[1].as_bytes()) + .ok()?; + let value: serde_json::Value = serde_json::from_slice(&payload).ok()?; + value.get("exp").and_then(|v| v.as_u64()) +} + +/// If `HOTDATA_DATABASE_TOKEN` is set in the environment, return +/// `(token, database_id)` — the database id read from the JWT's +/// `database` claim. Returns `None` if no env var is set, or if the +/// token isn't a parseable JWT (in which case we can still use it as +/// a bearer but can't identify the database). +pub fn database_token_in_use() -> Option<(String, Option)> { + let token = std::env::var("HOTDATA_DATABASE_TOKEN").ok()?; + if token.is_empty() { + return None; + } + let database_id = jwt_string_claim(&token, "database"); + Some((token, database_id)) +} + +/// In-child equivalent of a parent-side `ensure_access_token`: operates +/// on env vars only. Used by [`crate::api::ApiClient`] when the parent +/// `databases run` already passed in `HOTDATA_DATABASE_TOKEN` and +/// `HOTDATA_DATABASE_REFRESH_TOKEN`. The new tokens are *not* persisted +/// to disk — the child may not have write access to the parent's +/// config dir (sandboxed FS), and re-doing the refresh on the next +/// invocation costs one HTTP call. +/// +/// Falls back to the current `HOTDATA_DATABASE_TOKEN` value if a +/// refresh isn't needed or fails. +pub fn refresh_from_env(api_url: &str) -> Option { + let current = std::env::var("HOTDATA_DATABASE_TOKEN").ok()?; + let needs_refresh = match jwt_exp(¤t) { + Some(exp) => exp.saturating_sub(REFRESH_LEEWAY_SECONDS) <= now_unix(), + None => true, + }; + if !needs_refresh { + return Some(current); + } + let rt = std::env::var("HOTDATA_DATABASE_REFRESH_TOKEN").ok()?; + if rt.is_empty() { + return Some(current); + } + match refresh(api_url, &rt) { + Ok(new_session) => Some(new_session.access_token), + Err(_) => Some(current), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::test_helpers::with_temp_config_dir; + + fn mk_session(access_offset: i64, refresh_offset: i64) -> DatabaseSession { + let now = now_unix() as i64; + DatabaseSession { + access_token: "cached".into(), + refresh_token: "cached-refresh".into(), + database_id: "dbid_abc".into(), + workspace_id: "work_xyz".into(), + access_expires_at: (now + access_offset).max(0) as u64, + refresh_expires_at: (now + refresh_offset).max(0) as u64, + } + } + + #[test] + fn round_trip() { + let (_tmp, _guard) = with_temp_config_dir(); + let s = mk_session(3600, 86400); + save(&s).unwrap(); + let loaded = load().unwrap(); + assert_eq!(loaded.access_token, "cached"); + assert_eq!(loaded.database_id, "dbid_abc"); + assert_eq!(loaded.workspace_id, "work_xyz"); + } + + #[test] + fn file_is_mode_0600() { + use std::os::unix::fs::PermissionsExt; + let (_tmp, _guard) = with_temp_config_dir(); + save(&mk_session(60, 60)).unwrap(); + let mode = fs::metadata(session_path().unwrap()).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + + #[test] + fn refresh_posts_grant_type_to_database_endpoint() { + let mut server = mockito::Server::new(); + let m = server + .mock("POST", "/auth/database") + .match_body(mockito::Matcher::JsonString( + r#"{"grant_type":"refresh_token","refresh_token":"stable-refresh"}"#.to_string(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"ok":true,"token":"new-jwt","refresh_token":"stable-refresh","database_id":"dbid_abc","expires_in":300,"refresh_expires_in":259200}"#, + ) + .create(); + + let s = refresh(&server.url(), "stable-refresh").unwrap(); + m.assert(); + assert_eq!(s.access_token, "new-jwt"); + assert_eq!(s.refresh_token, "stable-refresh"); + assert_eq!(s.database_id, "dbid_abc"); + } + + #[test] + fn refresh_http_error() { + let mut server = mockito::Server::new(); + let m = server.mock("POST", "/auth/database").with_status(401).create(); + let err = refresh(&server.url(), "x").unwrap_err(); + m.assert(); + assert!(err.contains("401")); + } +} diff --git a/src/databases.rs b/src/databases.rs index 97fe178..8fe6c07 100644 --- a/src/databases.rs +++ b/src/databases.rs @@ -71,6 +71,16 @@ struct CreateDatabaseResponse { expires_at: Option, } +/// Response shape of `POST /v1/auth/database`. +#[derive(Deserialize)] +struct DatabaseTokenResponse { + token: String, + refresh_token: String, + database_id: String, + expires_in: u64, + refresh_expires_in: u64, +} + #[derive(Deserialize)] struct LoadManagedTableResponse { #[allow(dead_code)] @@ -438,6 +448,109 @@ pub fn get(workspace_id: &str, id_or_name: &str, format: &str) { } } +/// Create a database and return its id. Used by `run` when no +/// `--database` is given. Mirrors `create`'s request path but returns +/// the id instead of printing. +fn create_and_return_id( + api: &ApiClient, + description: Option<&str>, + schema: &str, + tables: &[String], + expires_at: Option<&str>, +) -> String { + use crossterm::style::Stylize; + let body = create_database_request(description, schema, tables, expires_at); + let (status, resp_body) = api.post_raw("/databases", &body); + if !status.is_success() { + eprintln!("{}", crate::util::api_error(resp_body).red()); + std::process::exit(1); + } + let result: CreateDatabaseResponse = match serde_json::from_str(&resp_body) { + Ok(v) => v, + Err(e) => { + eprintln!("error parsing create response: {e}"); + std::process::exit(1); + } + }; + result.id +} + +/// Mint a database-scoped JWT for an existing database id via +/// `POST /v1/auth/database` (grant_type=existing_database). The call +/// doubles as an existence + access check (the server 404s an unknown +/// or unreachable database). +fn mint_database_token(api: &ApiClient, database_id: &str) -> DatabaseTokenResponse { + let body = serde_json::json!({ + "grant_type": "existing_database", + "database_id": database_id, + }); + api.post("/auth/database", &body) +} + +/// Run a command with a database-scoped token. Creates a new database +/// first when `database` is None, then mints a JWT and execs the +/// command with it injected as HOTDATA_DATABASE_TOKEN. +pub fn run( + database: Option<&str>, + workspace_id: &str, + description: Option<&str>, + schema: &str, + tables: &[String], + expires_at: Option<&str>, + cmd: &[String], +) { + use crossterm::style::Stylize; + use std::time::{SystemTime, UNIX_EPOCH}; + + let api = ApiClient::new(Some(workspace_id)); + + // Unlike `create`, we don't persist the auto-created database as the + // workspace's "current" database: a `run` database is scratch/ephemeral + // for the child process, addressed only by the token we mint below. + let database_id = match database { + Some(id) => id.to_string(), + None => create_and_return_id(&api, description, schema, tables, expires_at), + }; + + let resp = mint_database_token(&api, &database_id); + let db_id = resp.database_id.clone(); + let db_jwt = resp.token.clone(); + let db_refresh = resp.refresh_token.clone(); + + let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let session = crate::database_session::DatabaseSession { + access_token: db_jwt.clone(), + refresh_token: db_refresh.clone(), + database_id: db_id.clone(), + workspace_id: workspace_id.to_string(), + access_expires_at: now + resp.expires_in, + refresh_expires_at: now + resp.refresh_expires_in, + }; + if let Err(e) = crate::database_session::save(&session) { + eprintln!("warning: could not persist database session: {e}"); + } + + eprintln!("{} {}", "database:".dark_grey(), db_id); + eprintln!("{} {}", "workspace:".dark_grey(), workspace_id); + + let status = std::process::Command::new(&cmd[0]) + .args(&cmd[1..]) + .env("HOTDATA_DATABASE", &db_id) + .env("HOTDATA_WORKSPACE", workspace_id) + .env("HOTDATA_API_URL", &api.api_url) + .env("HOTDATA_DATABASE_TOKEN", &db_jwt) + .env("HOTDATA_DATABASE_REFRESH_TOKEN", &db_refresh) + .status(); + + match status { + Ok(s) => std::process::exit(s.code().unwrap_or(1)), + Err(e) => { + eprintln!("error: failed to execute '{}': {e}", cmd[0]); + std::process::exit(1); + } + } +} + pub fn create( workspace_id: &str, name: Option<&str>, @@ -1053,4 +1166,54 @@ mod tests { assert_eq!(parsed.table_name, "events"); assert_eq!(parsed.row_count, 99); } + + #[test] + fn database_token_response_deserializes() { + let body = r#"{"ok":true,"token":"jwt-x","refresh_token":"rt-x","database_id":"dbid_abc","expires_in":300,"refresh_expires_in":259200}"#; + let resp: DatabaseTokenResponse = serde_json::from_str(body).unwrap(); + assert_eq!(resp.token, "jwt-x"); + assert_eq!(resp.database_id, "dbid_abc"); + assert_eq!(resp.refresh_token, "rt-x"); + assert_eq!(resp.expires_in, 300); + } + + #[test] + fn create_and_return_id_parses_id() { + let mut server = mockito::Server::new(); + let m = server + .mock("POST", "/databases") + .match_body(mockito::Matcher::Json(create_database_request( + Some("scratch"), + "public", + &[], + None, + ))) + .with_status(201) + .with_header("content-type", "application/json") + .with_body(r#"{"id":"dbid_new","description":"scratch","default_connection_id":"conn_1"}"#) + .create(); + let api = ApiClient::test_new(&server.url(), "k", Some("ws")); + let id = create_and_return_id(&api, Some("scratch"), "public", &[], None); + m.assert(); + assert_eq!(id, "dbid_new"); + } + + #[test] + fn mint_database_token_posts_existing_database_grant() { + let mut server = mockito::Server::new(); + let m = server + .mock("POST", "/auth/database") + .match_body(mockito::Matcher::JsonString( + r#"{"grant_type":"existing_database","database_id":"dbid_abc"}"#.to_string(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"ok":true,"token":"jwt-x","refresh_token":"rt-x","database_id":"dbid_abc","expires_in":300,"refresh_expires_in":259200}"#) + .create(); + let api = ApiClient::test_new(&server.url(), "k", Some("ws")); + let resp = mint_database_token(&api, "dbid_abc"); + m.assert(); + assert_eq!(resp.token, "jwt-x"); + assert_eq!(resp.database_id, "dbid_abc"); + } } diff --git a/src/main.rs b/src/main.rs index f7da255..4c3c5c8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod config; mod connections; mod connections_new; mod context; +mod database_session; mod databases; mod datasets; mod embedding_providers; @@ -135,6 +136,11 @@ extern "C" fn print_sandbox_footer() { extern "C" fn print_database_footer() { use crossterm::style::Stylize; + // Inside a `databases run` child the parent already announced the + // database at spawn; mirror sandbox's footer suppression. + if database_session::database_token_in_use().is_some() { + return; + } if let Some(ws_id) = ACTIVE_WORKSPACE_ID.get() { if let Some(id) = config::load_current_database("default", ws_id) { eprintln!( @@ -390,7 +396,31 @@ fn main() { command, } => { let workspace_id = resolve_workspace(workspace_id); - if let Some(name_or_id) = name_or_id { + // `databases run ...` should mint a token for , not + // short to `show`. Route Run before the name_or_id show-shorthand; + // --database on the subcommand takes precedence over the group + // positional. Other subcommands keep the existing semantics: a + // group-level name_or_id is treated as a `show` shorthand. + if let Some(DatabasesCommands::Run { + database, + description, + schema, + tables, + expires_at, + cmd, + }) = command + { + let db = database.as_deref().or(name_or_id.as_deref()); + databases::run( + db, + &workspace_id, + description.as_deref(), + &schema, + &tables, + expires_at.as_deref(), + &cmd, + ); + } else if let Some(name_or_id) = name_or_id { databases::get(&workspace_id, &name_or_id, &output); } else { match command { @@ -495,6 +525,10 @@ fn main() { } } }, + Some(DatabasesCommands::Run { .. }) => { + // Handled by the Run-first if-let above. + unreachable!("Run handled before name_or_id shorthand"); + } None => { use clap::CommandFactory; let mut cmd = Cli::command();