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-bootstrap/src/docker.rs b/crates/navigator-bootstrap/src/docker.rs index 08470b6ebd..ce94a7294b 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 { @@ -527,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); } } 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/main.rs b/crates/navigator-cli/src/main.rs index 73681a745a..2a8f482c1b 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, @@ -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 --cluster --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, - /// Cluster endpoint URL. Used in name mode. Deprecated: prefer --cluster. + /// Gateway endpoint URL. Used in name mode. Deprecated: prefer --gateway. #[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: Option, /// Sandbox name. Used in name mode. #[arg(long)] @@ -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_gateway_name(&cli.gateway)) + .unwrap_or_else(|| "nemoclaw".to_string()); run::cluster_admin_deploy( &name, update_kube_config, @@ -955,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?; } @@ -965,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?; } @@ -974,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: {}", @@ -984,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)?; } @@ -995,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, @@ -1010,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 { @@ -1030,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!( @@ -1084,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?; @@ -1110,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( @@ -1132,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 { @@ -1160,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 { @@ -1241,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!( @@ -1301,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); @@ -1334,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!( @@ -1347,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 { @@ -1385,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); @@ -1442,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?; @@ -1459,23 +1462,23 @@ async fn main() -> Result<()> { .map_err(|e| miette::miette!("failed to write completions: {e}"))?; } Some(Commands::SshProxy { - gateway, + gateway_endpoint, sandbox_id, token, server, - cluster, + gateway, name, }) => { - match (gateway, sandbox_id, token, server, cluster, name) { + match (gateway_endpoint, sandbox_id, token, server, gateway, 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: resolve endpoint from metadata. (_, _, _, server_override, Some(c), Some(n)) => { let endpoint = if let Some(srv) = server_override { srv @@ -1492,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). (_, _, _, 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-endpoint/--sandbox-id/--token or --gateway/--name (or --server/--name)" )); } } @@ -1540,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 6993299997..72ef62778b 100644 --- a/crates/navigator-cli/src/run.rs +++ b/crates/navigator-cli/src/run.rs @@ -105,86 +105,204 @@ 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, + 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::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::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, } -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}") + ProgressStyle::with_template("{spinner:.cyan} {msg}\n") .unwrap_or_else(|_| ProgressStyle::default_spinner()), ); 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, } } - 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 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 + } +} + +/// 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<&LogDisplay>) { +fn print_sandbox_header(sandbox: &Sandbox, display: Option<&ProvisioningDisplay>) { let lines = [ String::new(), - format!("{}", "Created sandbox:".cyan().bold()), + format!( + "{} {}", + "Created sandbox:".cyan().bold(), + sandbox.name.bold() + ), String::new(), - format!(" {} {}", "Name:".dimmed(), sandbox.name), - format!(" {} {}", "Namespace:".dimmed(), sandbox.namespace), ]; match display { Some(d) => { @@ -200,16 +318,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. @@ -263,9 +371,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, @@ -276,7 +383,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()); @@ -288,9 +395,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(), @@ -314,6 +420,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 { @@ -325,12 +436,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; } @@ -339,6 +445,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(); } @@ -395,19 +507,23 @@ 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) { 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(); } @@ -415,12 +531,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(); } @@ -433,6 +544,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) { @@ -521,9 +646,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."); @@ -611,13 +736,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(); @@ -669,36 +794,35 @@ 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) } } /// Print post-deploy summary showing the cluster name and gateway endpoint. pub(crate) fn print_deploy_summary(name: &str, handle: &navigator_bootstrap::ClusterHandle) { - eprintln!( - "{} {} {name}", - "✓".green().bold(), - "Cluster ready:".green().bold(), - ); + eprintln!(); + eprintln!("{} {} {name}", "✓".green().bold(), "Gateway ready:".green(),); eprintln!( " {} {}", - "Gateway endpoint:".dimmed(), + "Gateway endpoint:".bold(), handle.gateway_endpoint() ); eprintln!(); @@ -762,11 +886,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 @@ -793,7 +917,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(()) } @@ -1128,9 +1252,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 +1263,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 +1302,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 +1346,135 @@ 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 { + if let Some(d) = display.as_mut() { + d.spinner.finish_and_clear(); + } 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.set_active_step(ProvisioningStep::SandboxReady); + } } } 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::SandboxReady); + } 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 +1496,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 +1512,17 @@ 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..."); return sandbox_connect(&effective_server, &sandbox_name, &effective_tls).await; } - eprintln!("Connecting..."); // Resolve TTY mode: explicit --tty / --no-tty wins, otherwise // auto-detect from the local terminal. let tty = tty_override.unwrap_or_else(|| { @@ -1355,7 +1559,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" diff --git a/crates/navigator-cli/src/ssh.rs b/crates/navigator-cli/src/ssh.rs index b4fdcc96b2..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 {} --cluster {}", + "{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 `--cluster` so that `ssh-proxy` resolves the -/// gateway endpoint and TLS certificates from the cluster metadata directory +/// 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 --cluster {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 d498989da8..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 {} --cluster {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 198b733a9f..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 --cluster --name my-sandbox + ProxyCommand nemoclaw ssh-proxy --gateway --name my-sandbox ``` ### 3. Open VSCode