Full CLI per spec v0.2 - add/open/close/edit/status/list/remove/wipe/ completions, plus a hidden `__supervise` subcommand that IS the supervisor process. - profile.rs: TOML-backed profiles at ~/.config/porthole/profiles/, validated -l/-r/-d mapping + --via grammar, atomic writes. - instance.rs: JSON runtime state at ~/.local/state/porthole/, an flock-based lock file that's the source of truth for "is this open" (survives a crash/kill -9 without stale-lock cleanup), pid liveness checked against /proc rather than trusted from disk. - supervisor.rs: the __supervise loop - spawns ssh, traps SIGTERM/SIGINT into a flag (rather than inferring intent from ssh's exit status), classifies failures as fatal/known-transient/unrecognized, backs off with a stability-reset, rotates its log. - ssh.rs: builds the ssh invocation, including splitting --via into a -J jump chain plus the mandatory positional target. - open.rs: the detach/re-exec dance (setsid via pre_exec) and a bounded wait for the supervisor to reach Up/Error before open returns, so an immediate failure surfaces as a non-zero exit instead of a false "opened" - this took a real bug fix during smoke testing, since the instance file's initial state (Reconnecting, meaning "attempt in flight") was indistinguishable from "already failed once" by state alone. - close.rs: SIGTERM+wait, or SIGKILL the whole process group with --force so ssh can't be left orphaned. Smoke-tested against invalid/unreachable hosts (no real infrastructure touched): CLI surface, validation errors, add/edit/list/status/remove, the reconnect/backoff loop with live state transitions, close mid-retry, edit-while-running's warning, open --all, and wipe. cargo test: 14/14. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
121 lines
3.4 KiB
Rust
121 lines
3.4 KiB
Rust
use crate::cli::ListArgs;
|
|
use crate::error::Result;
|
|
use crate::{instance, profile, timefmt, ui};
|
|
use serde::Serialize;
|
|
|
|
struct Row {
|
|
name: String,
|
|
kind: String,
|
|
mapping: String,
|
|
via: String,
|
|
state: String,
|
|
uptime: String,
|
|
}
|
|
|
|
pub fn run(args: ListArgs) -> Result<()> {
|
|
let profiles = profile::list_all()?;
|
|
if profiles.is_empty() {
|
|
ui::info("No profiles saved.");
|
|
return Ok(());
|
|
}
|
|
|
|
let mut rows = Vec::new();
|
|
for p in &profiles {
|
|
let inst = instance::load(&p.name)?;
|
|
let live = inst.as_ref().is_some_and(|i| instance::supervisor_alive(i.pid, &p.name));
|
|
let (state, uptime) = match &inst {
|
|
None => ("closed".to_string(), String::new()),
|
|
Some(_) if !live => ("error".to_string(), String::new()),
|
|
Some(i) => (
|
|
i.state.label().to_string(),
|
|
i.connected_at.map(|c| timefmt::fmt_duration(timefmt::now() - c)).unwrap_or_default(),
|
|
),
|
|
};
|
|
|
|
if args.running && !matches!(state.as_str(), "up" | "reconnecting") {
|
|
continue;
|
|
}
|
|
|
|
rows.push(Row {
|
|
name: p.name.clone(),
|
|
kind: p.kind.label().to_string(),
|
|
mapping: p.mapping.clone(),
|
|
via: p.via.join(","),
|
|
state,
|
|
uptime,
|
|
});
|
|
}
|
|
|
|
if args.json {
|
|
print_json(&rows);
|
|
return Ok(());
|
|
}
|
|
|
|
if rows.is_empty() {
|
|
ui::info("No matching profiles.");
|
|
return Ok(());
|
|
}
|
|
print_table(&rows);
|
|
Ok(())
|
|
}
|
|
|
|
fn col(i: usize, r: &Row) -> &str {
|
|
match i {
|
|
0 => &r.name,
|
|
1 => &r.kind,
|
|
2 => &r.mapping,
|
|
3 => &r.via,
|
|
4 => &r.state,
|
|
_ => &r.uptime,
|
|
}
|
|
}
|
|
|
|
fn print_table(rows: &[Row]) {
|
|
let headers = ["NAME", "KIND", "MAPPING", "VIA", "STATE", "UPTIME"];
|
|
let widths: Vec<usize> =
|
|
(0..6).map(|i| rows.iter().map(|r| col(i, r).len()).max().unwrap_or(0).max(headers[i].len())).collect();
|
|
|
|
let header_line: Vec<String> = headers.iter().enumerate().map(|(i, h)| format!("{h:<w$}", w = widths[i])).collect();
|
|
println!("{}", ui::blue(&header_line.join(" ")));
|
|
|
|
for r in rows {
|
|
let state_colored = match r.state.as_str() {
|
|
"up" => ui::green(&r.state),
|
|
"reconnecting" => ui::yellow(&r.state),
|
|
"error" => ui::red(&r.state),
|
|
_ => r.state.clone(),
|
|
};
|
|
let cells = [
|
|
format!("{:<w$}", r.name, w = widths[0]),
|
|
format!("{:<w$}", r.kind, w = widths[1]),
|
|
format!("{:<w$}", r.mapping, w = widths[2]),
|
|
format!("{:<w$}", r.via, w = widths[3]),
|
|
// padded on the uncolored text width, then swapped for the
|
|
// colored version so ANSI codes don't throw off alignment
|
|
format!("{:<w$}", r.state, w = widths[4]).replacen(&r.state, &state_colored, 1),
|
|
format!("{:<w$}", r.uptime, w = widths[5]),
|
|
];
|
|
println!("{}", cells.join(" "));
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct RowJson<'a> {
|
|
name: &'a str,
|
|
kind: &'a str,
|
|
mapping: &'a str,
|
|
via: &'a str,
|
|
state: &'a str,
|
|
uptime: &'a str,
|
|
}
|
|
|
|
fn print_json(rows: &[Row]) {
|
|
let out: Vec<RowJson> = rows
|
|
.iter()
|
|
.map(|r| RowJson { name: &r.name, kind: &r.kind, mapping: &r.mapping, via: &r.via, state: &r.state, uptime: &r.uptime })
|
|
.collect();
|
|
if let Ok(text) = serde_json::to_string_pretty(&out) {
|
|
println!("{text}");
|
|
}
|
|
}
|