A lightweight CLI tool that connects to a remote server over SSH and executes PM2 process manager commands.
1use std::{io::Read, net::TcpStream, path::Path};
2
3use anyhow::Error;
4use base64::{Engine, engine::general_purpose};
5use log::info;
6use owo_colors::OwoColorize;
7use regex::Regex;
8use ssh2::Session;
9
10pub fn run_pm2_command(
11 host: &str,
12 port: u32,
13 key: &str,
14 cmd: &str,
15 args: Vec<&String>,
16) -> Result<(), Error> {
17 let parts: Vec<&str> = host.split('@').collect();
18 if parts.len() != 2 {
19 return Err(Error::msg("Host must be in the format user@host"));
20 }
21 let username = parts[0];
22
23 let host = parts[1];
24
25 let ssh_url = format!("{}@{}:{}", username, host, port);
26 info!(
27 "Connecting to {} using key {} ...",
28 ssh_url.bright_cyan(),
29 key.bright_cyan()
30 );
31
32 let tcp = TcpStream::connect(format!("{}:{}", host, port))?;
33 let mut session = Session::new()?;
34 session.set_tcp_stream(tcp);
35 session.handshake()?;
36
37 let private_key_path = shellexpand::tilde(key).to_string();
38 let private_key = Path::new(&private_key_path);
39 session.userauth_pubkey_file(username, None, private_key, None)?;
40 if !session.authenticated() {
41 return Err(Error::msg("SSH authentication failed"));
42 }
43
44 info!("Connected to {} successfully!", ssh_url.bright_cyan());
45
46 let mut channel = session.channel_session()?;
47 let command = format!(
48 "NO_NEOFETCH=1 bash -lic 'pm2 {} {}'",
49 cmd,
50 args.iter()
51 .map(|s| s.as_str())
52 .collect::<Vec<&str>>()
53 .join(" ")
54 );
55
56 info!("Executing command: {}", command.bright_yellow());
57
58 channel.request_pty("xterm", None, None)?;
59
60 setup_pm2(&session)?;
61
62 channel.exec(&command)?;
63
64 let mut buffer = [0; 1024];
65
66 // Stream stdout in real-time
67 loop {
68 match channel.read(&mut buffer) {
69 Ok(0) => break, // EOF, command finished
70 Ok(n) => {
71 let output = String::from_utf8_lossy(&buffer[..n]);
72 let clean_output = clean_terminal_noises(&output);
73 print!("{}", clean_output); // Real-time output
74 }
75 Err(e) => {
76 eprintln!("Error reading from channel: {}", e);
77 break;
78 }
79 }
80 }
81
82 channel.wait_close()?;
83 info!("Exit status: {}", channel.exit_status()?);
84
85 Ok(())
86}
87
88pub fn setup_pm2(session: &Session) -> Result<(), Error> {
89 let mut channel = session.channel_session()?;
90 channel.request_pty("xterm", None, None)?;
91
92 info!("Setting up PM2 on the remote server...");
93
94 let install_script = r#"#!/bin/bash
95 set -e
96
97 if ! command -v pm2 &> /dev/null; then
98 echo "PM2 not found, installing..."
99 curl https://mise.run | sh
100 export PATH=$HOME/.local/bin:$PATH
101 export PATH=$HOME/.local/share/mise/shims:$PATH
102 echo 'export PATH=$HOME/.local/bin:$PATH' >> ~/.bashrc
103 echo 'export PATH=$HOME/.local/share/mise/shims:$PATH' >> ~/.bashrc
104 mise install node
105 mise use -g node
106 npm install -g pm2
107 fi
108 "#;
109
110 let encoded_script = general_purpose::STANDARD.encode(install_script);
111
112 let remote_command = format!(
113 "echo {} | base64 -d > /tmp/install-pm2.sh && chmod +x /tmp/install-pm2.sh && /tmp/install-pm2.sh",
114 encoded_script
115 );
116
117 channel.exec(&format!("NO_NEOFETCH=1 bash -lic '{}'", remote_command))?;
118
119 let mut buffer = [0; 1024];
120 loop {
121 match channel.read(&mut buffer) {
122 Ok(0) => break, // EOF, script finished
123 Ok(n) => {
124 let output = String::from_utf8_lossy(&buffer[..n]);
125 let clean_output = clean_terminal_noises(&output);
126 print!("{}", clean_output); // Real-time output
127 }
128 Err(e) => {
129 eprintln!("Error reading from channel: {}", e);
130 break;
131 }
132 }
133 }
134
135 info!("PM2 setup completed successfully!");
136
137 Ok(())
138}
139
140fn clean_terminal_noises(s: &str) -> String {
141 let osc_re = Regex::new(r"\x1b\][^\x07\x1b]*(\x07|\x1b\\)").unwrap();
142 let csi_dsr_re = Regex::new(r"\x1b\[\d+;\d+R").unwrap();
143 let malformed_csi_re = Regex::new(r"\[{1,2}\d{1,3}(;\d{1,3})?R").unwrap();
144 let cleaned = osc_re.replace_all(s, "");
145 let cleaned = csi_dsr_re.replace_all(&cleaned, "");
146 let cleaned = malformed_csi_re.replace_all(&cleaned, "");
147 cleaned.into_owned()
148}