mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-30 07:33:59 +08:00
fix(cli): improve completion coverage and gateway selection (#241)
* fix(cli): improve completion coverage and gateway selection Closes #232 Add typed completion metadata for gateway, sandbox, and path arguments, make gateway selection interactive on TTYs, and expose root subcommands in completions while keeping non-interactive behavior stable.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 <name>` | 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.
|
||||
|
||||
|
||||
@@ -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<F: FnOnce()>(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<String> = result
|
||||
.iter()
|
||||
.map(|candidate| candidate.get_value().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert!(names.contains(&"alpha".to_string()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +151,9 @@ fn resolve_sandbox_name(name: Option<String>, gateway: &str) -> Result<String> {
|
||||
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<String>,
|
||||
|
||||
@@ -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<SandboxCommands>,
|
||||
},
|
||||
|
||||
/// 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<ForwardCommands>,
|
||||
},
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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<PolicyCommands>,
|
||||
},
|
||||
|
||||
/// 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<ProviderCommands>,
|
||||
@@ -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<GatewayCommands>,
|
||||
},
|
||||
|
||||
/// 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<InferenceCommands>,
|
||||
@@ -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<u8>, executable: &std::path::Path) -> Result<String> {
|
||||
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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
},
|
||||
@@ -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<String>,
|
||||
},
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
},
|
||||
|
||||
@@ -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<String>,
|
||||
},
|
||||
}
|
||||
@@ -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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// 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 <name>".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<OsString> = vec!["openshell".into(), "".into()];
|
||||
let candidates = clap_complete::engine::complete(&mut cmd, args, 1, None)
|
||||
.expect("completion engine failed");
|
||||
let names: Vec<String> = 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<OsString> = 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<String> = 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");
|
||||
|
||||
@@ -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<String>) -> 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!(
|
||||
" {:<name_width$} {:<endpoint_width$} {}",
|
||||
"NAME".bold(),
|
||||
"ENDPOINT".bold(),
|
||||
"TYPE".bold(),
|
||||
)
|
||||
}
|
||||
|
||||
fn format_gateway_select_items(gateways: &[GatewayMetadata]) -> Vec<String> {
|
||||
let (name_width, endpoint_width) = gateway_select_column_widths(gateways);
|
||||
|
||||
gateways
|
||||
.iter()
|
||||
.map(|gateway| {
|
||||
format!(
|
||||
"{:<name_width$} {:<endpoint_width$} {}",
|
||||
gateway.name,
|
||||
gateway.gateway_endpoint,
|
||||
gateway_type_label(gateway),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn gateway_select_column_widths(gateways: &[GatewayMetadata]) -> (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<F>(
|
||||
name: Option<&str>,
|
||||
gateway_flag: &Option<String>,
|
||||
interactive: bool,
|
||||
choose_gateway: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: FnOnce(&[GatewayMetadata], usize) -> Result<Option<String>>,
|
||||
{
|
||||
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 <name>".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<String>) -> 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} {:<name_width$} {:<endpoint_width$} {gateway_type}",
|
||||
gateway.name, gateway.gateway_endpoint,
|
||||
@@ -3556,10 +3663,14 @@ fn print_log_line(log: &navigator_core::proto::SandboxLogLine) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
GatewayControlTarget, TlsOptions, git_sync_files, http_health_check,
|
||||
inferred_provider_type, parse_credential_pairs, resolve_gateway_control_target_from,
|
||||
GatewayControlTarget, TlsOptions, format_gateway_select_header,
|
||||
format_gateway_select_items, gateway_select_with, gateway_type_label, git_sync_files,
|
||||
http_health_check, inferred_provider_type, parse_credential_pairs,
|
||||
resolve_gateway_control_target_from,
|
||||
};
|
||||
use crate::TEST_ENV_LOCK;
|
||||
use hyper::StatusCode;
|
||||
use navigator_bootstrap::{load_active_gateway, store_gateway_metadata};
|
||||
use std::fs;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
@@ -3608,6 +3719,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn with_tmp_xdg<F: FnOnce()>(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");
|
||||
|
||||
Reference in New Issue
Block a user