diff --git a/src/command.rs b/src/command.rs index 9f36e77..a8c33ef 100644 --- a/src/command.rs +++ b/src/command.rs @@ -478,6 +478,10 @@ pub enum DatasetsCommands { /// Saved query ID to create the dataset from #[arg(long, conflicts_with = "sql", required_unless_present = "sql")] query_id: Option, + + /// Output format + #[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])] + output: String, }, /// Update a dataset's description and/or name @@ -854,6 +858,12 @@ pub enum SandboxCommands { #[arg(trailing_var_arg = true, required = true)] cmd: Vec, }, + + /// Delete a sandbox permanently + Delete { + /// Sandbox ID to delete + id: String, + }, } #[derive(Subcommand)] diff --git a/src/context.rs b/src/context.rs index 8b7a44f..f59d189 100644 --- a/src/context.rs +++ b/src/context.rs @@ -311,7 +311,28 @@ pub fn push(workspace_id: &str, database_id: &str, name: &str, dry_run: bool) { let api = ApiClient::new(Some(workspace_id)); let body = json!({ "name": &name, "content": content }); - let resp: UpsertResponse = api.post(&format!("/databases/{database_id}/context"), &body); + let (status, resp_body) = api.post_raw(&format!("/databases/{database_id}/context"), &body); + + if !status.is_success() { + let msg = crate::util::api_error(resp_body.clone()); + if msg.to_lowercase().contains("not allowed within a session") { + eprintln!("{}", msg.red()); + eprintln!( + "{}", + "hint: context push is blocked inside an active sandbox. \ +Run 'hotdata sandbox set' (no args) to clear the active sandbox first." + .dark_grey() + ); + } else { + eprintln!("{}", msg.red()); + } + std::process::exit(1); + } + + let resp: UpsertResponse = serde_json::from_str(&resp_body).unwrap_or_else(|e| { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + }); println!( "{}", diff --git a/src/datasets.rs b/src/datasets.rs index 1cb6548..735031e 100644 --- a/src/datasets.rs +++ b/src/datasets.rs @@ -17,7 +17,7 @@ fn default_schema() -> String { "main".to_string() } -#[derive(Deserialize)] +#[derive(Deserialize, Serialize)] struct CreateResponse { id: String, label: String, @@ -75,6 +75,7 @@ fn create_dataset( description: Option<&str>, name: &str, source: serde_json::Value, + format: &str, ) { let label = description.unwrap_or(name); let body = json!({ "table_name": name, "label": label, "source": source }); @@ -96,18 +97,25 @@ fn create_dataset( }; use crossterm::style::Stylize; - println!("{}", "Dataset created".green()); - println!("id: {}", dataset.id); - println!("label: {}", dataset.label); - println!( - "full_name: datasets.{}.{}", - dataset.schema_name, dataset.table_name - ); + match format { + "json" => println!("{}", serde_json::to_string_pretty(&dataset).unwrap()), + "yaml" => print!("{}", serde_yaml::to_string(&dataset).unwrap()), + "table" => { + eprintln!("{}", "Dataset created".green()); + println!("id: {}", dataset.id); + println!("label: {}", dataset.label); + println!( + "full_name: datasets.{}.{}", + dataset.schema_name, dataset.table_name + ); + } + _ => unreachable!(), + } } -pub fn create_from_query(workspace_id: &str, sql: &str, description: Option<&str>, name: &str) { +pub fn create_from_query(workspace_id: &str, sql: &str, description: Option<&str>, name: &str, format: &str) { let api = ApiClient::new(Some(workspace_id)); - create_dataset(&api, description, name, json!({ "type": "sql_query", "sql": sql })); + create_dataset(&api, description, name, json!({ "type": "sql_query", "sql": sql }), format); } pub fn create_from_saved_query( @@ -115,9 +123,10 @@ pub fn create_from_saved_query( query_id: &str, description: Option<&str>, name: &str, + format: &str, ) { let api = ApiClient::new(Some(workspace_id)); - create_dataset(&api, description, name, json!({ "type": "saved_query", "saved_query_id": query_id })); + create_dataset(&api, description, name, json!({ "type": "saved_query", "saved_query_id": query_id }), format); } pub fn list(workspace_id: &str, limit: Option, offset: Option, format: &str) { diff --git a/src/main.rs b/src/main.rs index cd8ecdc..e5cb8dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -216,6 +216,7 @@ fn main() { description, sql, query_id, + output, }) => { if let Some(sql) = sql { datasets::create_from_query( @@ -223,6 +224,7 @@ fn main() { &sql, description.as_deref(), &name, + &output, ) } else { datasets::create_from_saved_query( @@ -230,6 +232,7 @@ fn main() { query_id.as_deref().unwrap_or_else(|| unreachable!("clap enforces --sql or --query-id")), description.as_deref(), &name, + &output, ) } } @@ -957,6 +960,9 @@ fn main() { Some(SandboxCommands::Set { id: set_id }) => { sandbox::set(set_id.as_deref(), &workspace_id) } + Some(SandboxCommands::Delete { id: delete_id }) => { + sandbox::delete(&delete_id, &workspace_id) + } None => match id { Some(id) => sandbox::get(&id, &workspace_id, &output), None => { diff --git a/src/sandbox.rs b/src/sandbox.rs index d0313af..0572e6b 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -135,7 +135,7 @@ pub fn read(sandbox_id: &str, workspace_id: &str) { if body.sandbox.markdown.is_empty() { eprintln!("{}", "Sandbox markdown is empty.".dark_grey()); } else { - print!("{}", body.sandbox.markdown); + println!("{}", body.sandbox.markdown); } } @@ -195,7 +195,7 @@ pub fn new(workspace_id: &str, name: Option<&str>, format: &str) { eprintln!("warning: could not save sandbox to config: {e}"); } - println!("{}", "Sandbox created".green()); + eprintln!("{}", "Sandbox created".green()); match format { "json" => println!("{}", serde_json::json!({"public_id": sandbox_id})), "yaml" => print!( @@ -238,7 +238,7 @@ pub fn update( let resp: DetailResponse = api.patch(&path, &body); let s = &resp.sandbox; - println!("{}", "Sandbox updated".green()); + eprintln!("{}", "Sandbox updated".green()); match format { "json" => println!("{}", serde_json::to_string_pretty(s).unwrap()), "yaml" => print!("{}", serde_yaml::to_string(s).unwrap()), @@ -340,6 +340,33 @@ pub fn set(sandbox_id: Option<&str>, workspace_id: &str) { } } +pub fn delete(sandbox_id: &str, workspace_id: &str) { + check_sandbox_lock(); + let api = ApiClient::new(Some(workspace_id)); + let path = format!("/sandboxes/{sandbox_id}"); + let (status, resp_body) = api.delete_raw(&path); + + if !status.is_success() { + eprintln!("{}", crate::util::api_error(resp_body).red()); + std::process::exit(1); + } + + // If the deleted sandbox was the active one, clear the cached session + // and config pointer so subsequent commands don't keep routing through + // a stale sandbox JWT — mirroring what `sandbox set` (no args) does. + let active = std::env::var("HOTDATA_SANDBOX") + .ok() + .or_else(|| config::load("default").ok().and_then(|p| p.sandbox)); + if active.as_deref() == Some(sandbox_id) { + sandbox_session::clear(); + if let Err(e) = config::clear_sandbox("default") { + eprintln!("warning: could not clear sandbox from config: {e}"); + } + } + + eprintln!("{}", "Sandbox deleted".green()); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/workspace.rs b/src/workspace.rs index 2dac178..f95959a 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -17,10 +17,10 @@ struct ListResponse { } pub fn set(workspace_id: Option<&str>) { - if std::env::var("HOTDATA_WORKSPACE").is_ok() + if std::env::var("HOTDATA_SANDBOX").is_ok() || crate::sandbox::find_sandbox_run_ancestor().is_some() { - eprintln!("error: workspace is locked"); + eprintln!("error: workspace cannot be changed inside a sandbox"); std::process::exit(1); } let api = ApiClient::new(None);