Skip to content

Commit 5a10d13

Browse files
authored
fix(cli): resolve 6 bugs found during E2E testing (#110)
* fix(cli): resolve 6 bugs found during E2E testing - datasets create: add missing -o/--output flag; move banner to stderr - sandbox new: move "Sandbox created" banner to stderr so -o json is parseable - sandbox read: use println! instead of print! to ensure trailing newline - sandbox: add delete subcommand (DELETE /sandboxes/{id}) - workspaces set: check HOTDATA_SANDBOX instead of HOTDATA_WORKSPACE; update error message - context push: surface friendly hint when server blocks push inside an active sandbox * fix(sandbox): move update banner to stderr; add functional test suite - sandbox update: move "Sandbox updated" banner to eprintln! (stderr) so -o json stdout is clean — same fix as sandbox new - scripts/test_commands.sh: new comprehensive functional test script covering all 128 command/subcommand/flag/output-format combinations, with real API calls, resource lifecycle, and cleanup * fix(sandbox): clear active session after deleting the active sandbox When the deleted sandbox is the currently active one (tracked via HOTDATA_SANDBOX env var or config.sandbox), clear the cached sandbox session and config pointer so subsequent commands do not keep routing through a stale JWT -- mirrors what sandbox set (no args) already does. * fix(sandbox): block delete inside an active sandbox run Add check_sandbox_lock() to sandbox::delete for consistency with new, run, and set -- prevents silently deleting the sandbox you are currently running inside. * revert: remove test script from PR --------- Co-authored-by: Eddie A Tejeda <669988+eddietejeda@users.noreply.github.com>
1 parent 77ef3bb commit 5a10d13

6 files changed

Lines changed: 90 additions & 17 deletions

File tree

src/command.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,10 @@ pub enum DatasetsCommands {
478478
/// Saved query ID to create the dataset from
479479
#[arg(long, conflicts_with = "sql", required_unless_present = "sql")]
480480
query_id: Option<String>,
481+
482+
/// Output format
483+
#[arg(long = "output", short = 'o', default_value = "table", value_parser = ["table", "json", "yaml"])]
484+
output: String,
481485
},
482486

483487
/// Update a dataset's description and/or name
@@ -854,6 +858,12 @@ pub enum SandboxCommands {
854858
#[arg(trailing_var_arg = true, required = true)]
855859
cmd: Vec<String>,
856860
},
861+
862+
/// Delete a sandbox permanently
863+
Delete {
864+
/// Sandbox ID to delete
865+
id: String,
866+
},
857867
}
858868

859869
#[derive(Subcommand)]

