use crate::config::global_xbp_paths;
use colored::Colorize;
use serde::Deserialize;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use tokio::process::Command;
use tracing::{debug, info};
async fn check_pm2_installed() -> Result<(), String> {
let check_result = if cfg!(target_os = "windows") {
Command::new("powershell")
.arg("-Command")
.arg("pm2 --version")
.output()
.await
} else {
Command::new("pm2").arg("--version").output().await
};
match check_result {
Ok(output) if output.status.success() => Ok(()),
_ => {
eprintln!("PM2 is not installed.");
eprintln!("");
eprintln!("To install PM2:");
eprintln!(" npm install -g pm2");
eprintln!("");
info!("{}", "PM2 is not installed.".red());
info!("");
info!("{}", "To install PM2:".bright_blue());
info!(" {}", "npm install -g pm2".cyan());
info!("");
Err("PM2 not found".to_string())
}
}
}
pub async fn list(debug: bool) -> Result<(), String> {
check_pm2_installed().await?;
run_pm2_command(&["list"], debug, true).await
}
pub async fn logs(project: Option<String>, debug: bool) -> Result<(), String> {
let mut args = vec!["logs".to_string()];
if let Some(name) = project {
args.push(name);
}
let arg_refs = args.iter().map(|arg| arg.as_str()).collect::<Vec<_>>();
run_pm2_command(&arg_refs, debug, true).await
}
pub async fn stop(name: &str, debug: bool) -> Result<(), String> {
let _ = run_pm2_command(&["stop", name], debug, false).await?;
Ok(())
}
pub async fn delete(name: &str, debug: bool) -> Result<(), String> {
let _ = run_pm2_command(&["delete", name], debug, false).await?;
Ok(())
}
pub async fn start(
name: &str,
command: &str,
log_dir: Option<&PathBuf>,
envs: Option<&HashMap<String, String>>,
debug: bool,
) -> Result<(), String> {
let mut cmd = Command::new("pm2");
cmd.arg("start");
if let Some(log_path) = log_dir {
let stdout_log = log_path.join(format!("{}-stdout.log", name));
let stderr_log = log_path.join(format!("{}-stderr.log", name));
if let Some(parent) = log_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create log directory: {}", e))?;
}
cmd.arg("--name").arg(name);
cmd.arg("--log").arg(stdout_log.to_string_lossy().as_ref());
cmd.arg("--error")
.arg(stderr_log.to_string_lossy().as_ref());
cmd.arg("--").arg(command);
} else {
cmd.arg("--name").arg(name);
cmd.arg("--").arg(command);
}
if let Some(envs) = envs {
cmd.envs(envs);
}
let output = cmd
.output()
.await
.map_err(|e| format!("failed to run pm2 start: {}", e))?;
if debug {
debug!(
"pm2 start {} status={:?} stdout='{}' stderr='{}'",
name,
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
if !output.status.success() {
return Err(format!(
"pm2 start failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Ok(())
}
pub async fn cleanup(debug: bool) -> Result<(), String> {
let output = run_pm2_capture(&["list", "--no-color"], debug).await?;
if !output.status.success() {
return Err(format!(
"Failed to list PM2 processes: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut processes_to_delete = Vec::new();
for line in stdout.lines() {
if line.contains("stopped") || line.contains("errored") {
let parts: Vec<&str> = line.split('│').collect();
if parts.len() >= 3 {
let name = parts[2].trim();
if !name.is_empty() && name != "name" {
processes_to_delete.push(name.to_string());
}
}
}
}
for process_name in processes_to_delete {
if debug {
debug!("Deleting stopped/errored process: {}", process_name);
}
delete(&process_name, debug).await?;
}
let save_output = run_pm2_capture(&["save"], debug).await?;
if !save_output.status.success() {
return Err(format!(
"Failed to save PM2 process list: {}",
String::from_utf8_lossy(&save_output.stderr)
));
}
Ok(())
}
pub async fn save(debug: bool) -> Result<(), String> {
let output = run_pm2_capture(&["save"], debug).await?;
if debug {
debug!(
"pm2 save status={:?} stdout='{}' stderr='{}'",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
if !output.status.success() {
return Err(format!(
"Failed to save PM2 process list: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Ok(())
}
pub async fn snapshot(debug: bool) -> Result<PathBuf, String> {
save(debug).await?;
let prettylist_output = run_pm2_capture(&["jlist"], debug).await?;
let processes: Value = serde_json::from_slice(&prettylist_output.stdout)
.map_err(|e| format!("Failed to parse pm2 jlist output: {}", e))?;
let snapshot_root = pm2_snapshot_root()?;
fs::create_dir_all(&snapshot_root)
.map_err(|e| format!("Failed to create snapshot directory: {}", e))?;
let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string();
let snapshot_dir = snapshot_root.join(×tamp);
fs::create_dir_all(&snapshot_dir)
.map_err(|e| format!("Failed to create snapshot directory: {}", e))?;
let snapshot_json_path = snapshot_dir.join("pm2-jlist.json");
fs::write(
&snapshot_json_path,
serde_json::to_string_pretty(&processes)
.map_err(|e| format!("Failed to serialize PM2 snapshot: {}", e))?,
)
.map_err(|e| format!("Failed to write PM2 snapshot: {}", e))?;
if let Ok(pretty_output) = run_pm2_capture(&["prettylist"], debug).await {
let prettylist_path = snapshot_dir.join("pm2-prettylist.txt");
let _ = fs::write(prettylist_path, pretty_output.stdout);
}
let dump_path = pm2_dump_path();
if dump_path.exists() {
let snapshot_dump = snapshot_dir.join("dump.pm2");
fs::copy(&dump_path, &snapshot_dump)
.map_err(|e| format!("Failed to copy PM2 dump file: {}", e))?;
}
let latest_dir = snapshot_root.join("latest");
if latest_dir.exists() {
let _ = fs::remove_dir_all(&latest_dir);
}
copy_dir_contents(&snapshot_dir, &latest_dir)?;
let metadata_path = snapshot_dir.join("snapshot.json");
fs::write(
&metadata_path,
serde_json::to_string_pretty(&json!({
"created_at": chrono::Local::now().to_rfc3339(),
"pm2_home": pm2_home_dir().display().to_string(),
"process_count": processes.as_array().map(|items| items.len()).unwrap_or(0),
"files": {
"jlist": snapshot_json_path.file_name().and_then(|n| n.to_str()).unwrap_or("pm2-jlist.json"),
"dump": if snapshot_dir.join("dump.pm2").exists() { Some("dump.pm2") } else { None }
}
}))
.map_err(|e| format!("Failed to serialize PM2 snapshot metadata: {}", e))?,
)
.map_err(|e| format!("Failed to write PM2 snapshot metadata: {}", e))?;
Ok(snapshot_dir)
}
pub async fn resurrect(debug: bool) -> Result<(), String> {
match run_pm2_command(&["resurrect"], debug, true).await {
Ok(()) => Ok(()),
Err(primary_err) => {
if let Err(restore_err) = restore_latest_snapshot_dump(debug).await {
return Err(format!(
"pm2 resurrect failed: {}. Snapshot restore also failed: {}",
primary_err, restore_err
));
}
run_pm2_command(&["resurrect"], debug, true)
.await
.map_err(|retry_err| {
format!(
"pm2 resurrect failed: {}. Restored latest snapshot dump but retry failed: {}",
primary_err, retry_err
)
})
}
}
}
pub async fn flush(target: Option<&str>, debug: bool) -> Result<(), String> {
let mut args = vec!["flush".to_string()];
if let Some(target) = target {
args.push(target.to_string());
}
let arg_refs = args.iter().map(|arg| arg.as_str()).collect::<Vec<_>>();
run_pm2_command(&arg_refs, debug, true).await
}
pub async fn monitor(debug: bool) -> Result<(), String> {
run_pm2_command(&["monitor"], debug, true).await
}
pub async fn env(target: &str, debug: bool) -> Result<(), String> {
let resolved_id = resolve_process_id(target, debug).await?;
run_pm2_command(&["env", &resolved_id], debug, true).await
}
async fn resolve_process_id(target: &str, debug: bool) -> Result<String, String> {
if target.chars().all(|c| c.is_ascii_digit()) {
return Ok(target.to_string());
}
let output = run_pm2_capture(&["jlist"], debug).await?;
let processes: Vec<Pm2Process> = serde_json::from_slice(&output.stdout)
.map_err(|e| format!("Failed to parse pm2 jlist output: {}", e))?;
resolve_process_id_from_list(target, &processes)
}
async fn run_pm2_capture(args: &[&str], debug: bool) -> Result<std::process::Output, String> {
check_pm2_installed().await?;
let mut cmd = Command::new("pm2");
cmd.args(args);
let output = cmd
.output()
.await
.map_err(|e| format!("failed to run pm2 {}: {}", args.join(" "), e))?;
if debug {
debug!(
"pm2 {} status={:?} stdout='{}' stderr='{}'",
args.join(" "),
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
if !output.status.success() {
return Err(format!(
"pm2 {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&output.stderr)
));
}
Ok(output)
}
async fn run_pm2_command(args: &[&str], debug: bool, inherit_io: bool) -> Result<(), String> {
check_pm2_installed().await?;
let mut cmd = Command::new("pm2");
cmd.args(args);
if debug {
debug!("running pm2 {}", args.join(" "));
}
if inherit_io {
let status = cmd
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.status()
.await
.map_err(|e| format!("failed to run pm2 {}: {}", args.join(" "), e))?;
if !status.success() {
return Err(format!(
"pm2 {} exited with status {}",
args.join(" "),
status
));
}
return Ok(());
}
let output = run_pm2_capture(args, debug).await?;
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
print!("{}", stdout);
}
Ok(())
}
#[derive(Debug, Deserialize)]
struct Pm2Process {
name: String,
pm_id: i64,
}
fn resolve_process_id_from_list(target: &str, processes: &[Pm2Process]) -> Result<String, String> {
let process = processes
.iter()
.find(|process| process.name == target)
.ok_or_else(|| format!("PM2 process '{}' not found", target))?;
Ok(process.pm_id.to_string())
}
fn pm2_snapshot_root() -> Result<PathBuf, String> {
let paths = global_xbp_paths()?;
Ok(paths.root_dir.join("snapshots").join("pm2"))
}
fn pm2_home_dir() -> PathBuf {
pm2_home_dir_from(
std::env::var_os("PM2_HOME").map(PathBuf::from),
dirs::home_dir(),
)
}
fn pm2_dump_path() -> PathBuf {
pm2_home_dir().join("dump.pm2")
}
fn pm2_home_dir_from(pm2_home: Option<PathBuf>, home_dir: Option<PathBuf>) -> PathBuf {
pm2_home
.or_else(|| home_dir.map(|home| home.join(".pm2")))
.unwrap_or_else(|| PathBuf::from(".pm2"))
}
async fn restore_latest_snapshot_dump(debug: bool) -> Result<(), String> {
let latest_dir = pm2_snapshot_root()?.join("latest");
let latest_dump = latest_dir.join("dump.pm2");
if !latest_dump.exists() {
return Err("No PM2 snapshot dump found at the latest snapshot location".to_string());
}
let target_dump = pm2_dump_path();
if let Some(parent) = target_dump.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create PM2 home directory: {}", e))?;
}
fs::copy(&latest_dump, &target_dump)
.map_err(|e| format!("Failed to restore PM2 dump from snapshot: {}", e))?;
if debug {
debug!(
"restored PM2 dump from {} to {}",
latest_dump.display(),
target_dump.display()
);
}
Ok(())
}
fn copy_dir_contents(source: &Path, destination: &Path) -> Result<(), String> {
fs::create_dir_all(destination)
.map_err(|e| format!("Failed to create destination snapshot directory: {}", e))?;
for entry in fs::read_dir(source).map_err(|e| format!("Failed to read snapshot dir: {}", e))? {
let entry = entry.map_err(|e| format!("Failed to read snapshot entry: {}", e))?;
let source_path = entry.path();
let destination_path = destination.join(entry.file_name());
if source_path.is_dir() {
copy_dir_contents(&source_path, &destination_path)?;
} else {
fs::copy(&source_path, &destination_path)
.map_err(|e| format!("Failed to copy snapshot file: {}", e))?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{copy_dir_contents, pm2_home_dir_from, resolve_process_id_from_list, Pm2Process};
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let dir = std::env::temp_dir().join(format!("xbp-pm2-{}-{}", label, nanos));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
#[test]
fn resolves_pm2_name_to_id() {
let processes = vec![
Pm2Process {
name: "api".to_string(),
pm_id: 4,
},
Pm2Process {
name: "worker".to_string(),
pm_id: 7,
},
];
let resolved =
resolve_process_id_from_list("worker", &processes).expect("worker should resolve");
assert_eq!(resolved, "7");
}
#[test]
fn errors_when_pm2_name_is_missing() {
let processes = vec![Pm2Process {
name: "api".to_string(),
pm_id: 4,
}];
let error =
resolve_process_id_from_list("missing", &processes).expect_err("missing should fail");
assert!(error.contains("PM2 process 'missing' not found"));
}
#[test]
fn pm2_home_dir_prefers_pm2_home_override() {
let expected = if cfg!(windows) {
PathBuf::from(r"C:\tmp\pm2-home")
} else {
PathBuf::from("/tmp/pm2-home")
};
assert_eq!(
pm2_home_dir_from(Some(expected.clone()), Some(PathBuf::from("/unused-home"))),
expected
);
}
#[test]
fn pm2_home_dir_falls_back_to_home_dot_pm2() {
let home = if cfg!(windows) {
PathBuf::from(r"C:\Users\floris")
} else {
PathBuf::from("/home/floris")
};
assert_eq!(
pm2_home_dir_from(None, Some(home.clone())),
home.join(".pm2")
);
}
#[test]
fn copy_dir_contents_copies_nested_files() {
let source = temp_dir("source");
let destination = temp_dir("destination");
let nested = source.join("nested");
fs::create_dir_all(&nested).expect("create nested");
fs::write(source.join("dump.pm2"), "pm2").expect("write top-level file");
fs::write(nested.join("processes.json"), "{}").expect("write nested file");
copy_dir_contents(&source, &destination).expect("copy");
assert_eq!(
fs::read_to_string(destination.join("dump.pm2")).expect("read copied top-level file"),
"pm2"
);
assert_eq!(
fs::read_to_string(destination.join("nested").join("processes.json"))
.expect("read copied nested file"),
"{}"
);
let _ = fs::remove_dir_all(source);
let _ = fs::remove_dir_all(destination);
}
}