From f72c79376ebb86f1d520345b45e6ac09b3248a63 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sun, 8 Mar 2026 12:39:02 -0700 Subject: [PATCH 01/12] feat(cli): improve sandbox provisioning status messages and UX Replace the single-line spinner with a step-based provisioning checklist that maps Kubernetes events to human-readable steps (Scheduled, Pulling image, Image pulled, Container created, Container started, Gateway ready, Sandbox ready). Each completed step is printed as a persistent checkmark line with elapsed time. - Add elapsed time display on spinner and completed steps - Show image name as detail text during pull - Add upload progress feedback messages - Improve non-interactive mode with timestamped step lines - Include stuck step name in timeout errors - Improve Connecting message to specify SSH Closes #174 --- crates/navigator-cli/src/run.rs | 402 +++++++++++++++++++++++++------- 1 file changed, 318 insertions(+), 84 deletions(-) diff --git a/crates/navigator-cli/src/run.rs b/crates/navigator-cli/src/run.rs index 6993299997..290b2ebe33 100644 --- a/crates/navigator-cli/src/run.rs +++ b/crates/navigator-cli/src/run.rs @@ -105,19 +105,81 @@ fn civil_from_days(days: u64) -> (i64, u64, u64) { (y, m, d) } -/// Live-updating display showing spinner with phase and latest log line. -struct LogDisplay { +/// Known provisioning steps derived from Kubernetes events and sandbox lifecycle. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum ProvisioningStep { + Scheduled, + Pulling, + Pulled, + ContainerCreated, + ContainerStarted, + GatewayReady, + SandboxReady, +} + +impl ProvisioningStep { + /// Human-readable label for a completed step. + fn completed_label(self) -> &'static str { + match self { + Self::Scheduled => "Scheduled on node", + Self::Pulling => "Image pulled", + Self::Pulled => "Image pulled", + Self::ContainerCreated => "Container created", + Self::ContainerStarted => "Container started", + Self::GatewayReady => "Gateway ready", + Self::SandboxReady => "Sandbox ready", + } + } + + /// Human-readable label for an in-progress step (shown on the spinner). + fn active_label(self) -> &'static str { + match self { + Self::Scheduled => "Scheduling on node...", + Self::Pulling => "Pulling image...", + Self::Pulled => "Pulling image...", + Self::ContainerCreated => "Creating container...", + Self::ContainerStarted => "Starting container...", + Self::GatewayReady => "Waiting for gateway...", + Self::SandboxReady => "Waiting for sandbox...", + } + } +} + +/// Map a Kubernetes event reason to a provisioning step. +fn kube_event_to_step(reason: &str) -> Option { + match reason { + "Scheduled" => Some(ProvisioningStep::Scheduled), + "Pulling" => Some(ProvisioningStep::Pulling), + "Pulled" => Some(ProvisioningStep::Pulled), + "Created" => Some(ProvisioningStep::ContainerCreated), + "Started" => Some(ProvisioningStep::ContainerStarted), + _ => None, + } +} + +/// Live-updating display showing a provisioning step checklist with spinner. +/// +/// Completed steps are printed as static `✓ Step` lines. The current +/// in-progress step is shown on a spinner with elapsed time. +struct ProvisioningDisplay { mp: MultiProgress, spinner: ProgressBar, - phase: String, - latest_log: String, + /// Steps that have been completed, in order. + completed_steps: Vec, + /// The currently active step label (shown on the spinner). + active_label: String, + /// Detail text shown next to the active step (e.g. image name). + active_detail: String, + /// When the current active step started (for elapsed time). + step_start: Instant, + /// Overall provisioning start time. + provision_start: Instant, } -impl LogDisplay { +impl ProvisioningDisplay { fn new() -> Self { let mp = MultiProgress::new(); - // Spinner for phase status + latest log let spinner = mp.add(ProgressBar::new_spinner()); spinner.set_style( ProgressStyle::with_template("{spinner:.cyan} {msg}") @@ -125,66 +187,148 @@ impl LogDisplay { ); spinner.enable_steady_tick(Duration::from_millis(120)); + let now = Instant::now(); Self { mp, spinner, - phase: String::new(), - latest_log: String::new(), + completed_steps: Vec::new(), + active_label: "Provisioning...".to_string(), + active_detail: String::new(), + step_start: now, + provision_start: now, } } - fn set_phase(&mut self, phase: &str) { - self.phase = phase.to_string(); + /// Record a completed provisioning step. + /// + /// The step is printed as a static `✓` line and the spinner advances + /// to the next expected state. + fn complete_step(&mut self, step: ProvisioningStep) { + self.complete_step_with_label(step, step.completed_label()); + } + + /// Record a completed provisioning step with a custom label. + fn complete_step_with_label(&mut self, step: ProvisioningStep, label: &str) { + // Don't duplicate steps we've already printed. + if self.completed_steps.contains(&step) { + return; + } + self.completed_steps.push(step); + + let elapsed = self.step_start.elapsed(); + let elapsed_str = format_elapsed(elapsed); + let _ = self.mp.println(format!( + " {} {} {}", + "\u{2713}".green().bold(), + label, + elapsed_str.dimmed() + )); + + // Reset step timer for the next step. + self.step_start = Instant::now(); + self.active_detail.clear(); + } + + /// Set the active (in-progress) step shown on the spinner. + fn set_active(&mut self, label: &str) { + self.active_label = label.to_string(); + self.active_detail.clear(); self.update_spinner(); } - fn finish_phase(&mut self, phase: &str) { - self.phase = phase.to_string(); - self.latest_log.clear(); - // Print the final phase as a static line above the spinner, then - // clear the spinner itself. This leaves the phase label visible - // in scrollback instead of erasing it with finish_and_clear(). - let _ = self - .mp - .println(format!(" {}", format_phase_label(&self.phase))); - self.spinner.finish_and_clear(); + /// Set the active step from a known provisioning step enum. + fn set_active_step(&mut self, step: ProvisioningStep) { + self.set_active(step.active_label()); } - fn set_log(&mut self, line: String) { - let line = line.trim().to_string(); - if line.is_empty() { - return; - } - self.latest_log = line; + /// Set detail text shown alongside the active step (e.g. image name). + fn set_active_detail(&mut self, detail: &str) { + self.active_detail = detail.to_string(); self.update_spinner(); } fn update_spinner(&self) { - let msg = if self.latest_log.is_empty() { - format_phase_label(&self.phase) + let elapsed = self.step_start.elapsed(); + let elapsed_str = format_elapsed(elapsed); + let msg = if self.active_detail.is_empty() { + format!("{} {}", self.active_label, elapsed_str.dimmed()) } else { format!( - "{} {}", - format_phase_label(&self.phase), - self.latest_log.dimmed() + "{} {} {}", + self.active_label, + self.active_detail.dimmed(), + elapsed_str.dimmed() ) }; self.spinner.set_message(msg); } + /// Finish the display, printing the final status line. + fn finish(&mut self, final_label: &str, success: bool) { + if success { + let elapsed = self.provision_start.elapsed(); + let elapsed_str = format_elapsed(elapsed); + let _ = self.mp.println(format!( + " {} {} {}", + "\u{2713}".green().bold(), + final_label.green().bold(), + elapsed_str.dimmed() + )); + } else { + let _ = self.mp.println(format!( + " {} {}", + "\u{2717}".red().bold(), + final_label.red().bold() + )); + } + self.spinner.finish_and_clear(); + } + + /// Finish with an error message shown on the last step line. + fn finish_error(&mut self, msg: &str) { + let _ = self + .mp + .println(format!(" {} {}", "\u{2717}".red().bold(), msg.red())); + self.spinner.finish_and_clear(); + } + /// Print a line above the progress bars (for static header content). fn println(&self, msg: &str) { let _ = self.mp.println(msg); } + + /// Return a description of the current active step for error messages. + fn current_step_description(&self) -> &str { + &self.active_label + } } -fn print_sandbox_header(sandbox: &Sandbox, display: Option<&LogDisplay>) { +/// Format a duration as a compact elapsed time string, e.g. `(3s)` or `(1m 12s)`. +fn format_elapsed(d: Duration) -> String { + let secs = d.as_secs(); + if secs < 60 { + format!("({secs}s)") + } else { + let mins = secs / 60; + let rem = secs % 60; + format!("({mins}m {rem}s)") + } +} + +/// Format a total elapsed time for non-interactive mode timestamps. +fn format_timestamp(d: Duration) -> String { + let secs = d.as_secs_f64(); + format!("[{secs:.1}s]") +} + +fn print_sandbox_header(sandbox: &Sandbox, display: Option<&ProvisioningDisplay>) { let lines = [ String::new(), format!("{}", "Created sandbox:".cyan().bold()), String::new(), format!(" {} {}", "Name:".dimmed(), sandbox.name), format!(" {} {}", "Namespace:".dimmed(), sandbox.namespace), + String::new(), ]; match display { Some(d) => { @@ -200,16 +344,6 @@ fn print_sandbox_header(sandbox: &Sandbox, display: Option<&LogDisplay>) { } } -fn format_phase_label(phase: &str) -> String { - let colored = match phase { - "Ready" => phase.green().to_string(), - "Error" => phase.red().to_string(), - "Provisioning" => phase.yellow().to_string(), - _ => phase.dimmed().to_string(), - }; - format!("{} {colored}", "Phase:".dimmed()) -} - const CLUSTER_DEPLOY_LOG_LINES: usize = 15; /// Return the current terminal width, falling back to 80 columns. @@ -1128,9 +1262,10 @@ pub async fn sandbox_create( let _ = save_last_sandbox(cluster, &sandbox_name); } - // Set up display + // Set up display — interactive terminals get a step-based checklist with + // spinners; non-interactive (pipes / CI) get timestamped lines. let mut display = if interactive { - Some(LogDisplay::new()) + Some(ProvisioningDisplay::new()) } else { None }; @@ -1138,13 +1273,17 @@ pub async fn sandbox_create( // Print header print_sandbox_header(&sandbox, display.as_ref()); - // Set initial phase + // Set initial active step on the spinner. if let Some(d) = display.as_mut() { - d.set_phase(phase_name(sandbox.phase)); + d.set_active("Provisioning..."); } else { - println!(" {}", format_phase_label(phase_name(sandbox.phase))); + let ts = format_timestamp(Duration::ZERO); + println!(" {} Created sandbox {}", ts.dimmed(), sandbox_name); } + // Non-interactive mode: track start time for timestamps. + let provision_start = Instant::now(); + // Don't use stop_on_terminal on the server — the Kubernetes CRD may // briefly report a stale Ready status before the controller reconciles // a newly created sandbox. Instead we handle termination client-side: @@ -1173,17 +1312,23 @@ pub async fn sandbox_create( let mut saw_non_ready = SandboxPhase::try_from(sandbox.phase) != Ok(SandboxPhase::Ready); let start_time = Instant::now(); let provision_timeout = Duration::from_secs(120); + // Track whether we saw the gateway become ready (from log messages). + let mut saw_gateway_ready = false; while let Some(item) = stream.next().await { // Check for timeout if start_time.elapsed() > provision_timeout { if let Some(d) = display.as_mut() { - d.finish_phase(phase_name(last_phase)); + let step_desc = d.current_step_description().to_string(); + d.finish_error(&format!( + "Timed out after {}s (stuck at: {step_desc})", + provision_timeout.as_secs() + )); } println!(); return Err(miette::miette!( - "sandbox provisioning timed out after {:?}", - provision_timeout + "sandbox provisioning timed out after {}s", + provision_timeout.as_secs() )); } @@ -1211,65 +1356,153 @@ pub async fn sandbox_create( } } } - if let Some(d) = display.as_mut() { - d.set_phase(phase_name(s.phase)); - } else { - println!(" {}", format_phase_label(phase_name(s.phase))); - } // Only accept Ready as terminal after we've observed a // non-Ready phase, proving the controller has reconciled. if saw_non_ready && phase == SandboxPhase::Ready { + // Complete the gateway step if we haven't already. + if !saw_gateway_ready { + if let Some(d) = display.as_mut() { + d.complete_step(ProvisioningStep::GatewayReady); + } + } + // Complete the final step. + if let Some(d) = display.as_mut() { + d.finish("Sandbox ready", true); + } else { + let ts = format_timestamp(provision_start.elapsed()); + println!(" {} {}", ts.dimmed(), "Sandbox ready".green().bold()); + } break; } } Some(navigator_core::proto::sandbox_stream_event::Payload::Log(line)) => { - if let Some(d) = display.as_mut() { - d.set_log(line.message); + // Detect gateway readiness from log messages. + if !saw_gateway_ready && line.message.contains("listening") { + saw_gateway_ready = true; + if let Some(d) = display.as_mut() { + d.complete_step(ProvisioningStep::GatewayReady); + d.set_active_step(ProvisioningStep::SandboxReady); + } else { + let ts = format_timestamp(provision_start.elapsed()); + println!(" {} Gateway ready", ts.dimmed()); + } + } else if let Some(d) = display.as_mut() { + // Show other log lines as detail on the spinner. + d.set_active_detail(&line.message); } } Some(navigator_core::proto::sandbox_stream_event::Payload::Event(ev)) => { - let reason = if ev.reason.is_empty() { - "Event" - } else { - &ev.reason - }; - let msg = if ev.message.is_empty() { - "" - } else { - &ev.message - }; - let line = format!("{} {} {}", "EVENT".dimmed(), reason, msg); - if let Some(d) = display.as_mut() { - d.set_log(line); + // Map Kubernetes events to provisioning steps. + if let Some(step) = kube_event_to_step(&ev.reason) { + match step { + ProvisioningStep::Scheduled => { + if let Some(d) = display.as_mut() { + d.complete_step(ProvisioningStep::Scheduled); + d.set_active_step(ProvisioningStep::Pulling); + } else { + let ts = format_timestamp(provision_start.elapsed()); + println!(" {} Scheduled on node", ts.dimmed()); + } + } + ProvisioningStep::Pulling => { + // Extract image name from the event message. + let image_detail = ev + .message + .strip_prefix("Pulling image ") + .map(|s| s.trim_matches('"')) + .unwrap_or(""); + if let Some(d) = display.as_mut() { + d.set_active("Pulling image..."); + if !image_detail.is_empty() { + d.set_active_detail(image_detail); + } + } else { + let ts = format_timestamp(provision_start.elapsed()); + if image_detail.is_empty() { + println!(" {} Pulling image...", ts.dimmed()); + } else { + println!(" {} Pulling image {image_detail}", ts.dimmed()); + } + } + } + ProvisioningStep::Pulled => { + if let Some(d) = display.as_mut() { + d.complete_step(ProvisioningStep::Pulled); + d.set_active_step(ProvisioningStep::ContainerCreated); + } else { + let ts = format_timestamp(provision_start.elapsed()); + println!(" {} Image pulled", ts.dimmed()); + } + } + ProvisioningStep::ContainerCreated => { + if let Some(d) = display.as_mut() { + d.complete_step(ProvisioningStep::ContainerCreated); + d.set_active_step(ProvisioningStep::ContainerStarted); + } else { + let ts = format_timestamp(provision_start.elapsed()); + println!(" {} Container created", ts.dimmed()); + } + } + ProvisioningStep::ContainerStarted => { + if let Some(d) = display.as_mut() { + d.complete_step(ProvisioningStep::ContainerStarted); + d.set_active_step(ProvisioningStep::GatewayReady); + } else { + let ts = format_timestamp(provision_start.elapsed()); + println!(" {} Container started", ts.dimmed()); + } + } + _ => {} + } + } else if let Some(d) = display.as_mut() { + // Unknown events: show as detail on the current spinner. + if !ev.message.is_empty() { + d.set_active_detail(&ev.message); + } } } Some(navigator_core::proto::sandbox_stream_event::Payload::Warning(w)) => { - let line = format!("{} {}", "WARN".yellow(), w.message); if let Some(d) = display.as_mut() { - d.set_log(line); + d.println(&format!(" {} {}", "!".yellow().bold(), w.message.yellow())); + } else { + let ts = format_timestamp(provision_start.elapsed()); + eprintln!(" {} {} {}", ts.dimmed(), "WARN".yellow(), w.message); } } None => {} } } - // Finish up - check final phase - if let Some(d) = display.as_mut() { - d.finish_phase(phase_name(last_phase)); + // If we exited the loop without hitting the Ready break, finish the display. + let final_phase = SandboxPhase::try_from(last_phase).unwrap_or(SandboxPhase::Unknown); + if final_phase != SandboxPhase::Ready { + if let Some(d) = display.as_mut() { + if final_phase == SandboxPhase::Error { + let msg = if last_error_reason.is_empty() { + "Sandbox entered error phase".to_string() + } else { + format!("Error: {last_error_reason}") + }; + d.finish_error(&msg); + } else { + d.finish_error("Provisioning stream ended unexpectedly"); + } + } } drop(display); let _ = std::io::stdout().flush(); let _ = std::io::stderr().flush(); println!(); - match SandboxPhase::try_from(last_phase) { - Ok(SandboxPhase::Ready) => { + match final_phase { + SandboxPhase::Ready => { drop(stream); drop(client); if let Some((local_path, sandbox_path, git_ignore)) = upload { let dest = sandbox_path.as_deref().unwrap_or("/sandbox"); + eprintln!(" {} Uploading files to {dest}...", "\u{2022}".dimmed(),); let local = Path::new(local_path); if *git_ignore && let Ok((base_dir, files)) = git_sync_files(local) { sandbox_sync_up_files( @@ -1291,6 +1524,7 @@ pub async fn sandbox_create( ) .await?; } + eprintln!(" {} Files uploaded", "\u{2713}".green().bold(),); } // If --forward was requested, start the background port forward @@ -1306,19 +1540,19 @@ pub async fn sandbox_create( ) .await?; eprintln!( - "{} Forwarding port {port} to sandbox {sandbox_name} in the background\n", - "✓".green().bold(), + " {} Forwarding port {port} to sandbox {sandbox_name} in the background\n", + "\u{2713}".green().bold(), ); - eprintln!("Access at: http://127.0.0.1:{port}/"); - eprintln!("Stop with: nemoclaw forward stop {port} {sandbox_name}",); + eprintln!(" Access at: http://127.0.0.1:{port}/"); + eprintln!(" Stop with: nemoclaw forward stop {port} {sandbox_name}",); } if command.is_empty() { - eprintln!("Connecting..."); + eprintln!(" {} Connecting via SSH...", "\u{2022}".dimmed(),); return sandbox_connect(&effective_server, &sandbox_name, &effective_tls).await; } - eprintln!("Connecting..."); + eprintln!(" {} Connecting via SSH...", "\u{2022}".dimmed(),); // Resolve TTY mode: explicit --tty / --no-tty wins, otherwise // auto-detect from the local terminal. let tty = tty_override.unwrap_or_else(|| { @@ -1355,7 +1589,7 @@ pub async fn sandbox_create( exec_result } - Ok(SandboxPhase::Error) => { + SandboxPhase::Error => { if last_error_reason.is_empty() { Err(miette::miette!( "sandbox entered error phase while provisioning" From 4e55a7b20f18b0b551ae3d11c9a3f2037a52b620 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sun, 8 Mar 2026 13:45:24 -0700 Subject: [PATCH 02/12] refactor(cli): simplify gateway start bootstrap output Consolidate 13+ individual status lines into 4 high-level phases (Pulling gateway image, Preparing gateway, Starting gateway, Waiting for gateway) with sub-step details shown only in the spinner during execution. Add granular progress messages during PKI generation to avoid the spinner appearing stuck. Rename user-facing 'cluster' references to 'gateway' throughout the deploy flow. --- crates/navigator-bootstrap/src/lib.rs | 42 ++++++++++--------- crates/navigator-cli/src/bootstrap.rs | 2 +- crates/navigator-cli/src/run.rs | 60 +++++++++++++++------------ 3 files changed, 57 insertions(+), 47 deletions(-) diff --git a/crates/navigator-bootstrap/src/lib.rs b/crates/navigator-bootstrap/src/lib.rs index 2548a69357..18c44dff90 100644 --- a/crates/navigator-bootstrap/src/lib.rs +++ b/crates/navigator-bootstrap/src/lib.rs @@ -243,7 +243,7 @@ where // Ensure the image is available on the target Docker daemon if remote_opts.is_some() { - log("[status] Pulling cluster image on remote host".to_string()); + log("[status] Pulling gateway image".to_string()); let on_log_clone = Arc::clone(&on_log); let progress_cb = move |msg: String| { if let Ok(mut f) = on_log_clone.lock() { @@ -253,14 +253,15 @@ where image::pull_remote_image(&target_docker, &image_ref, progress_cb).await?; } else { // Local deployment: ensure image exists (pull if needed) - log("[status] Ensuring cluster image is available".to_string()); + log("[status] Pulling gateway image".to_string()); ensure_image(&target_docker, &image_ref).await?; } // All subsequent operations use the target Docker (remote or local) - log("[status] Creating cluster network".to_string()); + log("[status] Preparing gateway".to_string()); + log("[progress] Creating gateway network".to_string()); ensure_network(&target_docker).await?; - log("[status] Preparing cluster volume".to_string()); + log("[progress] Preparing gateway volume".to_string()); ensure_volume(&target_docker, &volume_name(&name)).await?; // Compute extra TLS SANs for remote deployments so the gateway and k3s @@ -297,7 +298,7 @@ where (sans, gateway_host) }; - log("[status] Creating cluster container".to_string()); + log("[progress] Creating gateway container".to_string()); ensure_container( &target_docker, &name, @@ -308,10 +309,10 @@ where kube_port, ) .await?; - log("[status] Starting cluster container".to_string()); + log("[status] Starting gateway".to_string()); start_container(&target_docker, &name).await?; - log("[status] Waiting for kubeconfig".to_string()); + log("[progress] Waiting for kubeconfig".to_string()); let raw_kubeconfig = wait_for_kubeconfig(&target_docker, &name).await?; // Rewrite kubeconfig based on deployment mode @@ -319,15 +320,15 @@ where || rewrite_kubeconfig(&raw_kubeconfig, &name, kube_port), |opts| rewrite_kubeconfig_remote(&raw_kubeconfig, &name, &opts.destination, kube_port), ); - log("[status] Writing kubeconfig".to_string()); + log("[progress] Writing kubeconfig".to_string()); store_kubeconfig(&kubeconfig_path, &rewritten)?; // Clean up stale k3s nodes left over from previous container instances that // used the same persistent volume. Without this, pods remain scheduled on // NotReady ghost nodes and the health check will time out. - log("[status] Cleaning stale nodes".to_string()); + log("[progress] Cleaning stale nodes".to_string()); match clean_stale_nodes(&target_docker, &name).await { Ok(0) => {} - Ok(n) => log(format!("[status] Removed {n} stale node(s)")), + Ok(n) => log(format!("[progress] Removed {n} stale node(s)")), Err(err) => { tracing::debug!("stale node cleanup failed (non-fatal): {err}"); } @@ -344,7 +345,7 @@ where // cluster, secrets are always newly generated and a restart is unnecessary. // Restarting only when workload pre-existed avoids extra rollout latency. let workload_existed_before_pki = navigator_workload_exists(&target_docker, &name).await?; - log("[status] Reconciling TLS certificates".to_string()); + log("[progress] Reconciling TLS certificates".to_string()); let (pki_bundle, rotated) = reconcile_pki(&target_docker, &name, &extra_sans, &log).await?; if rotated && workload_existed_before_pki { @@ -352,11 +353,11 @@ where // it picks up the new TLS secrets before we write CLI-side certs. // A failed rollout is a hard error — CLI certs must not be persisted // if the server cannot come up with the new PKI. - log("[status] PKI rotated — restarting navigator workload".to_string()); + log("[progress] PKI rotated — restarting navigator workload".to_string()); restart_navigator_deployment(&target_docker, &name).await?; } - log("[status] Storing CLI mTLS credentials".to_string()); + log("[progress] Storing CLI mTLS credentials".to_string()); store_pki_bundle(&name, &pki_bundle)?; // Push locally-built component images into the k3s containerd runtime. @@ -373,7 +374,7 @@ where .collect(); if !images.is_empty() { log(format!( - "[status] Push mode: importing {} local image(s) into cluster", + "[progress] Importing {} local image(s) into gateway", images.len() )); let local_docker = Docker::connect_with_local_defaults().into_diagnostic()?; @@ -393,12 +394,12 @@ where ) .await?; - log("[status] Restarting navigator deployment to pick up imported images".to_string()); + log("[progress] Restarting navigator deployment".to_string()); restart_navigator_deployment(&target_docker, &name).await?; } } - log("[status] Waiting for control plane health checks".to_string()); + log("[status] Waiting for gateway".to_string()); { // Create a short-lived closure that locks on each call rather than holding // the MutexGuard across await points. @@ -412,7 +413,7 @@ where } // Create and store cluster metadata - log("[status] Persisting cluster metadata".to_string()); + log("[progress] Persisting gateway metadata".to_string()); let metadata = create_cluster_metadata_with_host( &name, remote_opts.as_ref(), @@ -590,12 +591,12 @@ where // Try to load existing secrets. match load_existing_pki_bundle(docker, &cname, kubeconfig).await { Ok(bundle) => { - log("[status] Reusing existing TLS certificates".to_string()); + log("[progress] Reusing existing TLS certificates".to_string()); return Ok((bundle, false)); } Err(reason) => { log(format!( - "[status] Cannot reuse existing TLS secrets ({reason}) — generating new PKI" + "[progress] Cannot reuse existing TLS secrets ({reason}) — generating new PKI" )); } } @@ -603,8 +604,11 @@ where // Generate fresh PKI and apply to cluster. // Namespace may still be creating on first bootstrap, so wait here only // when rotation is actually needed. + log("[progress] Waiting for navigator namespace".to_string()); wait_for_namespace(docker, &cname, kubeconfig, "navigator").await?; + log("[progress] Generating TLS certificates".to_string()); let bundle = generate_pki(extra_sans)?; + log("[progress] Applying TLS secrets to gateway".to_string()); create_k8s_tls_secrets(docker, name, &bundle) .await .wrap_err("failed to apply new TLS secrets")?; diff --git a/crates/navigator-cli/src/bootstrap.rs b/crates/navigator-cli/src/bootstrap.rs index fbd1a266ee..e19a37c7c8 100644 --- a/crates/navigator-cli/src/bootstrap.rs +++ b/crates/navigator-cli/src/bootstrap.rs @@ -116,7 +116,7 @@ pub fn confirm_bootstrap(override_value: Option) -> Result { let confirmed = Confirm::new() .with_prompt(format!( - "{} No cluster available to launch sandbox in. Create one now?", + "{} No gateway available to launch sandbox in. Create one now?", "!".yellow() )) .default(true) diff --git a/crates/navigator-cli/src/run.rs b/crates/navigator-cli/src/run.rs index 290b2ebe33..0c936809ad 100644 --- a/crates/navigator-cli/src/run.rs +++ b/crates/navigator-cli/src/run.rs @@ -397,9 +397,8 @@ fn truncate_to_width(s: &str, max_width: usize) -> String { struct ClusterDeployLogPanel { mp: MultiProgress, - name: String, - location: String, status: String, + progress: Option, current_step: Option, spinner: ProgressBar, completed_steps: Vec, @@ -410,7 +409,7 @@ struct ClusterDeployLogPanel { } impl ClusterDeployLogPanel { - fn new(name: &str, location: &str) -> Self { + fn new(_name: &str, _location: &str) -> Self { let mp = MultiProgress::new(); let spinner = mp.add(ProgressBar::new_spinner()); @@ -422,9 +421,8 @@ impl ClusterDeployLogPanel { let panel = Self { mp, - name: name.to_string(), - location: location.to_string(), - status: "Starting bootstrap".to_string(), + status: "Starting".to_string(), + progress: None, current_step: None, spinner, completed_steps: Vec::new(), @@ -448,6 +446,11 @@ impl ClusterDeployLogPanel { return; } + if let Some(detail) = line.strip_prefix("[progress] ") { + self.handle_progress(detail.to_string()); + return; + } + self.ensure_log_panel(); if self.buffer.len() == CLUSTER_DEPLOY_LOG_LINES { @@ -459,12 +462,7 @@ impl ClusterDeployLogPanel { fn handle_status(&mut self, status: String) { if is_progress_status(&status) { - if let Some(step) = &self.current_step { - self.status = format!("{step} ({status})"); - } else { - self.status = status; - } - self.update_spinner_message(); + self.handle_progress(status); return; } @@ -473,6 +471,12 @@ impl ClusterDeployLogPanel { } self.status = status; + self.progress = None; + self.update_spinner_message(); + } + + fn handle_progress(&mut self, detail: String) { + self.progress = Some(detail); self.update_spinner_message(); } @@ -529,12 +533,12 @@ impl ClusterDeployLogPanel { } fn update_spinner_message(&self) { - self.spinner.set_message(format!( - "Bootstrapping {} cluster {}: {}", - self.location, - self.name, - self.status.dimmed() - )); + let msg = if let Some(detail) = &self.progress { + format!("{} ({})", self.status, detail.dimmed()) + } else { + self.status.clone() + }; + self.spinner.set_message(msg); } fn finish_success(&mut self) { @@ -745,13 +749,13 @@ fn prompt_existing_cluster( "volume only" }; - eprintln!("• Existing cluster '{name}' detected ({status})"); + eprintln!("• Existing gateway '{name}' detected ({status})"); if let Some(image) = &info.container_image { eprintln!(" {} {}", "Image:".dimmed(), image); } eprintln!(); - eprint!("Destroy and recreate from scratch? [y/N] "); + eprint!("Destroy and recreate gateway from scratch? [y/N] "); std::io::stderr().flush().ok(); let mut input = String::new(); @@ -803,22 +807,24 @@ pub(crate) async fn deploy_cluster_with_panel( eprintln!( "{} {} {name}", "x".red().bold(), - "Cluster failed:".red().bold(), + "Gateway failed:".red().bold(), ); Err(err) } } } else { - eprintln!("Deploying {location} cluster {name}..."); + eprintln!("Deploying {location} gateway {name}..."); let handle = navigator_bootstrap::deploy_cluster_with_logs(options, |line| { if let Some(status) = line.strip_prefix("[status] ") { eprintln!(" {status}"); + } else if line.strip_prefix("[progress] ").is_some() { + // Sub-step progress: skip in non-interactive mode } else { eprintln!(" {line}"); } }) .await?; - eprintln!("Cluster {name} ready."); + eprintln!("Gateway {name} ready."); Ok(handle) } } @@ -828,7 +834,7 @@ pub(crate) fn print_deploy_summary(name: &str, handle: &navigator_bootstrap::Clu eprintln!( "{} {} {name}", "✓".green().bold(), - "Cluster ready:".green().bold(), + "Gateway ready:".green().bold(), ); eprintln!( " {} {}", @@ -896,11 +902,11 @@ pub async fn cluster_admin_deploy( }; if should_recreate { - eprintln!("• Destroying existing cluster..."); + eprintln!("• Destroying existing gateway..."); let handle = navigator_bootstrap::cluster_handle(name, remote_opts.as_ref()).await?; handle.destroy().await?; - eprintln!("{} Cluster destroyed, starting fresh.", "✓".green().bold()); + eprintln!("{} Gateway destroyed, starting fresh.", "✓".green().bold()); eprintln!(); } // If reusing, the deploy flow will handle stale node cleanup automatically @@ -927,7 +933,7 @@ pub async fn cluster_admin_deploy( // Auto-activate: set this cluster as the active cluster. save_active_cluster(name)?; - eprintln!("{} Active cluster set to '{name}'", "✓".green().bold()); + eprintln!("{} Active gateway set to '{name}'", "✓".green().bold()); Ok(()) } From b2891956f39fa61a18d06772f5bb7bcad3c34a03 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sun, 8 Mar 2026 13:47:57 -0700 Subject: [PATCH 03/12] refactor(cli): clear container logs on successful gateway start On success, clear the container log panel from terminal output so the final result is a clean list of phase checkmarks followed by the gateway ready summary. On failure, preserve the log panel for debugging. --- crates/navigator-cli/src/run.rs | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/crates/navigator-cli/src/run.rs b/crates/navigator-cli/src/run.rs index 0c936809ad..ea5d26497f 100644 --- a/crates/navigator-cli/src/run.rs +++ b/crates/navigator-cli/src/run.rs @@ -545,7 +545,11 @@ impl ClusterDeployLogPanel { if let Some(step) = self.current_step.take() { self.push_completed_step(&step, true); } - self.finish_all_bars(); + // Keep completed step checkmarks visible, clear the log panel. + for bar in &self.completed_steps { + bar.finish(); + } + self.clear_log_panel(); self.spinner.finish_and_clear(); } @@ -553,12 +557,7 @@ impl ClusterDeployLogPanel { if let Some(step) = self.current_step.take() { self.push_completed_step(&step, false); } - self.finish_all_bars(); - self.spinner.finish_and_clear(); - } - - /// Finish all progress bars so they are preserved when `MultiProgress` is dropped. - fn finish_all_bars(&self) { + // On failure, preserve everything (including logs) for debugging. for bar in &self.completed_steps { bar.finish(); } @@ -571,6 +570,20 @@ impl ClusterDeployLogPanel { if let Some(bottom_border) = &self.bottom_border { bottom_border.finish(); } + self.spinner.finish_and_clear(); + } + + /// Clear the container log panel from the terminal output. + fn clear_log_panel(&self) { + if let Some(top_border) = &self.top_border { + top_border.finish_and_clear(); + } + for bar in &self.log_lines { + bar.finish_and_clear(); + } + if let Some(bottom_border) = &self.bottom_border { + bottom_border.finish_and_clear(); + } } fn render(&self) { From ed86047cf7cc09bcb0188aef6970a3105c6d3e70 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sun, 8 Mar 2026 13:57:50 -0700 Subject: [PATCH 04/12] refactor(cli): polish deploy summary spacing and emphasis Add blank line before 'Gateway ready' to separate it from the phase checkmarks. Make 'Gateway endpoint:' bold instead of 'Gateway ready:' to emphasize the actionable information. --- crates/navigator-cli/src/run.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/navigator-cli/src/run.rs b/crates/navigator-cli/src/run.rs index ea5d26497f..b6d92621e4 100644 --- a/crates/navigator-cli/src/run.rs +++ b/crates/navigator-cli/src/run.rs @@ -844,14 +844,15 @@ pub(crate) async fn deploy_cluster_with_panel( /// Print post-deploy summary showing the cluster name and gateway endpoint. pub(crate) fn print_deploy_summary(name: &str, handle: &navigator_bootstrap::ClusterHandle) { + eprintln!(); eprintln!( "{} {} {name}", "✓".green().bold(), - "Gateway ready:".green().bold(), + "Gateway ready:".green(), ); eprintln!( " {} {}", - "Gateway endpoint:".dimmed(), + "Gateway endpoint:".bold(), handle.gateway_endpoint() ); eprintln!(); From 98fe063a15617115ec31663db873ddaf775dccc2 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sun, 8 Mar 2026 14:16:16 -0700 Subject: [PATCH 05/12] fix(cli): gateway start should respect NEMOCLAW_CLUSTER env var Resolve the gateway name using the same priority chain as stop, destroy, and info: --name flag > --cluster flag > NEMOCLAW_CLUSTER env > active cluster file > 'nemoclaw' default. Previously, --name always defaulted to 'nemoclaw', ignoring the env var. --- crates/navigator-cli/src/main.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/navigator-cli/src/main.rs b/crates/navigator-cli/src/main.rs index 73681a745a..48b8851069 100644 --- a/crates/navigator-cli/src/main.rs +++ b/crates/navigator-cli/src/main.rs @@ -428,9 +428,9 @@ enum ProviderCommands { enum GatewayCommands { /// Deploy/start the gateway. Start { - /// Gateway name. - #[arg(long, default_value = "nemoclaw")] - name: String, + /// Gateway name (defaults to active gateway). + #[arg(long)] + name: Option, /// Write stored kubeconfig into local kubeconfig. #[arg(long)] @@ -936,6 +936,9 @@ async fn main() -> Result<()> { kube_port, recreate, } => { + let name = name + .or_else(|| resolve_cluster_name(&cli.cluster)) + .unwrap_or_else(|| "nemoclaw".to_string()); run::cluster_admin_deploy( &name, update_kube_config, From 24117d7ae72bacddf66a8e2a1dac8435762f5e06 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sun, 8 Mar 2026 14:30:53 -0700 Subject: [PATCH 06/12] refactor(cli): rename --cluster flag to --gateway Replace the global --cluster/-c flag with --gateway/-g across the CLI. Rename internal ClusterContext/resolve_cluster to GatewayContext/resolve_gateway. Update SshProxy to use --gateway-name (with --cluster kept as a visible alias for backwards compat). Update ProxyCommand strings in ssh.rs and navigator-tui. Keep NEMOCLAW_CLUSTER env var unchanged for backwards compatibility. --- .agents/skills/nemoclaw-cli/cli-reference.md | 4 +- .env.example | 2 +- architecture/tui.md | 10 +- crates/navigator-cli/src/main.rs | 98 ++++++++++---------- crates/navigator-cli/src/run.rs | 10 +- crates/navigator-cli/src/ssh.rs | 8 +- crates/navigator-tui/src/lib.rs | 2 +- examples/vscode-remote-sandbox.md | 2 +- 8 files changed, 66 insertions(+), 70 deletions(-) diff --git a/.agents/skills/nemoclaw-cli/cli-reference.md b/.agents/skills/nemoclaw-cli/cli-reference.md index 2ff02478f8..7805bf136e 100644 --- a/.agents/skills/nemoclaw-cli/cli-reference.md +++ b/.agents/skills/nemoclaw-cli/cli-reference.md @@ -9,13 +9,13 @@ Quick-reference for the `nemoclaw` command-line interface. For workflow guidance | Flag | Description | |------|-------------| | `-v`, `--verbose` | Increase verbosity (`-v` = info, `-vv` = debug, `-vvv` = trace) | -| `-c`, `--gateway ` | Gateway to operate on. Also settable via `NEMOCLAW_CLUSTER` env var. Falls back to active gateway in `~/.config/nemoclaw/active_cluster`. | +| `-g`, `--gateway ` | Gateway to operate on. Also settable via `NEMOCLAW_CLUSTER` env var. Falls back to active gateway in `~/.config/nemoclaw/active_cluster`. | ## Environment Variables | Variable | Description | |----------|-------------| -| `NEMOCLAW_CLUSTER` | Override active cluster name (same as `--cluster`) | +| `NEMOCLAW_CLUSTER` | Override active gateway name (same as `--gateway`) | | `NEMOCLAW_SANDBOX_POLICY` | Path to default sandbox policy YAML (fallback when `--policy` is not provided) | --- diff --git a/.env.example b/.env.example index 3c52d4210c..ba4de4a239 100644 --- a/.env.example +++ b/.env.example @@ -11,7 +11,7 @@ # basename (e.g. "nemoclaw-c"). #CLUSTER_NAME=nemoclaw-c -# Default cluster name used by `nemoclaw` commands in this repo when `--cluster` +# Default gateway name used by `nemoclaw` commands in this repo when `--gateway` # is not provided. Usually matches CLUSTER_NAME. #NEMOCLAW_CLUSTER=nemoclaw-c diff --git a/architecture/tui.md b/architecture/tui.md index d614a8d4d3..e049b9fc67 100644 --- a/architecture/tui.md +++ b/architecture/tui.md @@ -7,17 +7,17 @@ The NemoClaw TUI is a terminal user interface for NemoClaw, inspired by [k9s](ht The TUI is a subcommand of the NemoClaw CLI, so it inherits all your existing configuration — cluster selection, TLS settings, and verbosity flags all work the same way. ```bash -nemoclaw term # launch against the active cluster +nemoclaw term # launch against the active gateway nav term # dev alias (builds from source) -nav term --cluster prod # target a specific cluster +nav term --gateway prod # target a specific gateway NEMOCLAW_CLUSTER=prod nav term # same thing, via environment variable ``` -Cluster resolution follows the same priority as the rest of the CLI: +Gateway resolution follows the same priority as the rest of the CLI: -1. `--cluster` flag (if provided) +1. `--gateway` flag (if provided) 2. `NEMOCLAW_CLUSTER` environment variable -3. Active cluster from `~/.config/nemoclaw/active_cluster` +3. Active gateway from `~/.config/nemoclaw/active_cluster` No separate configuration files or authentication are needed. diff --git a/crates/navigator-cli/src/main.rs b/crates/navigator-cli/src/main.rs index 48b8851069..24e6a909c8 100644 --- a/crates/navigator-cli/src/main.rs +++ b/crates/navigator-cli/src/main.rs @@ -17,24 +17,24 @@ use navigator_cli::completers; use navigator_cli::run; use navigator_cli::tls::TlsOptions; -/// Resolved cluster context: name + gateway endpoint. -struct ClusterContext { - /// The cluster name (used for TLS cert directory, metadata lookup, etc.). +/// Resolved gateway context: name + endpoint. +struct GatewayContext { + /// The gateway name (used for TLS cert directory, metadata lookup, etc.). name: String, /// The gateway endpoint URL (e.g., `https://127.0.0.1` or `https://10.0.0.5`). endpoint: String, } -/// Resolve the cluster name to a [`ClusterContext`] with the gateway endpoint. +/// Resolve the gateway name to a [`GatewayContext`] with the endpoint URL. /// /// Resolution priority: -/// 1. `--cluster` flag (explicit name) +/// 1. `--gateway` flag (explicit name) /// 2. `NEMOCLAW_CLUSTER` environment variable -/// 3. Active cluster from `~/.config/nemoclaw/active_cluster` +/// 3. Active gateway from `~/.config/nemoclaw/active_cluster` /// -/// Once the name is determined, loads the cluster metadata to get the endpoint. -fn resolve_cluster(cluster_flag: &Option) -> Result { - let name = cluster_flag +/// Once the name is determined, loads the gateway metadata to get the endpoint. +fn resolve_gateway(gateway_flag: &Option) -> Result { + let name = gateway_flag .clone() .or_else(|| { std::env::var("NEMOCLAW_CLUSTER") @@ -58,18 +58,18 @@ fn resolve_cluster(cluster_flag: &Option) -> Result { ) })?; - Ok(ClusterContext { + Ok(GatewayContext { name: metadata.name, endpoint: metadata.gateway_endpoint, }) } -/// Resolve only the cluster name (without requiring metadata to exist). +/// Resolve only the gateway name (without requiring metadata to exist). /// -/// Used by gateway commands that operate on a cluster by name but may not need -/// the gateway endpoint (e.g., `gateway start` creates the cluster). -fn resolve_cluster_name(cluster_flag: &Option) -> Option { - cluster_flag +/// Used by gateway commands that operate by name but may not need +/// the endpoint (e.g., `gateway start` creates the gateway). +fn resolve_gateway_name(gateway_flag: &Option) -> Option { + gateway_flag .clone() .or_else(|| { std::env::var("NEMOCLAW_CLUSTER") @@ -108,9 +108,9 @@ struct Cli { #[arg(short, long, action = clap::ArgAction::Count, global = true)] verbose: u8, - /// Cluster name to operate on (resolved from stored metadata). - #[arg(long, short, global = true, env = "NEMOCLAW_CLUSTER")] - cluster: Option, + /// Gateway name to operate on (resolved from stored metadata). + #[arg(long, short = 'g', global = true, env = "NEMOCLAW_CLUSTER")] + gateway: Option, #[command(subcommand)] command: Option, @@ -202,7 +202,7 @@ enum Commands { /// `nemoclaw ssh-proxy --gateway --sandbox-id --token ` /// /// **Name mode** (for use in `~/.ssh/config`): - /// `nemoclaw ssh-proxy --cluster --name ` + /// `nemoclaw ssh-proxy --gateway-name --name ` SshProxy { /// Gateway URL (e.g., ). /// Required in token mode. @@ -217,13 +217,13 @@ enum Commands { #[arg(long)] token: Option, - /// Cluster endpoint URL. Used in name mode. Deprecated: prefer --cluster. + /// Gateway endpoint URL. Used in name mode. Deprecated: prefer --gateway-name. #[arg(long)] server: Option, - /// Cluster name (resolves endpoint from stored metadata). Used in name mode. - #[arg(long, short)] - cluster: Option, + /// Gateway name (resolves endpoint from stored metadata). Used in name mode. + #[arg(long = "gateway-name", visible_alias = "cluster")] + gateway_name: Option, /// Sandbox name. Used in name mode. #[arg(long)] @@ -937,7 +937,7 @@ async fn main() -> Result<()> { recreate, } => { let name = name - .or_else(|| resolve_cluster_name(&cli.cluster)) + .or_else(|| resolve_gateway_name(&cli.gateway)) .unwrap_or_else(|| "nemoclaw".to_string()); run::cluster_admin_deploy( &name, @@ -958,7 +958,7 @@ async fn main() -> Result<()> { ssh_key, } => { let name = name - .or_else(|| resolve_cluster_name(&cli.cluster)) + .or_else(|| resolve_gateway_name(&cli.gateway)) .unwrap_or_else(|| "nemoclaw".to_string()); run::cluster_admin_stop(&name, remote.as_deref(), ssh_key.as_deref()).await?; } @@ -968,7 +968,7 @@ async fn main() -> Result<()> { ssh_key, } => { let name = name - .or_else(|| resolve_cluster_name(&cli.cluster)) + .or_else(|| resolve_gateway_name(&cli.gateway)) .unwrap_or_else(|| "nemoclaw".to_string()); run::cluster_admin_destroy(&name, remote.as_deref(), ssh_key.as_deref()).await?; } @@ -977,7 +977,7 @@ async fn main() -> Result<()> { run::cluster_use(&name)?; } else { // No name provided — show available gateways. - run::cluster_list(&cli.cluster)?; + run::cluster_list(&cli.gateway)?; eprintln!(); eprintln!( "Select a gateway with: {}", @@ -987,7 +987,7 @@ async fn main() -> Result<()> { } GatewayCommands::Info { name } => { let name = name - .or_else(|| resolve_cluster_name(&cli.cluster)) + .or_else(|| resolve_gateway_name(&cli.gateway)) .unwrap_or_else(|| "nemoclaw".to_string()); run::cluster_admin_info(&name)?; } @@ -998,7 +998,7 @@ async fn main() -> Result<()> { print_command, } => { let name = name - .or_else(|| resolve_cluster_name(&cli.cluster)) + .or_else(|| resolve_gateway_name(&cli.gateway)) .unwrap_or_else(|| "nemoclaw".to_string()); run::cluster_admin_tunnel( &name, @@ -1013,7 +1013,7 @@ async fn main() -> Result<()> { // Top-level status (was `cluster status`) // ----------------------------------------------------------- Some(Commands::Status) => { - if let Ok(ctx) = resolve_cluster(&cli.cluster) { + if let Ok(ctx) = resolve_gateway(&cli.gateway) { let tls = tls.with_cluster_name(&ctx.name); run::cluster_status(&ctx.name, &ctx.endpoint, &tls).await?; } else { @@ -1033,7 +1033,7 @@ async fn main() -> Result<()> { // ----------------------------------------------------------- Some(Commands::Forward { command: fwd_cmd }) => match fwd_cmd { ForwardCommands::Stop { port, name } => { - let cluster_name = resolve_cluster_name(&cli.cluster).unwrap_or_default(); + let cluster_name = resolve_gateway_name(&cli.gateway).unwrap_or_default(); let name = resolve_sandbox_name(name, &cluster_name)?; if run::stop_forward(&name, port)? { eprintln!( @@ -1087,7 +1087,7 @@ async fn main() -> Result<()> { name, background, } => { - let ctx = resolve_cluster(&cli.cluster)?; + let ctx = resolve_gateway(&cli.gateway)?; let tls = tls.with_cluster_name(&ctx.name); let name = resolve_sandbox_name(name, &ctx.name)?; run::sandbox_forward(&ctx.endpoint, &name, port, background, &tls).await?; @@ -1113,7 +1113,7 @@ async fn main() -> Result<()> { source, level, }) => { - let ctx = resolve_cluster(&cli.cluster)?; + let ctx = resolve_gateway(&cli.gateway)?; let tls = tls.with_cluster_name(&ctx.name); let name = resolve_sandbox_name(name, &ctx.name)?; run::sandbox_logs( @@ -1135,7 +1135,7 @@ async fn main() -> Result<()> { Some(Commands::Policy { command: policy_cmd, }) => { - let ctx = resolve_cluster(&cli.cluster)?; + let ctx = resolve_gateway(&cli.gateway)?; let tls = tls.with_cluster_name(&ctx.name); match policy_cmd { PolicyCommands::Set { @@ -1163,7 +1163,7 @@ async fn main() -> Result<()> { // Inference commands // ----------------------------------------------------------- Some(Commands::Inference { command }) => { - let ctx = resolve_cluster(&cli.cluster)?; + let ctx = resolve_gateway(&cli.gateway)?; let endpoint = &ctx.endpoint; let tls = tls.with_cluster_name(&ctx.name); match command { @@ -1244,7 +1244,7 @@ async fn main() -> Result<()> { // For `sandbox create`, a missing cluster is not fatal — the // bootstrap flow inside `sandbox_create` can deploy one. - match resolve_cluster(&cli.cluster) { + match resolve_gateway(&cli.gateway) { Ok(ctx) => { if remote.is_some() { eprintln!( @@ -1304,7 +1304,7 @@ async fn main() -> Result<()> { dest, no_git_ignore, } => { - let ctx = resolve_cluster(&cli.cluster)?; + let ctx = resolve_gateway(&cli.gateway)?; let tls = tls.with_cluster_name(&ctx.name); let sandbox_dest = dest.as_deref().unwrap_or("/sandbox"); let local = std::path::Path::new(&local_path); @@ -1337,7 +1337,7 @@ async fn main() -> Result<()> { sandbox_path, dest, } => { - let ctx = resolve_cluster(&cli.cluster)?; + let ctx = resolve_gateway(&cli.gateway)?; let tls = tls.with_cluster_name(&ctx.name); let local_dest = std::path::Path::new(dest.as_deref().unwrap_or(".")); eprintln!( @@ -1350,7 +1350,7 @@ async fn main() -> Result<()> { eprintln!("{} Download complete", "✓".green().bold()); } other => { - let ctx = resolve_cluster(&cli.cluster)?; + let ctx = resolve_gateway(&cli.gateway)?; let endpoint = &ctx.endpoint; let tls = tls.with_cluster_name(&ctx.name); match other { @@ -1388,7 +1388,7 @@ async fn main() -> Result<()> { } } Some(Commands::Provider { command }) => { - let ctx = resolve_cluster(&cli.cluster)?; + let ctx = resolve_gateway(&cli.gateway)?; let endpoint = &ctx.endpoint; let tls = tls.with_cluster_name(&ctx.name); @@ -1445,7 +1445,7 @@ async fn main() -> Result<()> { } } Some(Commands::Term) => { - let ctx = resolve_cluster(&cli.cluster)?; + let ctx = resolve_gateway(&cli.gateway)?; let tls = tls.with_cluster_name(&ctx.name); let channel = navigator_cli::tls::build_channel(&ctx.endpoint, &tls).await?; navigator_tui::run(channel, &ctx.name, &ctx.endpoint).await?; @@ -1466,19 +1466,19 @@ async fn main() -> Result<()> { sandbox_id, token, server, - cluster, + gateway_name, name, }) => { - match (gateway, sandbox_id, token, server, cluster, name) { + match (gateway, sandbox_id, token, server, gateway_name, name) { // Token mode (existing behavior): pre-created session credentials. - (Some(gw), Some(sid), Some(tok), _, cluster_opt, _) => { - let effective_tls = match cluster_opt { + (Some(gw), Some(sid), Some(tok), _, gw_name_opt, _) => { + let effective_tls = match gw_name_opt { Some(ref c) => tls.with_cluster_name(c), None => tls, }; run::sandbox_ssh_proxy(&gw, &sid, &tok, &effective_tls).await?; } - // Name mode with --cluster: resolve endpoint from metadata. + // Name mode with --gateway-name: resolve endpoint from metadata. (_, _, _, server_override, Some(c), Some(n)) => { let endpoint = if let Some(srv) = server_override { srv @@ -1495,13 +1495,13 @@ async fn main() -> Result<()> { let tls = tls.with_cluster_name(&c); run::sandbox_ssh_proxy_by_name(&endpoint, &n, &tls).await?; } - // Legacy name mode with --server only (no --cluster). + // Legacy name mode with --server only (no --gateway-name). (_, _, _, Some(srv), None, Some(n)) => { run::sandbox_ssh_proxy_by_name(&srv, &n, &tls).await?; } _ => { return Err(miette::miette!( - "provide either --gateway/--sandbox-id/--token or --cluster/--name (or --server/--name)" + "provide either --gateway/--sandbox-id/--token or --gateway-name/--name (or --server/--name)" )); } } @@ -1543,7 +1543,7 @@ async fn main() -> Result<()> { } }, ClusterCommands::Inference { command } => { - let ctx = resolve_cluster(&cli.cluster)?; + let ctx = resolve_gateway(&cli.gateway)?; let endpoint = &ctx.endpoint; let tls = tls.with_cluster_name(&ctx.name); match command { diff --git a/crates/navigator-cli/src/run.rs b/crates/navigator-cli/src/run.rs index b6d92621e4..f2925509a9 100644 --- a/crates/navigator-cli/src/run.rs +++ b/crates/navigator-cli/src/run.rs @@ -672,9 +672,9 @@ pub fn cluster_use(name: &str) -> Result<()> { } /// List all provisioned clusters. -pub fn cluster_list(cluster_flag: &Option) -> Result<()> { +pub fn cluster_list(gateway_flag: &Option) -> Result<()> { let clusters = list_clusters()?; - let active = cluster_flag.clone().or_else(load_active_cluster); + let active = gateway_flag.clone().or_else(load_active_cluster); if clusters.is_empty() { println!("No gateways found."); @@ -845,11 +845,7 @@ pub(crate) async fn deploy_cluster_with_panel( /// Print post-deploy summary showing the cluster name and gateway endpoint. pub(crate) fn print_deploy_summary(name: &str, handle: &navigator_bootstrap::ClusterHandle) { eprintln!(); - eprintln!( - "{} {} {name}", - "✓".green().bold(), - "Gateway ready:".green(), - ); + eprintln!("{} {} {name}", "✓".green().bold(), "Gateway ready:".green(),); eprintln!( " {} {}", "Gateway endpoint:".bold(), diff --git a/crates/navigator-cli/src/ssh.rs b/crates/navigator-cli/src/ssh.rs index b4fdcc96b2..e2823d4594 100644 --- a/crates/navigator-cli/src/ssh.rs +++ b/crates/navigator-cli/src/ssh.rs @@ -75,7 +75,7 @@ async fn ssh_session_config( .cluster_name() .ok_or_else(|| miette::miette!("cluster name is required to build SSH proxy command"))?; let proxy_command = format!( - "{exe_command} ssh-proxy --gateway {} --sandbox-id {} --token {} --cluster {}", + "{exe_command} ssh-proxy --gateway {} --sandbox-id {} --token {} --gateway-name {}", gateway_url, session.sandbox_id, session.token, @@ -565,14 +565,14 @@ pub async fn sandbox_ssh_proxy_by_name(server: &str, name: &str, tls: &TlsOption /// The output is suitable for appending to `~/.ssh/config` so that tools like /// `VSCode` Remote-SSH can connect to the sandbox by host alias. /// -/// The `ProxyCommand` uses `--cluster` so that `ssh-proxy` resolves the -/// gateway endpoint and TLS certificates from the cluster metadata directory +/// The `ProxyCommand` uses `--gateway-name` so that `ssh-proxy` resolves the +/// gateway endpoint and TLS certificates from the gateway metadata directory /// (`~/.config/nemoclaw/clusters//mtls/`). pub fn print_ssh_config(cluster: &str, name: &str) { let exe = std::env::current_exe().expect("failed to resolve NemoClaw executable"); let exe = shell_escape(&exe.to_string_lossy()); - let proxy_cmd = format!("{exe} ssh-proxy --cluster {cluster} --name {name}"); + let proxy_cmd = format!("{exe} ssh-proxy --gateway-name {cluster} --name {name}"); println!("Host nemoclaw-{name}"); println!(" User sandbox"); diff --git a/crates/navigator-tui/src/lib.rs b/crates/navigator-tui/src/lib.rs index d498989da8..588b39edad 100644 --- a/crates/navigator-tui/src/lib.rs +++ b/crates/navigator-tui/src/lib.rs @@ -1277,7 +1277,7 @@ async fn start_port_forwards( let exe_str = shell_escape(&exe.to_string_lossy()); let cluster = shell_escape(cluster_name); let proxy_command = format!( - "{exe_str} ssh-proxy --gateway {gateway_url} --sandbox-id {} --token {} --cluster {cluster}", + "{exe_str} ssh-proxy --gateway {gateway_url} --sandbox-id {} --token {} --gateway-name {cluster}", session.sandbox_id, session.token, ); diff --git a/examples/vscode-remote-sandbox.md b/examples/vscode-remote-sandbox.md index 198b733a9f..56763d810a 100644 --- a/examples/vscode-remote-sandbox.md +++ b/examples/vscode-remote-sandbox.md @@ -38,7 +38,7 @@ Host nemoclaw-my-sandbox UserKnownHostsFile /dev/null GlobalKnownHostsFile /dev/null LogLevel ERROR - ProxyCommand nemoclaw ssh-proxy --cluster --name my-sandbox + ProxyCommand nemoclaw ssh-proxy --gateway-name --name my-sandbox ``` ### 3. Open VSCode From f43e70df7488282954d0085a5826dc547f511ee0 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sun, 8 Mar 2026 14:32:53 -0700 Subject: [PATCH 07/12] refactor(cli): use --gateway for name and --gateway-endpoint for URL In the ssh-proxy subcommand, rename --gateway (URL) to --gateway-endpoint and --gateway-name (name) to --gateway. This makes --gateway consistently refer to the gateway name across all subcommands, while --gateway-endpoint is the explicit URL used in token mode. --- crates/navigator-cli/src/main.rs | 26 +++++++++++++------------- crates/navigator-cli/src/ssh.rs | 6 +++--- crates/navigator-tui/src/lib.rs | 2 +- examples/vscode-remote-sandbox.md | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/crates/navigator-cli/src/main.rs b/crates/navigator-cli/src/main.rs index 24e6a909c8..b057883b13 100644 --- a/crates/navigator-cli/src/main.rs +++ b/crates/navigator-cli/src/main.rs @@ -199,15 +199,15 @@ enum Commands { /// Two mutually exclusive modes: /// /// **Token mode** (used internally by `sandbox connect`): - /// `nemoclaw ssh-proxy --gateway --sandbox-id --token ` + /// `nemoclaw ssh-proxy --gateway-endpoint --sandbox-id --token ` /// /// **Name mode** (for use in `~/.ssh/config`): - /// `nemoclaw ssh-proxy --gateway-name --name ` + /// `nemoclaw ssh-proxy --gateway --name ` SshProxy { - /// Gateway URL (e.g., ). + /// Gateway endpoint URL (e.g., ). /// Required in token mode. #[arg(long)] - gateway: Option, + gateway_endpoint: Option, /// Sandbox id. Required in token mode. #[arg(long)] @@ -217,13 +217,13 @@ enum Commands { #[arg(long)] token: Option, - /// Gateway endpoint URL. Used in name mode. Deprecated: prefer --gateway-name. + /// Gateway endpoint URL. Used in name mode. Deprecated: prefer --gateway. #[arg(long)] server: Option, /// Gateway name (resolves endpoint from stored metadata). Used in name mode. - #[arg(long = "gateway-name", visible_alias = "cluster")] - gateway_name: Option, + #[arg(long, visible_alias = "cluster")] + gateway: Option, /// Sandbox name. Used in name mode. #[arg(long)] @@ -1462,14 +1462,14 @@ async fn main() -> Result<()> { .map_err(|e| miette::miette!("failed to write completions: {e}"))?; } Some(Commands::SshProxy { - gateway, + gateway_endpoint, sandbox_id, token, server, - gateway_name, + gateway, name, }) => { - match (gateway, sandbox_id, token, server, gateway_name, name) { + match (gateway_endpoint, sandbox_id, token, server, gateway, name) { // Token mode (existing behavior): pre-created session credentials. (Some(gw), Some(sid), Some(tok), _, gw_name_opt, _) => { let effective_tls = match gw_name_opt { @@ -1478,7 +1478,7 @@ async fn main() -> Result<()> { }; run::sandbox_ssh_proxy(&gw, &sid, &tok, &effective_tls).await?; } - // Name mode with --gateway-name: resolve endpoint from metadata. + // Name mode with --gateway: resolve endpoint from metadata. (_, _, _, server_override, Some(c), Some(n)) => { let endpoint = if let Some(srv) = server_override { srv @@ -1495,13 +1495,13 @@ async fn main() -> Result<()> { let tls = tls.with_cluster_name(&c); run::sandbox_ssh_proxy_by_name(&endpoint, &n, &tls).await?; } - // Legacy name mode with --server only (no --gateway-name). + // Legacy name mode with --server only (no --gateway). (_, _, _, Some(srv), None, Some(n)) => { run::sandbox_ssh_proxy_by_name(&srv, &n, &tls).await?; } _ => { return Err(miette::miette!( - "provide either --gateway/--sandbox-id/--token or --gateway-name/--name (or --server/--name)" + "provide either --gateway-endpoint/--sandbox-id/--token or --gateway/--name (or --server/--name)" )); } } diff --git a/crates/navigator-cli/src/ssh.rs b/crates/navigator-cli/src/ssh.rs index e2823d4594..32b9ca6948 100644 --- a/crates/navigator-cli/src/ssh.rs +++ b/crates/navigator-cli/src/ssh.rs @@ -75,7 +75,7 @@ async fn ssh_session_config( .cluster_name() .ok_or_else(|| miette::miette!("cluster name is required to build SSH proxy command"))?; let proxy_command = format!( - "{exe_command} ssh-proxy --gateway {} --sandbox-id {} --token {} --gateway-name {}", + "{exe_command} ssh-proxy --gateway-endpoint {} --sandbox-id {} --token {} --gateway {}", gateway_url, session.sandbox_id, session.token, @@ -565,14 +565,14 @@ pub async fn sandbox_ssh_proxy_by_name(server: &str, name: &str, tls: &TlsOption /// The output is suitable for appending to `~/.ssh/config` so that tools like /// `VSCode` Remote-SSH can connect to the sandbox by host alias. /// -/// The `ProxyCommand` uses `--gateway-name` so that `ssh-proxy` resolves the +/// The `ProxyCommand` uses `--gateway` so that `ssh-proxy` resolves the /// gateway endpoint and TLS certificates from the gateway metadata directory /// (`~/.config/nemoclaw/clusters//mtls/`). pub fn print_ssh_config(cluster: &str, name: &str) { let exe = std::env::current_exe().expect("failed to resolve NemoClaw executable"); let exe = shell_escape(&exe.to_string_lossy()); - let proxy_cmd = format!("{exe} ssh-proxy --gateway-name {cluster} --name {name}"); + let proxy_cmd = format!("{exe} ssh-proxy --gateway {cluster} --name {name}"); println!("Host nemoclaw-{name}"); println!(" User sandbox"); diff --git a/crates/navigator-tui/src/lib.rs b/crates/navigator-tui/src/lib.rs index 588b39edad..0bff862437 100644 --- a/crates/navigator-tui/src/lib.rs +++ b/crates/navigator-tui/src/lib.rs @@ -1277,7 +1277,7 @@ async fn start_port_forwards( let exe_str = shell_escape(&exe.to_string_lossy()); let cluster = shell_escape(cluster_name); let proxy_command = format!( - "{exe_str} ssh-proxy --gateway {gateway_url} --sandbox-id {} --token {} --gateway-name {cluster}", + "{exe_str} ssh-proxy --gateway-endpoint {gateway_url} --sandbox-id {} --token {} --gateway {cluster}", session.sandbox_id, session.token, ); diff --git a/examples/vscode-remote-sandbox.md b/examples/vscode-remote-sandbox.md index 56763d810a..3e1d7e268f 100644 --- a/examples/vscode-remote-sandbox.md +++ b/examples/vscode-remote-sandbox.md @@ -38,7 +38,7 @@ Host nemoclaw-my-sandbox UserKnownHostsFile /dev/null GlobalKnownHostsFile /dev/null LogLevel ERROR - ProxyCommand nemoclaw ssh-proxy --gateway-name --name my-sandbox + ProxyCommand nemoclaw ssh-proxy --gateway --name my-sandbox ``` ### 3. Open VSCode From 66326dafef80851e0e898a6602bc111d4335d5b3 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sun, 8 Mar 2026 14:37:21 -0700 Subject: [PATCH 08/12] refactor(cli): drop --cluster alias from ssh-proxy --gateway --- crates/navigator-cli/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/navigator-cli/src/main.rs b/crates/navigator-cli/src/main.rs index b057883b13..2a8f482c1b 100644 --- a/crates/navigator-cli/src/main.rs +++ b/crates/navigator-cli/src/main.rs @@ -222,7 +222,7 @@ enum Commands { server: Option, /// Gateway name (resolves endpoint from stored metadata). Used in name mode. - #[arg(long, visible_alias = "cluster")] + #[arg(long)] gateway: Option, /// Sandbox name. Used in name mode. From 372306ce0184a72f2bef7133d4c5b6f263a9936f Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sun, 8 Mar 2026 14:42:18 -0700 Subject: [PATCH 09/12] fix(bootstrap): always recreate Docker network during deploy Remove and recreate the Docker network on every deploy instead of reusing an existing one. Stale networks from interrupted destroys or Docker Desktop restarts can leave broken routing that causes k3s to fail with 'no default routes found'. The force-remove handles disconnecting any lingering containers before deletion. --- crates/navigator-bootstrap/src/docker.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/crates/navigator-bootstrap/src/docker.rs b/crates/navigator-bootstrap/src/docker.rs index 08470b6ebd..0c73c1ed27 100644 --- a/crates/navigator-bootstrap/src/docker.rs +++ b/crates/navigator-bootstrap/src/docker.rs @@ -123,14 +123,11 @@ pub async fn create_ssh_docker_client(remote: &RemoteOptions) -> Result } pub async fn ensure_network(docker: &Docker) -> Result<()> { - match docker - .inspect_network(NETWORK_NAME, None::) - .await - { - Ok(_) => return Ok(()), - Err(err) if is_not_found(&err) => {} - Err(err) => return Err(err).into_diagnostic(), - } + // Always remove and recreate the network to guarantee a clean state. + // Stale Docker networks (e.g., from a previous interrupted destroy or + // Docker Desktop restart) can leave broken routing that causes the + // container to fail with "no default routes found". + force_remove_network(docker).await?; docker .create_network(NetworkCreateRequest { From 380d581fa6699ddecaefa2b271a269e0fd9ebb5e Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sun, 8 Mar 2026 14:46:50 -0700 Subject: [PATCH 10/12] fix(bootstrap): retry image removal on conflict during destroy Docker may briefly report a container as still running after a force-remove, causing image deletion to fail with a 409 conflict. Retry up to 5 times with 500ms backoff to handle this race condition instead of silently leaving stale images behind. --- crates/navigator-bootstrap/src/docker.rs | 51 +++++++++++++++++------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/crates/navigator-bootstrap/src/docker.rs b/crates/navigator-bootstrap/src/docker.rs index 0c73c1ed27..ce94a7294b 100644 --- a/crates/navigator-bootstrap/src/docker.rs +++ b/crates/navigator-bootstrap/src/docker.rs @@ -524,22 +524,45 @@ pub async fn destroy_cluster_resources( // Remove the cluster image so the next deploy always pulls the latest // version from the registry instead of reusing a stale local copy. + // Docker may briefly report the container as still running after a + // force-remove, so retry a few times on conflict (409) errors. if let Some(ref image_id) = container_image { tracing::debug!("Removing cluster image: {}", image_id); - let result = docker - .remove_image( - image_id, - Some(RemoveImageOptions { - force: true, - noprune: true, - ..Default::default() - }), - None, - ) - .await; - if let Err(err) = result - && !is_not_found(&err) - { + let mut last_err = None; + for attempt in 0..5 { + if attempt > 0 { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } + match docker + .remove_image( + image_id, + Some(RemoveImageOptions { + force: true, + noprune: true, + ..Default::default() + }), + None, + ) + .await + { + Ok(_) => { + last_err = None; + break; + } + Err(err) if is_not_found(&err) => { + last_err = None; + break; + } + Err(err) if is_conflict(&err) => { + last_err = Some(err); + } + Err(err) => { + last_err = Some(err); + break; + } + } + } + if let Some(err) = last_err { tracing::warn!("Failed to remove cluster image {}: {}", image_id, err); } } From 1e9613b18aacf42a7900656e2b23f0a794e11dec Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sun, 8 Mar 2026 15:56:42 -0700 Subject: [PATCH 11/12] refactor(cli): simplify sandbox create output --- crates/navigator-cli/src/run.rs | 59 +++++---------------------------- 1 file changed, 8 insertions(+), 51 deletions(-) diff --git a/crates/navigator-cli/src/run.rs b/crates/navigator-cli/src/run.rs index f2925509a9..af0854f9c7 100644 --- a/crates/navigator-cli/src/run.rs +++ b/crates/navigator-cli/src/run.rs @@ -113,7 +113,6 @@ enum ProvisioningStep { Pulled, ContainerCreated, ContainerStarted, - GatewayReady, SandboxReady, } @@ -126,7 +125,6 @@ impl ProvisioningStep { Self::Pulled => "Image pulled", Self::ContainerCreated => "Container created", Self::ContainerStarted => "Container started", - Self::GatewayReady => "Gateway ready", Self::SandboxReady => "Sandbox ready", } } @@ -139,7 +137,6 @@ impl ProvisioningStep { Self::Pulled => "Pulling image...", Self::ContainerCreated => "Creating container...", Self::ContainerStarted => "Starting container...", - Self::GatewayReady => "Waiting for gateway...", Self::SandboxReady => "Waiting for sandbox...", } } @@ -172,8 +169,6 @@ struct ProvisioningDisplay { active_detail: String, /// When the current active step started (for elapsed time). step_start: Instant, - /// Overall provisioning start time. - provision_start: Instant, } impl ProvisioningDisplay { @@ -182,7 +177,7 @@ impl ProvisioningDisplay { let spinner = mp.add(ProgressBar::new_spinner()); spinner.set_style( - ProgressStyle::with_template("{spinner:.cyan} {msg}") + ProgressStyle::with_template("{spinner:.cyan} {msg}\n") .unwrap_or_else(|_| ProgressStyle::default_spinner()), ); spinner.enable_steady_tick(Duration::from_millis(120)); @@ -195,7 +190,6 @@ impl ProvisioningDisplay { active_label: "Provisioning...".to_string(), active_detail: String::new(), step_start: now, - provision_start: now, } } @@ -263,27 +257,6 @@ impl ProvisioningDisplay { self.spinner.set_message(msg); } - /// Finish the display, printing the final status line. - fn finish(&mut self, final_label: &str, success: bool) { - if success { - let elapsed = self.provision_start.elapsed(); - let elapsed_str = format_elapsed(elapsed); - let _ = self.mp.println(format!( - " {} {} {}", - "\u{2713}".green().bold(), - final_label.green().bold(), - elapsed_str.dimmed() - )); - } else { - let _ = self.mp.println(format!( - " {} {}", - "\u{2717}".red().bold(), - final_label.red().bold() - )); - } - self.spinner.finish_and_clear(); - } - /// Finish with an error message shown on the last step line. fn finish_error(&mut self, msg: &str) { let _ = self @@ -324,10 +297,11 @@ fn format_timestamp(d: Duration) -> String { fn print_sandbox_header(sandbox: &Sandbox, display: Option<&ProvisioningDisplay>) { let lines = [ String::new(), - format!("{}", "Created sandbox:".cyan().bold()), - String::new(), - format!(" {} {}", "Name:".dimmed(), sandbox.name), - format!(" {} {}", "Namespace:".dimmed(), sandbox.namespace), + format!( + "{} {}", + "Created sandbox:".cyan().bold(), + sandbox.name.bold() + ), String::new(), ]; match display { @@ -1376,18 +1350,8 @@ pub async fn sandbox_create( // Only accept Ready as terminal after we've observed a // non-Ready phase, proving the controller has reconciled. if saw_non_ready && phase == SandboxPhase::Ready { - // Complete the gateway step if we haven't already. - if !saw_gateway_ready { - if let Some(d) = display.as_mut() { - d.complete_step(ProvisioningStep::GatewayReady); - } - } - // Complete the final step. if let Some(d) = display.as_mut() { - d.finish("Sandbox ready", true); - } else { - let ts = format_timestamp(provision_start.elapsed()); - println!(" {} {}", ts.dimmed(), "Sandbox ready".green().bold()); + d.spinner.finish_and_clear(); } break; } @@ -1397,11 +1361,7 @@ pub async fn sandbox_create( if !saw_gateway_ready && line.message.contains("listening") { saw_gateway_ready = true; if let Some(d) = display.as_mut() { - d.complete_step(ProvisioningStep::GatewayReady); d.set_active_step(ProvisioningStep::SandboxReady); - } else { - let ts = format_timestamp(provision_start.elapsed()); - println!(" {} Gateway ready", ts.dimmed()); } } else if let Some(d) = display.as_mut() { // Show other log lines as detail on the spinner. @@ -1463,7 +1423,7 @@ pub async fn sandbox_create( ProvisioningStep::ContainerStarted => { if let Some(d) = display.as_mut() { d.complete_step(ProvisioningStep::ContainerStarted); - d.set_active_step(ProvisioningStep::GatewayReady); + d.set_active_step(ProvisioningStep::SandboxReady); } else { let ts = format_timestamp(provision_start.elapsed()); println!(" {} Container started", ts.dimmed()); @@ -1509,7 +1469,6 @@ pub async fn sandbox_create( drop(display); let _ = std::io::stdout().flush(); let _ = std::io::stderr().flush(); - println!(); match final_phase { SandboxPhase::Ready => { @@ -1564,11 +1523,9 @@ pub async fn sandbox_create( } if command.is_empty() { - eprintln!(" {} Connecting via SSH...", "\u{2022}".dimmed(),); return sandbox_connect(&effective_server, &sandbox_name, &effective_tls).await; } - eprintln!(" {} Connecting via SSH...", "\u{2022}".dimmed(),); // Resolve TTY mode: explicit --tty / --no-tty wins, otherwise // auto-detect from the local terminal. let tty = tty_override.unwrap_or_else(|| { From a16436b556790cc5ef528b7779660c0dcdfdd897 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Sun, 8 Mar 2026 16:03:33 -0700 Subject: [PATCH 12/12] fix(cli): stop showing server log lines on provisioning spinner --- crates/navigator-cli/src/run.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/navigator-cli/src/run.rs b/crates/navigator-cli/src/run.rs index af0854f9c7..72ef62778b 100644 --- a/crates/navigator-cli/src/run.rs +++ b/crates/navigator-cli/src/run.rs @@ -1363,9 +1363,6 @@ pub async fn sandbox_create( if let Some(d) = display.as_mut() { d.set_active_step(ProvisioningStep::SandboxReady); } - } else if let Some(d) = display.as_mut() { - // Show other log lines as detail on the spinner. - d.set_active_detail(&line.message); } } Some(navigator_core::proto::sandbox_stream_event::Payload::Event(ev)) => {