#![forbid(unsafe_code)]
#[cfg(not(unix))]
compile_error!("fleetcom supports Unix platforms only.");
mod app;
mod editbuf;
mod ui;
mod core;
mod preview;
mod supervisor;
mod task;
mod daemon;
mod protocol;
mod session;
mod transport;
mod harness;
mod path;
mod terminal;
#[cfg(test)]
mod testutil;
pub(crate) use terminal::{ansi, emulator, format, frame, input};
use std::{
io::{self, IsTerminal},
sync::{
Arc,
atomic::{AtomicBool, Ordering},
},
};
use crossterm::{
cursor::{Hide, Show},
event::{
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, KeyboardEnhancementFlags,
PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
},
execute,
style::Print,
terminal::{
Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
enable_raw_mode, size, supports_keyboard_enhancement,
},
};
use app::App;
static KITTY_PUSHED: AtomicBool = AtomicBool::new(false);
const USAGE: &str = "\
fleetcom - a fleet-view supervisor for arbitrary shell commands
Usage:
fleetcom [<session>] connect to the daemon (autostarting it),
optionally loading a saved session
fleetcom --foreground [<session>] run without a daemon; tasks die on quit
fleetcom --kill kill the daemon and every task it owns
fleetcom --daemon run the daemon (internal; the first
fleetcom starts it automatically)
Options:
-h, --help print this help
-V, --version print the version
--scrollback <lines> per-task terminal scrollback in lines
(default 2000, max 100000, 0 disables)
";
#[derive(Debug, PartialEq, Eq)]
enum Invocation {
Help,
Version,
Daemon,
Kill,
Client {
foreground: bool,
session: Option<String>,
scrollback: Option<usize>,
},
}
fn parse_args(args: &[String]) -> Result<Invocation, String> {
let (mut daemon, mut kill, mut foreground) = (false, false, false);
let mut session: Option<String> = None;
let mut scrollback: Option<usize> = None;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"-h" | "--help" => return Ok(Invocation::Help),
"-V" | "--version" => return Ok(Invocation::Version),
"--daemon" => daemon = true,
"--kill" => kill = true,
"--foreground" => foreground = true,
"--scrollback" => {
let v = it
.next()
.ok_or_else(|| "--scrollback requires a value".to_string())?;
scrollback = Some(
v.parse()
.map_err(|_| format!("invalid --scrollback value '{v}'"))?,
);
}
f if f.starts_with('-') => return Err(format!("unrecognized flag '{f}'")),
name => {
if session.is_some() {
return Err(format!(
"unexpected argument '{name}' (one session name max)"
));
}
session = Some(name.to_string());
}
}
}
if daemon && (kill || foreground || session.is_some() || scrollback.is_some()) {
return Err("--daemon takes no other arguments".to_string());
}
if kill && (foreground || session.is_some() || scrollback.is_some()) {
return Err("--kill takes no other arguments".to_string());
}
match (daemon, kill) {
(true, _) => Ok(Invocation::Daemon),
(_, true) => Ok(Invocation::Kill),
_ => Ok(Invocation::Client {
foreground,
session,
scrollback,
}),
}
}
fn error_line(e: impl std::fmt::Display) -> String {
format!("fleetcom: {e}")
}
fn main() {
if let Err(e) = run() {
eprintln!("{}", error_line(&e));
std::process::exit(1);
}
}
fn run() -> io::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let (foreground, session, scrollback) = match parse_args(&args) {
Ok(Invocation::Help) => {
print!("{USAGE}");
return Ok(());
}
Ok(Invocation::Version) => {
println!("fleetcom {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
Ok(Invocation::Daemon) => return daemon::run_daemon(),
Ok(Invocation::Kill) => return daemon::run_kill(),
Ok(Invocation::Client {
foreground,
session,
scrollback,
}) => (foreground, session, scrollback),
Err(e) => {
eprintln!("{}", error_line(e));
eprintln!("try 'fleetcom --help'");
std::process::exit(2);
}
};
if !io::stdout().is_terminal() {
eprintln!("{}", error_line("stdout is not a terminal"));
std::process::exit(1);
}
if let Some(lines) = scrollback {
supervisor::set_scrollback_flag(lines);
}
install_panic_hook();
let (cols, rows) = size()?;
let mut app = if foreground {
App::new_foreground(rows, cols)
} else {
match App::connect(rows, cols) {
Ok(a) => a,
Err(e) => {
eprintln!("fleetcom: could not reach the daemon: {e}");
std::process::exit(1);
}
}
};
install_signal_handlers(app.signal_flag())?;
let mut out = io::stdout();
enable_raw_mode()?;
let guard = TerminalGuard;
let kitty = supports_keyboard_enhancement().unwrap_or(false);
execute!(out, EnterAlternateScreen, Clear(ClearType::All), Hide)?;
if kitty {
execute!(
out,
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
)?;
KITTY_PUSHED.store(true, Ordering::Relaxed);
}
execute!(out, EnableBracketedPaste, Print("\x1b[?1007s\x1b[?1007h"))?;
if let Some(name) = &session {
app.load_session(name);
}
let result = app.run(&mut out);
drop(guard);
result
}
struct TerminalGuard;
impl Drop for TerminalGuard {
fn drop(&mut self) {
restore_terminal(&mut io::stdout());
}
}
fn restore_terminal(out: &mut io::Stdout) {
let _ = emit_restore_sequences(out, KITTY_PUSHED.load(Ordering::Relaxed));
let _ = disable_raw_mode();
}
fn emit_restore_sequences(out: &mut impl io::Write, kitty_pushed: bool) -> io::Result<()> {
if kitty_pushed {
execute!(out, PopKeyboardEnhancementFlags)?;
}
execute!(
out,
DisableMouseCapture,
DisableBracketedPaste,
Print("\x1b[?1007r"),
Show,
LeaveAlternateScreen
)
}
pub(crate) fn install_signal_handlers(flag: Arc<AtomicBool>) -> io::Result<()> {
use signal_hook::consts::{SIGHUP, SIGINT, SIGTERM};
signal_hook::flag::register(SIGTERM, Arc::clone(&flag))?;
signal_hook::flag::register(SIGHUP, Arc::clone(&flag))?;
signal_hook::flag::register(SIGINT, flag)?;
Ok(())
}
fn install_panic_hook() {
let default = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
restore_terminal(&mut io::stdout());
default(info);
}));
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Result<Invocation, String> {
let owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
parse_args(&owned)
}
#[test]
fn plain_and_session_invocations() {
assert_eq!(
parse(&[]),
Ok(Invocation::Client {
foreground: false,
session: None,
scrollback: None
})
);
assert_eq!(
parse(&["work"]),
Ok(Invocation::Client {
foreground: false,
session: Some("work".into()),
scrollback: None
})
);
assert_eq!(
parse(&["--foreground", "work"]),
Ok(Invocation::Client {
foreground: true,
session: Some("work".into()),
scrollback: None
})
);
}
#[test]
fn scrollback_flag_parses_and_rejects() {
assert_eq!(
parse(&["--scrollback", "5000"]),
Ok(Invocation::Client {
foreground: false,
session: None,
scrollback: Some(5000)
})
);
assert_eq!(
parse(&["--scrollback", "0", "work"]),
Ok(Invocation::Client {
foreground: false,
session: Some("work".into()),
scrollback: Some(0)
})
);
assert!(parse(&["--scrollback", "garbage"]).is_err());
assert!(parse(&["--scrollback"]).is_err());
assert!(parse(&["--scrollback", "-5"]).is_err());
assert!(parse(&["--daemon", "--scrollback", "5000"]).is_err());
assert!(parse(&["--kill", "--scrollback", "5000"]).is_err());
}
#[test]
fn modes_parse() {
assert_eq!(parse(&["--daemon"]), Ok(Invocation::Daemon));
assert_eq!(parse(&["--kill"]), Ok(Invocation::Kill));
assert_eq!(parse(&["-h"]), Ok(Invocation::Help));
assert_eq!(parse(&["--help"]), Ok(Invocation::Help));
assert_eq!(parse(&["-V"]), Ok(Invocation::Version));
assert_eq!(parse(&["--version"]), Ok(Invocation::Version));
}
#[test]
fn unknown_flags_are_rejected() {
assert!(parse(&["--foregroud"]).is_err());
assert!(parse(&["-x"]).is_err());
assert!(parse(&["--daemonize"]).is_err());
}
#[test]
fn help_and_version_win() {
assert_eq!(parse(&["--daemon", "--help"]), Ok(Invocation::Help));
assert_eq!(parse(&["--kill", "-V"]), Ok(Invocation::Version));
}
#[test]
fn conflicting_modes_are_rejected() {
assert!(parse(&["--daemon", "--kill"]).is_err());
assert!(parse(&["--daemon", "work"]).is_err());
assert!(parse(&["--daemon", "--foreground"]).is_err());
assert!(parse(&["--kill", "--foreground"]).is_err());
assert!(parse(&["--kill", "work"]).is_err());
}
#[test]
fn second_session_name_is_rejected() {
assert!(parse(&["one", "two"]).is_err());
}
#[test]
fn error_line_prefixes_display_form() {
let e = io::Error::new(io::ErrorKind::TimedOut, "daemon did not exit after SIGTERM");
assert_eq!(
error_line(&e),
"fleetcom: daemon did not exit after SIGTERM"
);
assert_eq!(
error_line("stdout is not a terminal"),
"fleetcom: stdout is not a terminal"
);
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
fn encode(cmd: impl crossterm::Command) -> Vec<u8> {
let mut buf = Vec::new();
execute!(buf, cmd).unwrap();
buf
}
#[test]
fn restore_pops_kitty_flags_only_when_pushed() {
let pop = encode(PopKeyboardEnhancementFlags);
let mut pushed = Vec::new();
emit_restore_sequences(&mut pushed, true).unwrap();
let mut unpushed = Vec::new();
emit_restore_sequences(&mut unpushed, false).unwrap();
assert!(contains(&pushed, &pop));
assert!(!contains(&unpushed, &pop));
let leave = encode(LeaveAlternateScreen);
let show = encode(Show);
for out in [&pushed, &unpushed] {
assert!(contains(out, &leave));
assert!(contains(out, &show));
assert!(contains(out, b"\x1b[?1007r"));
}
}
}