feat(cli): port ghostty +action CLI commands
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`
This commit is contained in:
1 parent
5462bd8e3e
commit
f2c4b40062
46 files changed
+7135
-6
No files matched your search
@@ -0,0 +1,308 @@
|
||||
//! `+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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user