src/context.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,7 +311,28 @@ pub fn push(workspace_id: &str, database_id: &str, name: &str, dry_run: bool) {
311311

312312
let api = ApiClient::new(Some(workspace_id));
313313
let body = json!({ "name": &name, "content": content });
314-
let resp: UpsertResponse = api.post(&format!("/databases/{database_id}/context"), &body);
314+
let (status, resp_body) = api.post_raw(&format!("/databases/{database_id}/context"), &body);
315+
316+
if !status.is_success() {
317+
let msg = crate::util::api_error(resp_body.clone());
318+
if msg.to_lowercase().contains("not allowed within a session") {
319+
eprintln!("{}", msg.red());
320+
eprintln!(
321+
"{}",
322+
"hint: context push is blocked inside an active sandbox. \
323+
Run 'hotdata sandbox set' (no args) to clear the active sandbox first."
324+
.dark_grey()
325+
);
326+
} else {
327+
eprintln!("{}", msg.red());
328+
}
329+
std::process::exit(1);
330+
}
331+
332+
let resp: UpsertResponse = serde_json::from_str(&resp_body).unwrap_or_else(|e| {
333+
eprintln!("error parsing response: {e}");
334+
std::process::exit(1);
335+
});
315336

316337
println!(
317338
"{}",

src/datasets.rs

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ fn default_schema() -> String {
1717
"main".to_string()
1818
}
1919

20-
#[derive(Deserialize)]
20+
#[derive(Deserialize, Serialize)]
2121
struct CreateResponse {
2222
id: String,
2323
label: String,
@@ -75,6 +75,7 @@ fn create_dataset(
7575
description: Option<&str>,
7676
name: &str,
7777
source: serde_json::Value,
78+
format: &str,
7879
) {
7980
let label = description.unwrap_or(name);
8081
let body = json!({ "table_name": name, "label": label, "source": source });
@@ -96,28 +97,36 @@ fn create_dataset(
9697
};
9798

9899
use crossterm::style::Stylize;
99-
println!("{}", "Dataset created".green());
100-
println!("id: {}", dataset.id);
101-
println!("label: {}", dataset.label);
102-
println!(
103-
"full_name: datasets.{}.{}",
104-
dataset.schema_name, dataset.table_name
105-
);
100+
match format {
101+
"json" => println!("{}", serde_json::to_string_pretty(&dataset).unwrap()),
102+
"yaml" => print!("{}", serde_yaml::to_string(&dataset).unwrap()),
103+
"table" => {
104+
eprintln!("{}", "Dataset created".green());
105+
println!("id: {}", dataset.id);
106+
println!("label: {}", dataset.label);
107+
println!(
108+
"full_name: datasets.{}.{}",
109+
dataset.schema_name, dataset.table_name
110+
);
111+
}
112+
_ => unreachable!(),
113+
}
106114
}
107115

108-
pub fn create_from_query(workspace_id: &str, sql: &str, description: Option<&str>, name: &str) {
116+
pub fn create_from_query(workspace_id: &str, sql: &str, description: Option<&str>, name: &str, format: &str) {
109117
let api = ApiClient::new(Some(workspace_id));
110-
create_dataset(&api, description, name, json!({ "type": "sql_query", "sql": sql }));
118+
create_dataset(&api, description, name, json!({ "type": "sql_query", "sql": sql }), format);
111119
}
112120

113121
pub fn create_from_saved_query(
114122
workspace_id: &str,
115123
query_id: &str,
116124
description: Option<&str>,
117125
name: &str,
126+
format: &str,
118127
) {
119128
let api = ApiClient::new(Some(workspace_id));
120-
create_dataset(&api, description, name, json!({ "type": "saved_query", "saved_query_id": query_id }));
129+
create_dataset(&api, description, name, json!({ "type": "saved_query", "saved_query_id": query_id }), format);
121130
}
122131

123132
pub fn list(workspace_id: &str, limit: Option<u32>, offset: Option<u32>, format: &str) {

src/main.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,20 +216,23 @@ fn main() {
216216
description,
217217
sql,
218218
query_id,
219+
output,
219220
}) => {
220221
if let Some(sql) = sql {
221222
datasets::create_from_query(
222223
&workspace_id,
223224
&sql,
224225
description.as_deref(),
225226
&name,
227+
&output,
226228
)
227229
} else {
228230
datasets::create_from_saved_query(
229231
&workspace_id,
230232
query_id.as_deref().unwrap_or_else(|| unreachable!("clap enforces --sql or --query-id")),
231233
description.as_deref(),
232234
&name,
235+
&output,
233236
)
234237
}
235238
}
@@ -957,6 +960,9 @@ fn main() {
957960
Some(SandboxCommands::Set { id: set_id }) => {
958961
sandbox::set(set_id.as_deref(), &workspace_id)
959962
}
963+
Some(SandboxCommands::Delete { id: delete_id }) => {
964+
sandbox::delete(&delete_id, &workspace_id)
965+
}
960966
None => match id {
961967
Some(id) => sandbox::get(&id, &workspace_id, &output),
962968
None => {

src/sandbox.rs

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ pub fn read(sandbox_id: &str, workspace_id: &str) {
135135
if body.sandbox.markdown.is_empty() {
136136
eprintln!("{}", "Sandbox markdown is empty.".dark_grey());
137137
} else {
138-
print!("{}", body.sandbox.markdown);
138+
println!("{}", body.sandbox.markdown);
139139
}
140140
}
141141

@@ -195,7 +195,7 @@ pub fn new(workspace_id: &str, name: Option<&str>, format: &str) {
195195
eprintln!("warning: could not save sandbox to config: {e}");
196196
}
197197

198-
println!("{}", "Sandbox created".green());
198+
eprintln!("{}", "Sandbox created".green());
199199
match format {
200200
"json" => println!("{}", serde_json::json!({"public_id": sandbox_id})),
201201
"yaml" => print!(
@@ -238,7 +238,7 @@ pub fn update(
238238
let resp: DetailResponse = api.patch(&path, &body);
239239
let s = &resp.sandbox;
240240

241-
println!("{}", "Sandbox updated".green());
241+
eprintln!("{}", "Sandbox updated".green());
242242
match format {
243243
"json" => println!("{}", serde_json::to_string_pretty(s).unwrap()),
244244
"yaml" => print!("{}", serde_yaml::to_string(s).unwrap()),
@@ -340,6 +340,33 @@ pub fn set(sandbox_id: Option<&str>, workspace_id: &str) {
340340
}
341341
}
342342

343+
pub fn delete(sandbox_id: &str, workspace_id: &str) {
344+
check_sandbox_lock();
345+
let api = ApiClient::new(Some(workspace_id));
346+
let path = format!("/sandboxes/{sandbox_id}");
347+
let (status, resp_body) = api.delete_raw(&path);
348+
349+
if !status.is_success() {
350+
eprintln!("{}", crate::util::api_error(resp_body).red());
351+
std::process::exit(1);
352+
}
353+
354+
// If the deleted sandbox was the active one, clear the cached session
355+
// and config pointer so subsequent commands don't keep routing through
356+
// a stale sandbox JWT — mirroring what `sandbox set` (no args) does.
357+
let active = std::env::var("HOTDATA_SANDBOX")
358+
.ok()
359+
.or_else(|| config::load("default").ok().and_then(|p| p.sandbox));
360+
if active.as_deref() == Some(sandbox_id) {
361+
sandbox_session::clear();
362+
if let Err(e) = config::clear_sandbox("default") {
363+
eprintln!("warning: could not clear sandbox from config: {e}");
364+
}
365+
}
366+
367+
eprintln!("{}", "Sandbox deleted".green());
368+
}
369+
343370
#[cfg(test)]
344371
mod tests {
345372
use super::*;

src/workspace.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ struct ListResponse {
1717
}
1818

1919
pub fn set(workspace_id: Option<&str>) {
20-
if std::env::var("HOTDATA_WORKSPACE").is_ok()
20+
if std::env::var("HOTDATA_SANDBOX").is_ok()
2121
|| crate::sandbox::find_sandbox_run_ancestor().is_some()
2222
{
23-
eprintln!("error: workspace is locked");
23+
eprintln!("error: workspace cannot be changed inside a sandbox");
2424
std::process::exit(1);
2525
}
2626
let api = ApiClient::new(None);

0 commit comments

Comments
 (0)