diff --git a/architecture/README.md b/architecture/README.md index d39b6e281..6b9977e6e 100644 --- a/architecture/README.md +++ b/architecture/README.md @@ -238,7 +238,7 @@ The CLI is the primary way users interact with the platform. It provides command - **Provider management** (`openshell provider`): Create, update, list, and delete external service credentials. - **Inference management** (`openshell cluster inference`): Configure cluster-level inference by specifying a provider and model. The gateway resolves endpoint and credential details from the named provider record. -The CLI resolves which gateway to operate on through a priority chain: explicit `--gateway` flag, then the `OPENSHELL_GATEWAY` environment variable, then the active gateway set by `openshell gateway select`. It supports TLS client certificates for mutual authentication with the gateway. +The CLI resolves which gateway to operate on through a priority chain: explicit `--gateway` flag, then the `OPENSHELL_GATEWAY` environment variable, then the active gateway set by `openshell gateway select`. Gateway names are exposed to shell completion from local metadata, and `openshell gateway select` opens an interactive chooser on a TTY while falling back to a printed list in non-interactive use. The CLI supports TLS client certificates for mutual authentication with the gateway. ## How Users Get Started diff --git a/architecture/gateway-single-node.md b/architecture/gateway-single-node.md index abb91e603..cf999e677 100644 --- a/architecture/gateway-single-node.md +++ b/architecture/gateway-single-node.md @@ -50,7 +50,7 @@ All gateway lifecycle commands live under `openshell gateway`: | `openshell gateway tunnel [--name NAME] [--remote user@host] [--print-command]` | Start or print SSH tunnel for kubectl access | | `openshell status` | Show gateway health via gRPC/HTTP | | `openshell gateway select ` | Set the active gateway | -| `openshell gateway select` | List all gateways with metadata | +| `openshell gateway select` | Open an interactive chooser on a TTY, or list all gateways in non-interactive mode | The `--name` flag defaults to `"openshell"`. When omitted on commands that accept it, the CLI resolves the active gateway via: `--gateway` flag, then `OPENSHELL_GATEWAY` env, then `~/.config/openshell/active_gateway` file. diff --git a/crates/navigator-cli/src/completers.rs b/crates/navigator-cli/src/completers.rs index 6fa4e1d20..5aa76d048 100644 --- a/crates/navigator-cli/src/completers.rs +++ b/crates/navigator-cli/src/completers.rs @@ -121,6 +121,7 @@ where mod tests { use super::*; use crate::TEST_ENV_LOCK; + use navigator_bootstrap::{GatewayMetadata, store_gateway_metadata}; use temp_env::with_vars; fn with_isolated_cli_env(tmp: &std::path::Path, f: F) { @@ -163,4 +164,34 @@ mod tests { assert!(result.is_empty()); }); } + + #[test] + fn gateway_completer_returns_registered_gateways() { + let temp = tempfile::tempdir().unwrap(); + with_isolated_cli_env(temp.path(), || { + store_gateway_metadata( + "alpha", + &GatewayMetadata { + name: "alpha".to_string(), + gateway_endpoint: "https://alpha.example.com".to_string(), + is_remote: true, + gateway_port: 0, + kube_port: None, + remote_host: None, + resolved_host: None, + auth_mode: Some("cloudflare_jwt".to_string()), + edge_team_domain: None, + edge_auth_url: None, + }, + ) + .unwrap(); + + let result = complete_gateway_names(OsStr::new("a")); + let names: Vec = result + .iter() + .map(|candidate| candidate.get_value().to_string_lossy().into_owned()) + .collect(); + assert!(names.contains(&"alpha".to_string())); + }); + } } diff --git a/crates/navigator-cli/src/main.rs b/crates/navigator-cli/src/main.rs index 7a22ddf21..f8a2f350f 100644 --- a/crates/navigator-cli/src/main.rs +++ b/crates/navigator-cli/src/main.rs @@ -151,7 +151,9 @@ fn resolve_sandbox_name(name: Option, gateway: &str) -> Result { Ok(last) } -// Custom help template organized like `gh` CLI +// Custom root help stays hand-authored so commands can be grouped into product +// areas without relying on clap's default subcommand listing. User-facing +// commands remain visible so shell completion can suggest them at the root. const HELP_TEMPLATE: &str = "\ {about-with-newline} \x1b[1mUSAGE\x1b[0m @@ -289,7 +291,8 @@ struct Cli { short = 'g', global = true, env = "OPENSHELL_GATEWAY", - help_heading = "GATEWAY FLAGS" + help_heading = "GATEWAY FLAGS", + add = ArgValueCompleter::new(completers::complete_gateway_names) )] gateway: Option, @@ -325,23 +328,24 @@ enum Commands { // SANDBOX COMMANDS // =================================================================== /// Manage sandboxes. - #[command(visible_alias = "sb", hide = true, after_help = SANDBOX_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] + #[command(alias = "sb", after_help = SANDBOX_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] Sandbox { #[command(subcommand)] command: Option, }, /// Manage port forwarding to a sandbox. - #[command(visible_alias = "fwd", hide = true, after_help = FORWARD_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] + #[command(alias = "fwd", after_help = FORWARD_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] Forward { #[command(subcommand)] command: Option, }, /// View sandbox logs. - #[command(visible_alias = "lg", hide = true, after_help = LOGS_EXAMPLES, help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + #[command(alias = "lg", after_help = LOGS_EXAMPLES, help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Logs { /// Sandbox name (defaults to last-used sandbox). + #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] name: Option, /// Number of log lines to return. @@ -367,14 +371,14 @@ enum Commands { }, /// Manage sandbox policy. - #[command(visible_alias = "pol", hide = true, after_help = POLICY_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] + #[command(alias = "pol", after_help = POLICY_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] Policy { #[command(subcommand)] command: Option, }, /// Manage provider configuration. - #[command(hide = true, after_help = PROVIDER_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] + #[command(after_help = PROVIDER_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] Provider { #[command(subcommand)] command: Option, @@ -384,18 +388,18 @@ enum Commands { // GATEWAY COMMANDS // =================================================================== /// Manage the gateway lifecycle. - #[command(visible_alias = "gw", hide = true, after_help = GATEWAY_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] + #[command(alias = "gw", after_help = GATEWAY_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] Gateway { #[command(subcommand)] command: Option, }, /// Show gateway status and information. - #[command(hide = true, help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Status, /// Manage inference configuration. - #[command(hide = true, after_help = INFERENCE_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] + #[command(after_help = INFERENCE_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)] Inference { #[command(subcommand)] command: Option, @@ -405,11 +409,11 @@ enum Commands { // ADDITIONAL COMMANDS // =================================================================== /// Launch the `OpenShell` interactive TUI. - #[command(hide = true, help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Term, /// Generate shell completions. - #[command(hide = true, after_long_help = COMPLETIONS_HELP, help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] + #[command(after_long_help = COMPLETIONS_HELP, help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Completions { /// Shell to generate completions for. shell: CompletionShell, @@ -482,7 +486,7 @@ The script is output on stdout, allowing you to redirect the output to the file The exact config file locations might vary based on your system. Make sure to restart your shell before testing whether completions are working. -## bash +\x1b[1mBASH\x1b[0m First, ensure that you install `bash-completion` using your package manager. @@ -494,12 +498,12 @@ On macOS with Homebrew (install bash-completion first): mkdir -p $(brew --prefix)/etc/bash_completion.d openshell completions bash > $(brew --prefix)/etc/bash_completion.d/openshell.bash-completion -## fish +\x1b[1mFISH\x1b[0m mkdir -p ~/.config/fish/completions openshell completions fish > ~/.config/fish/completions/openshell.fish -## zsh +\x1b[1mZSH\x1b[0m mkdir -p ~/.zfunc openshell completions zsh > ~/.zfunc/_openshell @@ -508,7 +512,7 @@ Then add the following to your .zshrc before compinit: fpath+=~/.zfunc -## powershell +\x1b[1mPOWERSHELL\x1b[0m openshell completions powershell >> $PROFILE @@ -517,6 +521,12 @@ If no profile exists yet, create one first: New-Item -Path $PROFILE -Type File -Force "; +fn normalize_completion_script(output: Vec, executable: &std::path::Path) -> Result { + let script = String::from_utf8(output) + .map_err(|e| miette::miette!("generated completions were not valid UTF-8: {e}"))?; + Ok(script.replace(executable.to_string_lossy().as_ref(), "openshell")) +} + #[derive(Clone, Debug, ValueEnum)] enum CliProviderType { Claude, @@ -730,7 +740,7 @@ enum GatewayCommands { #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Stop { /// Gateway name (defaults to active gateway). - #[arg(long, env = "OPENSHELL_GATEWAY")] + #[arg(long, env = "OPENSHELL_GATEWAY", add = ArgValueCompleter::new(completers::complete_gateway_names))] name: Option, /// Override SSH destination (auto-resolved from gateway metadata). @@ -746,7 +756,7 @@ enum GatewayCommands { #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Destroy { /// Gateway name (defaults to active gateway). - #[arg(long, env = "OPENSHELL_GATEWAY")] + #[arg(long, env = "OPENSHELL_GATEWAY", add = ArgValueCompleter::new(completers::complete_gateway_names))] name: Option, /// Override SSH destination (auto-resolved from gateway metadata). @@ -792,10 +802,11 @@ enum GatewayCommands { /// Select the active gateway. /// - /// When called without a name, lists available gateways to choose from. + /// When called without a name, opens an interactive chooser on a TTY and + /// lists available gateways in non-interactive mode. #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Select { - /// Gateway name (omit to list available gateways). + /// Gateway name (omit to choose interactively or list in non-interactive mode). #[arg(add = ArgValueCompleter::new(completers::complete_gateway_names))] name: Option, }, @@ -804,7 +815,7 @@ enum GatewayCommands { #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Info { /// Gateway name (defaults to active gateway). - #[arg(long, env = "OPENSHELL_GATEWAY")] + #[arg(long, env = "OPENSHELL_GATEWAY", add = ArgValueCompleter::new(completers::complete_gateway_names))] name: Option, }, @@ -812,7 +823,7 @@ enum GatewayCommands { #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Tunnel { /// Gateway name (defaults to active gateway). - #[arg(long, env = "OPENSHELL_GATEWAY")] + #[arg(long, env = "OPENSHELL_GATEWAY", add = ArgValueCompleter::new(completers::complete_gateway_names))] name: Option, /// Override SSH destination (auto-resolved from gateway metadata). @@ -898,7 +909,7 @@ enum SandboxCommands { /// /// When given a Dockerfile or directory, the image is built and pushed /// into the cluster automatically before creating the sandbox. - #[arg(long)] + #[arg(long, value_hint = ValueHint::AnyPath)] from: Option, /// Upload local files into the sandbox before running. @@ -1065,6 +1076,7 @@ enum SandboxCommands { sandbox_path: String, /// Local destination (defaults to `.`). + #[arg(value_hint = ValueHint::AnyPath)] dest: Option, }, @@ -1075,6 +1087,7 @@ enum SandboxCommands { #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] SshConfig { /// Sandbox name (defaults to last-used sandbox). + #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] name: Option, }, } @@ -1085,6 +1098,7 @@ enum PolicyCommands { #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Set { /// Sandbox name (defaults to last-used sandbox). + #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] name: Option, /// Path to the policy YAML file. @@ -1104,6 +1118,7 @@ enum PolicyCommands { #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] Get { /// Sandbox name (defaults to last-used sandbox). + #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] name: Option, /// Show a specific policy revision (default: latest). @@ -1119,6 +1134,7 @@ enum PolicyCommands { #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] List { /// Sandbox name (defaults to last-used sandbox). + #[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))] name: Option, /// Maximum number of revisions to return. @@ -1267,17 +1283,7 @@ async fn main() -> Result<()> { run::gateway_login(&name).await?; } GatewayCommands::Select { name } => { - if let Some(name) = name { - run::gateway_use(&name)?; - } else { - // No name provided — show available gateways. - run::gateway_list(&cli.gateway)?; - eprintln!(); - eprintln!( - "Select a gateway with: {}", - "openshell gateway select ".dimmed() - ); - } + run::gateway_select(name.as_deref(), &cli.gateway)?; } GatewayCommands::Info { name } => { let name = name @@ -1777,12 +1783,13 @@ async fn main() -> Result<()> { Some(Commands::Completions { shell }) => { let exe = std::env::current_exe() .map_err(|e| miette::miette!("failed to find current executable: {e}"))?; - let output = std::process::Command::new(exe) + let output = std::process::Command::new(&exe) .env("COMPLETE", shell.to_string()) .output() .map_err(|e| miette::miette!("failed to generate completions: {e}"))?; + let script = normalize_completion_script(output.stdout, &exe)?; std::io::stdout() - .write_all(&output.stdout) + .write_all(script.as_bytes()) .map_err(|e| miette::miette!("failed to write completions: {e}"))?; } Some(Commands::SshProxy { @@ -1963,9 +1970,26 @@ mod tests { let args: Vec = vec!["openshell".into(), "".into()]; let candidates = clap_complete::engine::complete(&mut cmd, args, 1, None) .expect("completion engine failed"); + let names: Vec = candidates + .iter() + .map(|c| c.get_value().to_string_lossy().into_owned()) + .collect(); + assert!( - !candidates.is_empty(), - "expected subcommand completions for empty input" + names.contains(&"sandbox".to_string()), + "expected 'sandbox' in root candidates, got: {names:?}" + ); + assert!( + names.contains(&"--gateway".to_string()), + "expected '--gateway' in root candidates, got: {names:?}" + ); + assert!( + !names.contains(&"lg".to_string()), + "expected root candidates to prefer canonical command names, got: {names:?}" + ); + assert!( + !names.contains(&"pol".to_string()), + "expected root candidates to prefer canonical command names, got: {names:?}" ); } @@ -2036,6 +2060,23 @@ mod tests { 4, "Dockerfile", ), + ( + vec!["openshell", "sandbox", "create", "--from", "Do"], + 4, + "Dockerfile", + ), + ( + vec![ + "openshell", + "sandbox", + "download", + "demo", + "/sandbox/file", + "Do", + ], + 5, + "Dockerfile", + ), ]; for (raw_args, index, expected) in cases { @@ -2101,6 +2142,100 @@ mod tests { ); } + #[test] + fn gateway_completion_suggests_registered_gateways() { + let tmp = tempfile::tempdir().expect("failed to create tempdir"); + with_tmp_xdg(tmp.path(), || { + store_gateway_metadata( + "alpha", + &edge_metadata("alpha", "https://alpha.example.com"), + ) + .expect("store gateway alpha"); + store_gateway_metadata("beta", &edge_metadata("beta", "https://beta.example.com")) + .expect("store gateway beta"); + + for (raw_args, index) in [ + (vec!["openshell", "--gateway", "a"], 2), + (vec!["openshell", "gateway", "select", "a"], 3), + (vec!["openshell", "gateway", "info", "--name", "a"], 4), + ] { + let mut cmd = Cli::command(); + let args: Vec = raw_args.iter().copied().map(Into::into).collect(); + let candidates = clap_complete::engine::complete(&mut cmd, args, index, None) + .expect("completion engine failed"); + let names: Vec = candidates + .iter() + .map(|c| c.get_value().to_string_lossy().into_owned()) + .collect(); + + assert!( + names.contains(&"alpha".to_string()), + "expected gateway completion for args {raw_args:?}, got: {names:?}" + ); + } + }); + } + + #[test] + fn global_gateway_flag_still_parses_with_subcommands() { + let cli = Cli::try_parse_from(["openshell", "--gateway", "demo", "status"]) + .expect("global gateway flag should parse with subcommands"); + + assert_eq!(cli.gateway.as_deref(), Some("demo")); + assert!(matches!(cli.command, Some(Commands::Status))); + } + + #[test] + fn hidden_aliases_still_parse() { + let cli = Cli::try_parse_from(["openshell", "lg", "sandbox-1"]) + .expect("hidden aliases should still parse"); + + assert!(matches!( + cli.command, + Some(Commands::Logs { name: Some(ref name), .. }) if name == "sandbox-1" + )); + } + + #[test] + fn completion_script_uses_openshell_command_name() { + let script = normalize_completion_script( + b"/tmp/custom/openshell -- \"${words[@]}\"\n#compdef openshell\n".to_vec(), + std::path::Path::new("/tmp/custom/openshell"), + ) + .expect("normalize completion script"); + + assert!(script.contains("openshell -- \"${words[@]}\"")); + assert!(!script.contains("/tmp/custom/openshell")); + } + + #[test] + fn sandbox_create_and_download_use_path_value_hints() { + let cmd = Cli::command(); + let sandbox = cmd + .get_subcommands() + .find(|c| c.get_name() == "sandbox") + .expect("missing sandbox subcommand"); + let create = sandbox + .get_subcommands() + .find(|c| c.get_name() == "create") + .expect("missing create subcommand"); + let from = create + .get_arguments() + .find(|arg| arg.get_id() == "from") + .expect("missing from argument"); + let download = sandbox + .get_subcommands() + .find(|c| c.get_name() == "download") + .expect("missing download subcommand"); + let dest = download + .get_arguments() + .find(|arg| arg.get_id() == "dest") + .expect("missing dest argument"); + + assert_eq!(from.get_value_hint(), ValueHint::AnyPath); + assert_eq!(dest.get_value_hint(), ValueHint::AnyPath); + } + #[test] fn parse_upload_spec_without_remote() { let (local, remote) = parse_upload_spec("./src"); diff --git a/crates/navigator-cli/src/run.rs b/crates/navigator-cli/src/run.rs index 107518704..74dc926cc 100644 --- a/crates/navigator-cli/src/run.rs +++ b/crates/navigator-cli/src/run.rs @@ -7,7 +7,7 @@ use crate::tls::{ TlsOptions, build_rustls_config, grpc_client, grpc_inference_client, require_tls_materials, }; use bytes::Bytes; -use dialoguer::Confirm; +use dialoguer::{Confirm, Select, theme::ColorfulTheme}; use futures::StreamExt; use http_body_util::Full; use hyper::{Request, StatusCode}; @@ -722,6 +722,117 @@ pub fn gateway_use(name: &str) -> Result<()> { Ok(()) } +pub fn gateway_select(name: Option<&str>, gateway_flag: &Option) -> Result<()> { + let interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal(); + gateway_select_with(name, gateway_flag, interactive, |gateways, default| { + let prompt = format!( + "Select a gateway\n{}", + format_gateway_select_header(gateways) + ); + let items = format_gateway_select_items(gateways); + Select::with_theme(&ColorfulTheme::default()) + .with_prompt(prompt) + .items(&items) + .default(default) + .report(false) + .interact_opt() + .into_diagnostic() + .map(|selection| selection.map(|index| gateways[index].name.clone())) + }) +} + +fn format_gateway_select_header(gateways: &[GatewayMetadata]) -> String { + let (name_width, endpoint_width) = gateway_select_column_widths(gateways); + format!( + " {: Vec { + let (name_width, endpoint_width) = gateway_select_column_widths(gateways); + + gateways + .iter() + .map(|gateway| { + format!( + "{: (usize, usize) { + let name_width = gateways + .iter() + .map(|gateway| gateway.name.len()) + .max() + .unwrap_or(4) + .max(4); + let endpoint_width = gateways + .iter() + .map(|gateway| gateway.gateway_endpoint.len()) + .max() + .unwrap_or(8) + .max(8); + + (name_width, endpoint_width) +} + +fn gateway_type_label(gateway: &GatewayMetadata) -> &'static str { + match gateway.auth_mode.as_deref() { + Some("cloudflare_jwt") => "edge", + _ if gateway.is_remote => "remote", + _ => "local", + } +} + +fn gateway_select_with( + name: Option<&str>, + gateway_flag: &Option, + interactive: bool, + choose_gateway: F, +) -> Result<()> +where + F: FnOnce(&[GatewayMetadata], usize) -> Result>, +{ + if let Some(name) = name { + return gateway_use(name); + } + + let gateways = list_gateways()?; + if gateways.is_empty() || !interactive { + gateway_list(gateway_flag)?; + if !gateways.is_empty() { + eprintln!(); + eprintln!( + "Select a gateway with: {}", + "openshell gateway select ".dimmed() + ); + } + return Ok(()); + } + + let active = gateway_flag.clone().or_else(load_active_gateway); + let default = active + .as_deref() + .and_then(|name| gateways.iter().position(|gateway| gateway.name == name)) + .unwrap_or(0); + + if let Some(name) = choose_gateway(&gateways, default)? { + gateway_use(&name)?; + } else { + eprintln!("{} Gateway selection cancelled", "!".yellow()); + } + + Ok(()) +} + /// Register an external edge-authenticated gateway. /// /// Creates local metadata for the given endpoint so it appears in @@ -870,11 +981,7 @@ pub fn gateway_list(gateway_flag: &Option) -> Result<()> { for gateway in &gateways { let is_active = active.as_deref() == Some(&gateway.name); let marker = if is_active { "*" } else { " " }; - let gateway_type = match gateway.auth_mode.as_deref() { - Some("cloudflare_jwt") => "edge", - _ if gateway.is_remote => "remote", - _ => "local", - }; + let gateway_type = gateway_type_label(gateway); let line = format!( "{marker} {:(tmp: &Path, f: F) { + let _guard = TEST_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let guard = EnvVarGuard::set( + "XDG_CONFIG_HOME", + tmp.to_str().expect("temp path should be utf-8"), + ); + f(); + drop(guard); + } + fn edge_registration(name: &str, endpoint: &str) -> GatewayMetadata { GatewayMetadata { name: name.to_string(), @@ -3766,6 +3889,111 @@ mod tests { } } + #[test] + fn gateway_select_uses_explicit_name_without_prompting() { + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + with_tmp_xdg(tmpdir.path(), || { + store_gateway_metadata( + "alpha", + &edge_registration("alpha", "https://alpha.example.com"), + ) + .expect("store gateway"); + + let mut prompted = false; + gateway_select_with(Some("alpha"), &None, true, |_, _| { + prompted = true; + Ok(None) + }) + .expect("select explicit gateway"); + + assert_eq!(load_active_gateway().as_deref(), Some("alpha")); + assert!(!prompted, "explicit gateway should skip prompting"); + }); + } + + #[test] + fn gateway_select_prefers_active_gateway_as_default_choice() { + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + with_tmp_xdg(tmpdir.path(), || { + store_gateway_metadata( + "alpha", + &edge_registration("alpha", "https://alpha.example.com"), + ) + .expect("store alpha"); + store_gateway_metadata( + "beta", + &edge_registration("beta", "https://beta.example.com"), + ) + .expect("store beta"); + super::save_active_gateway("beta").expect("save active gateway"); + + let mut seen_default = None; + gateway_select_with(None, &None, true, |gateways, default| { + seen_default = Some(default); + Ok(Some(gateways[default].name.clone())) + }) + .expect("interactive selection"); + + assert_eq!(seen_default, Some(1)); + assert_eq!(load_active_gateway().as_deref(), Some("beta")); + }); + } + + #[test] + fn gateway_select_non_interactive_lists_gateways_without_prompting() { + let tmpdir = tempfile::tempdir().expect("create tmpdir"); + with_tmp_xdg(tmpdir.path(), || { + store_gateway_metadata( + "alpha", + &edge_registration("alpha", "https://alpha.example.com"), + ) + .expect("store gateway"); + + let mut prompted = false; + gateway_select_with(None, &None, false, |_, _| { + prompted = true; + Ok(None) + }) + .expect("non-interactive selection"); + + assert!(!prompted, "non-interactive mode should not prompt"); + assert_eq!(load_active_gateway(), None); + }); + } + + #[test] + fn gateway_select_items_include_endpoint_and_type() { + let gateways = vec![ + edge_registration("alpha", "https://edge.example.com"), + GatewayMetadata { + name: "local".to_string(), + gateway_endpoint: "http://127.0.0.1:8080".to_string(), + is_remote: false, + gateway_port: 8080, + kube_port: None, + remote_host: None, + resolved_host: None, + auth_mode: None, + edge_team_domain: None, + edge_auth_url: None, + }, + ]; + + let items = format_gateway_select_items(&gateways); + let header = format_gateway_select_header(&gateways); + + assert_eq!(gateway_type_label(&gateways[0]), "edge"); + assert_eq!(gateway_type_label(&gateways[1]), "local"); + assert!(header.contains("NAME")); + assert!(header.contains("ENDPOINT")); + assert!(header.contains("TYPE")); + assert!(items[0].contains("alpha")); + assert!(items[0].contains("https://edge.example.com")); + assert!(items[0].contains("edge")); + assert!(items[1].contains("local")); + assert!(items[1].contains("http://127.0.0.1:8080")); + } + #[tokio::test] async fn http_health_check_supports_plain_http_endpoints() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind listener");