use crate::cli::{AttachArgs, DetachArgs, InspectArgs, LogsArgs, PruneArgs, PsArgs, StopArgs};
use crate::command_display::{format_command_line, truncate_command};
use crate::session::{self, SessionAttachment, SessionRecord, SessionStatus};
use colored::Colorize;
use nono::{NonoError, Result};
use std::collections::VecDeque;
use std::io::{BufRead, Seek, SeekFrom};
use std::path::Path;
use tracing::debug;
fn reject_if_sandboxed(command: &str) -> Result<()> {
if std::env::var_os("NONO_CAP_FILE").is_some() {
return Err(NonoError::ConfigParse(format!(
"`nono {}` cannot be used inside a sandbox.",
command
)));
}
Ok(())
}
pub fn run_ps(args: &PsArgs) -> Result<()> {
let sessions = session::list_sessions()?;
let filtered: Vec<&SessionRecord> = sessions
.iter()
.filter(|s| args.all || s.status != SessionStatus::Exited)
.collect();
if args.json {
let json = serde_json::to_string_pretty(&filtered)
.map_err(|e| nono::NonoError::ConfigParse(format!("JSON serialization failed: {e}")))?;
println!("{json}");
return Ok(());
}
if filtered.is_empty() {
if args.all {
eprintln!("No sessions found.");
} else {
eprintln!("No running or detached sessions. Use --all to include exited sessions.");
}
return Ok(());
}
println!(
"{:<16} {:<12} {:<12} {:<12} {:<8} {:<10} {:<14} COMMAND",
"SESSION", "NAME", "STATUS", "ATTACH", "PID", "UPTIME", "PROFILE"
);
for session in &filtered {
let name = session.name.as_deref().unwrap_or("-");
let col_width = 12;
let exit_code = session.exit_code.unwrap_or(-1);
let status_text = match session.status {
SessionStatus::Running => "running".to_string(),
SessionStatus::Paused => "paused".to_string(),
SessionStatus::Exited => format!("exited({exit_code})"),
};
let status_padded = format!("{status_text:<col_width$}");
let status = match session.status {
SessionStatus::Running => status_padded.green().to_string(),
SessionStatus::Paused => status_padded.yellow().to_string(),
SessionStatus::Exited if exit_code != 0 => status_padded.red().to_string(),
_ => status_padded,
};
let attach_text = match session.status {
SessionStatus::Exited => "-".to_string(),
_ => match session.attachment {
SessionAttachment::Attached => "attached".to_string(),
SessionAttachment::Detached => "detached".to_string(),
},
};
let attach_padded = format!("{attach_text:<col_width$}");
let attach = match (&session.status, &session.attachment) {
(SessionStatus::Exited, _) => attach_padded,
(_, SessionAttachment::Attached) => attach_padded.green().to_string(),
(_, SessionAttachment::Detached) => attach_padded.yellow().to_string(),
};
let pid = session.child_pid;
let uptime = format_uptime(&session.started);
let profile = session.profile.as_deref().unwrap_or("-");
let command = truncate_command(&session.command, 40);
println!(
"{:<16} {:<12} {} {} {:<8} {:<10} {:<14} {}",
session.session_id, name, status, attach, pid, uptime, profile, command
);
}
Ok(())
}
fn format_uptime(started: &str) -> String {
let Ok(start) = chrono::DateTime::parse_from_rfc3339(started) else {
return "-".to_string();
};
let now = chrono::Local::now();
let duration = now.signed_duration_since(start);
if duration.num_days() > 0 {
format!("{}d", duration.num_days())
} else if duration.num_hours() > 0 {
format!("{}h", duration.num_hours())
} else if duration.num_minutes() > 0 {
format!("{}m", duration.num_minutes())
} else {
format!("{}s", duration.num_seconds().max(0))
}
}
pub fn run_stop(args: &StopArgs) -> Result<()> {
reject_if_sandboxed("stop")?;
let record = session::load_session(&args.session)?;
if record.status == SessionStatus::Exited {
return Err(NonoError::ConfigParse(format!(
"Session {} is already exited",
record.session_id
)));
}
if !session::is_process_alive(record.supervisor_pid, record.started_epoch) {
return Err(NonoError::ConfigParse(format!(
"Session {} supervisor (PID {}) is no longer running",
record.session_id, record.supervisor_pid
)));
}
let pid = nix::unistd::Pid::from_raw(record.supervisor_pid as i32);
if args.force {
nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGKILL)
.map_err(|e| NonoError::ConfigParse(format!("Failed to send SIGKILL: {}", e)))?;
eprintln!("Stopped session {}.", record.session_id);
} else {
nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGTERM)
.map_err(|e| NonoError::ConfigParse(format!("Failed to send SIGTERM: {}", e)))?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(args.timeout);
loop {
if !session::is_process_alive(record.supervisor_pid, record.started_epoch) {
eprintln!("Stopped session {}.", record.session_id);
break;
}
if std::time::Instant::now() >= deadline {
let _ = nix::sys::signal::kill(pid, nix::sys::signal::Signal::SIGKILL);
eprintln!("Stopped session {} (forced).", record.session_id);
break;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
}
Ok(())
}
pub fn run_detach(args: &DetachArgs) -> Result<()> {
reject_if_sandboxed("detach")?;
let record = session::load_session(&args.session)?;
if record.attachment == SessionAttachment::Detached {
eprintln!("Session {} is already detached.", record.session_id);
return Ok(());
}
if record.status != SessionStatus::Running {
return Err(NonoError::ConfigParse(format!(
"Session {} is not running (status: {:?})",
record.session_id, record.status
)));
}
if !session::is_process_alive(record.supervisor_pid, record.started_epoch) {
return Err(NonoError::ConfigParse(format!(
"Session {} supervisor (PID {}) is no longer running",
record.session_id, record.supervisor_pid
)));
}
crate::pty_proxy::request_session_detach(&record.session_id)?;
eprintln!("Detached session {}.", record.session_id);
Ok(())
}
pub fn run_attach(args: &AttachArgs) -> Result<()> {
reject_if_sandboxed("attach")?;
let record = session::load_session(&args.session)?;
if record.status == SessionStatus::Exited {
match record.exit_code {
Some(code) => {
eprintln!(
"[nono] Session {} has already exited (exit code {}).",
record.session_id, code
);
}
None => {
eprintln!("[nono] Session {} has already exited.", record.session_id);
}
}
return Ok(());
}
if !session::is_process_alive(record.supervisor_pid, record.started_epoch) {
return Err(NonoError::ConfigParse(format!(
"Session {} supervisor (PID {}) is no longer running",
record.session_id, record.supervisor_pid
)));
}
eprintln!("[nono] Attaching to session {}...", record.session_id);
if record.status == SessionStatus::Paused {
return Err(NonoError::ConfigParse(format!(
"Session {} is paused/stopped and cannot accept attach",
record.session_id
)));
}
match crate::pty_proxy::attach_to_session(&record.session_id) {
Err(NonoError::AttachBusy) => {
eprintln!(
"[nono] Session {} already has an active attached client.",
record.session_id
);
Ok(())
}
Err(NonoError::SessionGone) => {
eprintln!(
"[nono] Session {} exited before attach could complete.",
record.session_id
);
Ok(())
}
other => other,
}
}
pub fn run_logs(args: &LogsArgs) -> Result<()> {
let record = session::load_session(&args.session)?;
let events_path = session::session_events_path(&record.session_id)?;
if !events_path.exists() {
eprintln!("No event log recorded for session {}.", record.session_id);
return Ok(());
}
if args.follow {
follow_event_log(&events_path, args.tail, args.json)
} else {
let lines = read_event_log_lines(&events_path, args.tail)?;
print_event_log_lines(&lines, args.json)
}
}
pub fn run_inspect(args: &InspectArgs) -> Result<()> {
let record = session::load_session(&args.session)?;
if args.json {
let json = serde_json::to_string_pretty(&record)
.map_err(|e| NonoError::ConfigParse(format!("JSON serialization failed: {e}")))?;
println!("{json}");
return Ok(());
}
println!("Session: {}", record.session_id);
if let Some(ref name) = record.name {
println!("Name: {}", name);
}
println!("Status: {:?}", record.status);
println!("Attached: {:?}", record.attachment);
println!(
"PID: {} (supervisor: {})",
record.child_pid, record.supervisor_pid
);
println!("Started: {}", record.started);
if let Some(code) = record.exit_code {
println!("Exit code: {}", code);
}
println!("Command: {}", format_command_line(&record.command));
if let Some(ref profile) = record.profile {
println!("Profile: {}", profile);
}
println!("Workdir: {}", record.workdir.display());
println!("Network: {}", record.network);
if let Some(ref rollback) = record.rollback_session {
println!("Rollback: {}", rollback);
}
Ok(())
}
pub fn run_prune(args: &PruneArgs) -> Result<()> {
reject_if_sandboxed("prune")?;
let sessions = session::list_sessions()?;
let now = chrono::Utc::now();
let mut to_remove: Vec<&SessionRecord> = Vec::new();
for s in &sessions {
if s.status == SessionStatus::Running {
continue;
}
let should_remove = if let Some(days) = args.older_than {
if let Ok(started) = chrono::DateTime::parse_from_rfc3339(&s.started) {
let age = now.signed_duration_since(started);
age.num_days() >= days as i64
} else {
false
}
} else {
true };
if should_remove {
to_remove.push(s);
}
}
if let Some(keep) = args.keep {
if to_remove.len() > keep {
to_remove = to_remove[keep..].to_vec();
} else {
to_remove.clear();
}
}
if to_remove.is_empty() {
eprintln!("Nothing to prune.");
return Ok(());
}
let dir = session::sessions_dir()?;
for s in &to_remove {
let session_file = dir.join(format!("{}.json", s.session_id));
let events_file = dir.join(format!("{}.events.ndjson", s.session_id));
if args.dry_run {
eprintln!("Would remove: {} (started {})", s.session_id, s.started);
} else {
if let Err(e) = std::fs::remove_file(&session_file) {
debug!(
"Failed to remove session file {}: {}",
session_file.display(),
e
);
}
if events_file.exists() {
if let Err(e) = std::fs::remove_file(&events_file) {
debug!(
"Failed to remove events file {}: {}",
events_file.display(),
e
);
}
}
eprintln!("Removed: {} (started {})", s.session_id, s.started);
}
}
eprintln!(
"\n{} {} session(s).",
if args.dry_run {
"Would prune"
} else {
"Pruned"
},
to_remove.len()
);
Ok(())
}
fn read_event_log_lines(path: &Path, tail: Option<usize>) -> Result<Vec<String>> {
let file = std::fs::File::open(path).map_err(|e| NonoError::ConfigRead {
path: path.to_path_buf(),
source: e,
})?;
let reader = std::io::BufReader::new(file);
if let Some(limit) = tail {
let mut lines = VecDeque::with_capacity(limit.min(256));
for line in reader.lines() {
let line = line.map_err(|e| NonoError::ConfigRead {
path: path.to_path_buf(),
source: e,
})?;
if lines.len() == limit {
let _ = lines.pop_front();
}
lines.push_back(line);
}
Ok(lines.into_iter().collect())
} else {
reader
.lines()
.collect::<std::io::Result<Vec<_>>>()
.map_err(|e| NonoError::ConfigRead {
path: path.to_path_buf(),
source: e,
})
}
}
fn print_event_log_lines(lines: &[String], as_json: bool) -> Result<()> {
if as_json {
let values: Vec<serde_json::Value> = lines
.iter()
.map(|line| {
serde_json::from_str::<serde_json::Value>(line)
.unwrap_or_else(|_| serde_json::Value::String(line.clone()))
})
.collect();
let json = serde_json::to_string_pretty(&values)
.map_err(|e| NonoError::ConfigParse(format!("JSON serialization failed: {e}")))?;
println!("{json}");
} else {
for line in lines {
println!("{line}");
}
}
Ok(())
}
fn follow_event_log(path: &Path, tail: Option<usize>, as_json: bool) -> Result<()> {
let initial_lines = read_event_log_lines(path, tail)?;
if as_json {
for line in &initial_lines {
println!("{line}");
}
} else {
print_event_log_lines(&initial_lines, false)?;
}
let mut file = std::fs::File::open(path).map_err(|e| NonoError::ConfigRead {
path: path.to_path_buf(),
source: e,
})?;
file.seek(SeekFrom::End(0))
.map_err(|e| NonoError::ConfigRead {
path: path.to_path_buf(),
source: e,
})?;
let mut reader = std::io::BufReader::new(file);
loop {
let mut line = String::new();
let bytes = reader
.read_line(&mut line)
.map_err(|e| NonoError::ConfigRead {
path: path.to_path_buf(),
source: e,
})?;
if bytes == 0 {
std::thread::sleep(std::time::Duration::from_millis(250));
continue;
}
print!("{}", line);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_uptime_seconds() {
let now = chrono::Local::now();
let started = (now - chrono::Duration::seconds(30)).to_rfc3339();
let result = format_uptime(&started);
assert!(result.ends_with('s'));
}
#[test]
fn test_format_uptime_minutes() {
let now = chrono::Local::now();
let started = (now - chrono::Duration::minutes(5)).to_rfc3339();
let result = format_uptime(&started);
assert!(result.ends_with('m'));
}
}