(initial) Align formatting and code style to project standards. Add live_test.sh: full automated integration suite against a real SSH server Show [name] for optional positionals, allow filtering import by name too Fix transfer rough edges: value-name hint, identity warnings, single-profile export Add transfer subcommand for bulk profile export/import Harden forced ssh flags: accept-new host keys, block multiplexing, tighten identity auth release build opts Standardize error messages, formatting, and code style. Refine --help text for consistency. Update comments for clarity and tone alignment. Normalize user-facing --help text for tone/format consistency Show real mapping grammar in --help, make --via repeatable, trim comments Match vmic's custom top-level --help renderer Implement porthole v0.1: profiles, supervised open/close, reconnect Fix --via/-J grammar and --force process-group kill added .gitignore
209 lines
7.3 KiB
Rust
209 lines
7.3 KiB
Rust
mod atomic;
|
|
mod cli;
|
|
mod commands;
|
|
mod error;
|
|
mod instance;
|
|
mod profile;
|
|
mod ssh;
|
|
mod supervisor;
|
|
mod timefmt;
|
|
mod ui;
|
|
|
|
use clap::{CommandFactory, Parser};
|
|
use cli::{Cli, Commands};
|
|
|
|
fn main()
|
|
{
|
|
let args: Vec<String> = std::env::args().collect();
|
|
|
|
// Plain `porthole`, `-h`/`--help`, or `help` at the top level: show
|
|
// every subcommand's own flags inline instead of requiring a
|
|
// per-subcommand `--help`.
|
|
if wants_top_level_help(&args) {
|
|
print_full_help();
|
|
std::process::exit(if args.len() <= 1 { 2 } else { 0 });
|
|
}
|
|
|
|
let cli = match Cli::try_parse_from(&args) {
|
|
Ok(cli) => cli,
|
|
Err(e) => e.exit(),
|
|
};
|
|
|
|
let result = match cli.command {
|
|
Commands::Add(args) => commands::add::run(args),
|
|
Commands::Open(args) => commands::open::run(args),
|
|
Commands::Close(args) => commands::close::run(args),
|
|
Commands::Edit(args) => commands::edit::run(args),
|
|
Commands::Status(args) => commands::status::run(args),
|
|
Commands::List(args) => commands::list::run(args),
|
|
Commands::Remove(args) => commands::remove::run(args),
|
|
Commands::Wipe(args) => commands::wipe::run(args),
|
|
Commands::Transfer(args) => commands::transfer::run(args),
|
|
Commands::Completions { shell } => {
|
|
commands::completions::run(shell);
|
|
Ok(())
|
|
}
|
|
// Internal
|
|
Commands::Supervise { name } => supervisor::run(&name),
|
|
};
|
|
|
|
if let Err(e) = result {
|
|
ui::err(&e.to_string());
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
/// True for a bare `porthole` invocation, or top-level `-h`/`--help`/`help`,
|
|
/// i.e. anything that should show the expanded help rather than being
|
|
/// handled (or rejected) by a specific subcommand.
|
|
fn wants_top_level_help(args: &[String]) -> bool {
|
|
match args.get(1..) {
|
|
Some([]) => true,
|
|
Some([a]) => a == "-h" || a == "--help" || a == "help",
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Prints one screen of help: one summary line per subcommand (name,
|
|
/// positional args, description), followed by an indented line per flag
|
|
/// with its own help text.
|
|
fn print_full_help() {
|
|
let mut cmd = Cli::command();
|
|
cmd.build(); // resolve default value names etc. before introspecting
|
|
let bin = cmd.get_name().to_string();
|
|
|
|
if let Some(about) = cmd.get_about() {
|
|
println!("{about}");
|
|
println!();
|
|
}
|
|
println!("{} {bin} {} {}", ui::blue("Usage:"), ui::cyan("<COMMAND>"), ui::yellow("[ARGS]"));
|
|
println!();
|
|
|
|
println!("{}", ui::blue("Commands:"));
|
|
let subcommands: Vec<&clap::Command> =
|
|
cmd.get_subcommands().filter(|s| s.get_name() != "help" && !s.is_hide_set()).collect();
|
|
let flag_rows_by_cmd: Vec<Vec<(String, String)>> = subcommands.iter().map(|s| flag_rows(s)).collect();
|
|
|
|
let name_w = subcommands.iter().map(|s| s.get_name().len()).max().unwrap_or(0);
|
|
let pos_w = subcommands.iter().map(|s| visual_width(&positional_args(s))).max().unwrap_or(0);
|
|
// One width across every subcommand's flags, not just its own, so the
|
|
// help-text column lines up no matter which command it's under.
|
|
let flag_w = flag_rows_by_cmd.iter().flatten().map(|(f, _)| visual_width(f)).max().unwrap_or(0);
|
|
|
|
for (i, s) in subcommands.iter().enumerate() {
|
|
let name = s.get_name();
|
|
let positionals = positional_args(s);
|
|
let about = s.get_about().map(|a| a.to_string()).unwrap_or_default();
|
|
|
|
println!(" {name:name_w$} {} {about}", pad_visual(&positionals, pos_w));
|
|
|
|
let rows = &flag_rows_by_cmd[i];
|
|
for (flag, help) in rows {
|
|
println!(" {} {help}", pad_visual(flag, flag_w));
|
|
}
|
|
// Flagless commands stay a tight single line; only the multi-line
|
|
// (flag-bearing) ones get a blank line to separate them visually.
|
|
if !rows.is_empty() && i + 1 < subcommands.len() {
|
|
println!();
|
|
}
|
|
}
|
|
println!();
|
|
|
|
let alias_rows: Vec<(String, String)> = subcommands
|
|
.iter()
|
|
.filter_map(|s| {
|
|
let aliases = s.get_visible_aliases().collect::<Vec<_>>().join(", ");
|
|
(!aliases.is_empty()).then(|| (s.get_name().to_string(), aliases))
|
|
})
|
|
.collect();
|
|
if !alias_rows.is_empty() {
|
|
println!("{}", ui::blue("Aliases:"));
|
|
let name_w = alias_rows.iter().map(|(n, _)| n.len()).max().unwrap_or(0);
|
|
for (name, aliases) in &alias_rows {
|
|
println!(" {name:name_w$} {aliases}");
|
|
}
|
|
println!();
|
|
}
|
|
|
|
println!("{}", ui::blue("Options:"));
|
|
let opt_rows: Vec<(String, String)> = cmd
|
|
.get_arguments()
|
|
.filter(|a| !a.is_positional())
|
|
.filter_map(|a| Some((flag_names(a)?, a.get_help().map(|h| h.to_string()).unwrap_or_default())))
|
|
.collect();
|
|
let flag_w = opt_rows.iter().map(|(f, _)| f.len()).max().unwrap_or(0);
|
|
for (flag, help) in &opt_rows {
|
|
println!(" {flag:flag_w$} {help}");
|
|
}
|
|
}
|
|
|
|
/// `<name>` for each required positional arg of `cmd`, `[name]` for an
|
|
/// optional one, space-joined and colored cyan.
|
|
fn positional_args(cmd: &clap::Command) -> String {
|
|
cmd.get_positionals()
|
|
.map(|a| {
|
|
let id = a.get_id().as_str();
|
|
let text = if a.is_required_set() { format!("<{id}>") } else { format!("[{id}]") };
|
|
ui::cyan(&text)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
}
|
|
|
|
/// `(flag display, help text)` for each of `cmd`'s non-positional, non-help
|
|
/// args, e.g. `("-l/--local <[BIND:]PORT:HOST:PORT>", "Local forward: your
|
|
/// machine -> remote")`. The flag display is colored yellow.
|
|
fn flag_rows(cmd: &clap::Command) -> Vec<(String, String)> {
|
|
cmd.get_arguments()
|
|
.filter(|a| !a.is_positional() && a.get_id().as_str() != "help")
|
|
.filter_map(|a| {
|
|
let flag = flag_names(a)?;
|
|
let display = if matches!(a.get_action(), clap::ArgAction::Set | clap::ArgAction::Append) {
|
|
let value = a
|
|
.get_value_names()
|
|
.and_then(|v| v.first())
|
|
.map(|v| v.to_string())
|
|
.unwrap_or_else(|| a.get_id().as_str().to_uppercase());
|
|
format!("{flag} <{value}>")
|
|
} else {
|
|
flag
|
|
};
|
|
let help = a.get_help().map(|h| h.to_string()).unwrap_or_default();
|
|
Some((ui::yellow(&display), help))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// `-x/--long` / `-x` / `--long` for a non-positional arg, or `None` for
|
|
/// one with no visible flag at all.
|
|
fn flag_names(arg: &clap::Arg) -> Option<String> {
|
|
match (arg.get_short(), arg.get_long()) {
|
|
(Some(s), Some(l)) => Some(format!("-{s}/--{l}")),
|
|
(Some(s), None) => Some(format!("-{s}")),
|
|
(None, Some(l)) => Some(format!("--{l}")),
|
|
(None, None) => None,
|
|
}
|
|
}
|
|
|
|
/// Number of visible columns in `s`, skipping any `\x1b[...m` ANSI SGR
|
|
/// escape sequences it contains.
|
|
fn visual_width(s: &str) -> usize {
|
|
let mut width = 0;
|
|
let mut in_escape = false;
|
|
for c in s.chars() {
|
|
if in_escape {
|
|
in_escape = c != 'm';
|
|
} else if c == '\x1b' {
|
|
in_escape = true;
|
|
} else {
|
|
width += 1;
|
|
}
|
|
}
|
|
width
|
|
}
|
|
|
|
/// Right-pads `s` with spaces to `width` visible columns, per `visual_width`.
|
|
fn pad_visual(s: &str, width: usize) -> String {
|
|
format!("{s}{}", " ".repeat(width.saturating_sub(visual_width(s))))
|
|
}
|