Implement porthole v0.1: profiles, supervised open/close, reconnect
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>
This commit is contained in:
120
src/commands/list.rs
Normal file
120
src/commands/list.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user