Ports ZIG/src/cli into ghostty-cli: action detection/dispatch with per-action --help, and the actions help, version, list-actions, list-colors, list-keybinds, list-themes (plain listing plus an interactive ratatui browser mirroring Ghostty's libvaxis UI), show-config, validate-config, edit-config, crash-report (listing), ssh-cache and boo (235 embedded frames). Verified byte-for-byte against the installed Ghostty 1.3.1 for stdout, stderr and exit codes of most actions, incl. tty output in a pty at widths 20-200. Known differences: +list-colors --plain prints all 782 colors (Ghostty truncates at 617 because it never flushes), +version has placeholder build info until build_info::set is wired up. Deferred: list-fonts, show-face (need font discovery integration) and new-window (needs GTK IPC); they return NotYetAvailable for now. Test Plan: - cargo test -p ghostty-cli (86 tests) - cargo run -p ghostty-cli --example cli -- +list-keybinds --plain and diff against `ghostty +list-keybinds --plain`
309 lines
10 KiB
Rust
309 lines
10 KiB
Rust
//! `+ssh-cache` (ZIG/src/cli/ssh_cache.zig).
|
|
|
|
use std::io::Write;
|
|
|
|
use ghostty_config::config_struct;
|
|
|
|
use crate::error::Error;
|
|
|
|
pub mod disk_cache;
|
|
pub mod entry;
|
|
|
|
pub use disk_cache::DiskCache;
|
|
pub use entry::Entry;
|
|
|
|
config_struct! {
|
|
pub struct Options {
|
|
"clear" => clear: bool = false,
|
|
"add" => add: Option<String> = None,
|
|
"remove" => remove: Option<String> = None,
|
|
"host" => host: Option<String> = None,
|
|
"expire-days" => expire_days: Option<u32> = None,
|
|
}
|
|
}
|
|
cli_options!(Options, help);
|
|
|
|
/// Manage the SSH terminfo cache for automatic remote host setup.
|
|
///
|
|
/// When SSH integration is enabled with `shell-integration-features = ssh-terminfo`,
|
|
/// Ghostty automatically installs its terminfo on remote hosts. This command
|
|
/// manages the cache of successful installations to avoid redundant uploads.
|
|
///
|
|
/// The cache stores hostnames (or user@hostname combinations) along with timestamps.
|
|
/// Entries older than the expiration period are automatically removed during cache
|
|
/// operations. By default, entries never expire.
|
|
///
|
|
/// Only one of `--clear`, `--add`, `--remove`, or `--host` can be specified.
|
|
/// If multiple are specified, one of the actions will be executed but
|
|
/// it isn't guaranteed which one. This is entirely unsafe so you should split
|
|
/// multiple actions into separate commands.
|
|
///
|
|
/// Examples:
|
|
/// ghostty +ssh-cache # List all cached hosts
|
|
/// ghostty +ssh-cache --host=example.com # Check if host is cached
|
|
/// ghostty +ssh-cache --add=example.com # Manually add host to cache
|
|
/// ghostty +ssh-cache --add=user@example.com # Add user@host combination
|
|
/// ghostty +ssh-cache --remove=example.com # Remove host from cache
|
|
/// ghostty +ssh-cache --clear # Clear entire cache
|
|
/// ghostty +ssh-cache --expire-days=30 # Set custom expiration period
|
|
pub fn run(args: &[String]) -> Result<u8, Error> {
|
|
let mut opts = Options::default();
|
|
crate::parse_options(&mut opts, args)?;
|
|
|
|
let mut stdout = std::io::BufWriter::new(std::io::stdout().lock());
|
|
let mut stderr = std::io::BufWriter::new(std::io::stderr().lock());
|
|
|
|
let result = run_inner(&opts, &mut stdout, &mut stderr);
|
|
|
|
// Flushing *shouldn't* fail but...
|
|
let _ = stdout.flush();
|
|
let _ = stderr.flush();
|
|
result
|
|
}
|
|
|
|
/// Like Zig's `runInner` with the cache at the standard location.
|
|
pub fn run_inner(
|
|
opts: &Options,
|
|
stdout: &mut dyn Write,
|
|
stderr: &mut dyn Write,
|
|
) -> Result<u8, Error> {
|
|
// Setup our disk cache to the standard location
|
|
let cache_path = DiskCache::default_path("ghostty").map_err(|e| Error::Other(e.to_string()))?;
|
|
run_with_cache(&DiskCache { path: cache_path }, opts, stdout, stderr)
|
|
}
|
|
|
|
fn invalid_hostname(host: &str, stderr: &mut dyn Write) -> Result<u8, Error> {
|
|
writeln!(stderr, "Error: Invalid hostname format '{host}'")?;
|
|
writeln!(stderr, "Expected format: hostname or user@hostname")?;
|
|
Ok(1)
|
|
}
|
|
|
|
/// Run the action against an explicit cache.
|
|
pub fn run_with_cache(
|
|
cache: &DiskCache,
|
|
opts: &Options,
|
|
stdout: &mut dyn Write,
|
|
stderr: &mut dyn Write,
|
|
) -> Result<u8, Error> {
|
|
use disk_cache::{AddResult, Error as CacheError};
|
|
|
|
if opts.clear {
|
|
cache.clear().map_err(|e| Error::Other(e.to_string()))?;
|
|
writeln!(stdout, "Cache cleared.")?;
|
|
return Ok(0);
|
|
}
|
|
|
|
if let Some(host) = &opts.add {
|
|
let result = match cache.add(host) {
|
|
Ok(r) => r,
|
|
Err(CacheError::HostnameIsInvalid) => return invalid_hostname(host, stderr),
|
|
Err(CacheError::CacheIsLocked) => {
|
|
writeln!(stderr, "Error: Cache is busy, try again")?;
|
|
return Ok(1);
|
|
}
|
|
Err(err) => {
|
|
writeln!(
|
|
stderr,
|
|
"Error: Unable to add '{host}' to cache. Error: {err}"
|
|
)?;
|
|
return Ok(1);
|
|
}
|
|
};
|
|
|
|
match result {
|
|
AddResult::Added => writeln!(stdout, "Added '{host}' to cache.")?,
|
|
AddResult::Updated => writeln!(stdout, "Updated '{host}' cache entry.")?,
|
|
}
|
|
return Ok(0);
|
|
}
|
|
|
|
if let Some(host) = &opts.remove {
|
|
match cache.remove(host) {
|
|
Ok(()) => {}
|
|
Err(CacheError::HostnameIsInvalid) => return invalid_hostname(host, stderr),
|
|
Err(CacheError::CacheIsLocked) => {
|
|
writeln!(stderr, "Error: Cache is busy, try again")?;
|
|
return Ok(1);
|
|
}
|
|
Err(err) => {
|
|
writeln!(
|
|
stderr,
|
|
"Error: Unable to remove '{host}' from cache. Error: {err}"
|
|
)?;
|
|
return Ok(1);
|
|
}
|
|
}
|
|
writeln!(stdout, "Removed '{host}' from cache.")?;
|
|
return Ok(0);
|
|
}
|
|
|
|
if let Some(host) = &opts.host {
|
|
let cached = match cache.contains(host) {
|
|
Ok(c) => c,
|
|
Err(CacheError::HostnameIsInvalid) => return invalid_hostname(host, stderr),
|
|
Err(err) => {
|
|
writeln!(
|
|
stderr,
|
|
"Error: Unable to check host '{host}' in cache. Error: {err}"
|
|
)?;
|
|
return Ok(1);
|
|
}
|
|
};
|
|
|
|
if cached {
|
|
writeln!(stdout, "'{host}' has Ghostty terminfo installed.")?;
|
|
return Ok(0);
|
|
}
|
|
writeln!(stdout, "'{host}' does not have Ghostty terminfo installed.")?;
|
|
return Ok(1);
|
|
}
|
|
|
|
// Default action: list all hosts
|
|
let entries = cache.list().map_err(|e| Error::Other(e.to_string()))?;
|
|
list_entries(&entries, entry::now_timestamp(), stdout)?;
|
|
Ok(0)
|
|
}
|
|
|
|
fn list_entries(
|
|
entries: &disk_cache::Entries,
|
|
now: i64,
|
|
writer: &mut dyn Write,
|
|
) -> std::io::Result<()> {
|
|
if entries.is_empty() {
|
|
writeln!(writer, "No hosts in cache.")?;
|
|
return Ok(());
|
|
}
|
|
|
|
// Sort entries by hostname for consistent output
|
|
let mut items: Vec<&Entry> = entries.values().collect();
|
|
items.sort_by(|a, b| a.hostname.as_bytes().cmp(b.hostname.as_bytes()));
|
|
|
|
writeln!(writer, "Cached hosts ({}):", items.len())?;
|
|
|
|
for entry in items {
|
|
let age_days = (now - entry.timestamp) / 86_400;
|
|
if age_days == 0 {
|
|
writeln!(writer, " {} (today)", entry.hostname)?;
|
|
} else if age_days == 1 {
|
|
writeln!(writer, " {} (yesterday)", entry.hostname)?;
|
|
} else {
|
|
writeln!(writer, " {} ({age_days} days ago)", entry.hostname)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#![allow(clippy::unwrap_used)]
|
|
use super::*;
|
|
|
|
fn run_cache(cache: &DiskCache, opts: &Options) -> (u8, String, String) {
|
|
let (mut out, mut err) = (Vec::new(), Vec::new());
|
|
let rc = run_with_cache(cache, opts, &mut out, &mut err).unwrap();
|
|
(
|
|
rc,
|
|
String::from_utf8(out).unwrap(),
|
|
String::from_utf8(err).unwrap(),
|
|
)
|
|
}
|
|
|
|
fn opt_add(h: &str) -> Options {
|
|
Options {
|
|
add: Some(h.into()),
|
|
..Options::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn add_list_host_remove_clear() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let cache = DiskCache {
|
|
path: tmp.path().join("ssh_cache"),
|
|
};
|
|
|
|
let (rc, out, _) = run_cache(&cache, &Options::default());
|
|
assert_eq!((0, "No hosts in cache.\n"), (rc, out.as_str()));
|
|
|
|
let (rc, out, _) = run_cache(&cache, &opt_add("b.com"));
|
|
assert_eq!((0, "Added 'b.com' to cache.\n"), (rc, out.as_str()));
|
|
let (rc, out, _) = run_cache(&cache, &opt_add("b.com"));
|
|
assert_eq!((0, "Updated 'b.com' cache entry.\n"), (rc, out.as_str()));
|
|
run_cache(&cache, &opt_add("a.com"));
|
|
|
|
let (rc, out, _) = run_cache(&cache, &Options::default());
|
|
assert_eq!(0, rc);
|
|
assert_eq!("Cached hosts (2):\n a.com (today)\n b.com (today)\n", out);
|
|
|
|
let host = |h: &str| Options {
|
|
host: Some(h.into()),
|
|
..Options::default()
|
|
};
|
|
let (rc, out, _) = run_cache(&cache, &host("a.com"));
|
|
assert_eq!(
|
|
(0, "'a.com' has Ghostty terminfo installed.\n"),
|
|
(rc, out.as_str())
|
|
);
|
|
let (rc, out, _) = run_cache(&cache, &host("zzz.com"));
|
|
assert_eq!(
|
|
(1, "'zzz.com' does not have Ghostty terminfo installed.\n"),
|
|
(rc, out.as_str())
|
|
);
|
|
|
|
let (rc, out, _) = run_cache(
|
|
&cache,
|
|
&Options {
|
|
remove: Some("a.com".into()),
|
|
..Options::default()
|
|
},
|
|
);
|
|
assert_eq!((0, "Removed 'a.com' from cache.\n"), (rc, out.as_str()));
|
|
|
|
let (rc, out, _) = run_cache(
|
|
&cache,
|
|
&Options {
|
|
clear: true,
|
|
..Options::default()
|
|
},
|
|
);
|
|
assert_eq!((0, "Cache cleared.\n"), (rc, out.as_str()));
|
|
assert!(!cache.path.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_hostname_message() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let cache = DiskCache {
|
|
path: tmp.path().join("ssh_cache"),
|
|
};
|
|
let (rc, out, err) = run_cache(&cache, &opt_add("bad host"));
|
|
assert_eq!(1, rc);
|
|
assert_eq!(0, out.len());
|
|
assert_eq!(
|
|
"Error: Invalid hostname format 'bad host'\nExpected format: hostname or user@hostname\n",
|
|
err
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn list_age_wording() {
|
|
let mut entries = disk_cache::Entries::new();
|
|
for (h, age) in [("a", 0), ("b", 1), ("c", 5)] {
|
|
entries.insert(
|
|
h.to_string(),
|
|
Entry {
|
|
hostname: h.to_string(),
|
|
timestamp: 1_000_000 - age * 86_400,
|
|
terminfo_version: "x".into(),
|
|
},
|
|
);
|
|
}
|
|
let mut out = Vec::new();
|
|
list_entries(&entries, 1_000_000, &mut out).unwrap();
|
|
assert_eq!(
|
|
"Cached hosts (3):\n a (today)\n b (yesterday)\n c (5 days ago)\n",
|
|
String::from_utf8(out).unwrap()
|
|
);
|
|
}
|
|
}
|