1use anyhow::{Context, Result};
2use std::fs;
3use std::io::{BufRead, BufReader};
4use std::path::PathBuf;
5use std::process::{Command, Stdio};
6use tracing::{info, warn};
7
8pub struct ManagerPaths {
10 pub pid_file: PathBuf,
11 pub log_file: PathBuf,
12 pub config_dir: PathBuf,
13}
14
15impl Default for ManagerPaths {
16 fn default() -> Self {
17 let config_dir = dirs::config_dir()
18 .unwrap_or_else(|| PathBuf::from("."))
19 .join("codex-memory");
20
21 Self {
22 pid_file: config_dir.join("codex-memory.pid"),
23 log_file: config_dir.join("codex-memory.log"),
24 config_dir,
25 }
26 }
27}
28
29pub struct ServerManager {
31 paths: ManagerPaths,
32}
33
34impl ServerManager {
35 pub fn new() -> Self {
36 let paths = ManagerPaths::default();
37
38 if !paths.config_dir.exists() {
40 fs::create_dir_all(&paths.config_dir).ok();
41 }
42
43 Self { paths }
44 }
45
46 pub fn with_paths(pid_file: Option<String>, log_file: Option<String>) -> Self {
47 let mut paths = ManagerPaths::default();
48
49 if let Some(pid) = pid_file {
50 paths.pid_file = PathBuf::from(pid);
51 }
52 if let Some(log) = log_file {
53 paths.log_file = PathBuf::from(log);
54 }
55
56 Self { paths }
57 }
58
59 pub async fn start_daemon(&self, daemon: bool) -> Result<()> {
61 if let Some(pid) = self.get_running_pid()? {
63 return Err(anyhow::anyhow!("Server already running with PID: {}", pid));
64 }
65
66 info!("Starting Codex Memory server...");
67
68 if daemon {
69 self.start_background_process().await?;
71 } else {
72 info!("Starting server in foreground mode...");
75 self.start_background_process().await?;
76 }
77
78 Ok(())
79 }
80
81 async fn start_background_process(&self) -> Result<()> {
83 let exe = std::env::current_exe()
84 .context("Failed to get current executable path")?;
85
86 let log_file = fs::OpenOptions::new()
87 .create(true)
88 .append(true)
89 .open(&self.paths.log_file)
90 .context("Failed to open log file")?;
91
92 let cmd = Command::new(exe)
93 .arg("start")
94 .arg("--skip-setup")
95 .stdout(Stdio::from(log_file.try_clone()?))
96 .stderr(Stdio::from(log_file))
97 .spawn()
98 .context("Failed to spawn server process")?;
99
100 let pid = cmd.id();
101
102 fs::write(&self.paths.pid_file, pid.to_string())
104 .context("Failed to write PID file")?;
105
106 info!("Server started with PID: {}", pid);
107 info!("Log file: {}", self.paths.log_file.display());
108
109 Ok(())
110 }
111
112 pub async fn stop(&self) -> Result<()> {
114 let pid = self.get_running_pid()?
115 .ok_or_else(|| anyhow::anyhow!("Server is not running"))?;
116
117 info!("Stopping server with PID: {}", pid);
118
119 #[cfg(unix)]
120 {
121 use nix::sys::signal::{self, Signal};
122 use nix::unistd::Pid;
123
124 signal::kill(Pid::from_raw(pid as i32), Signal::SIGTERM)
126 .context("Failed to send SIGTERM")?;
127
128 for _ in 0..10 {
130 if !self.is_process_running(pid)? {
131 break;
132 }
133 tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
134 }
135
136 if self.is_process_running(pid)? {
138 warn!("Process didn't stop gracefully, forcing kill");
139 signal::kill(Pid::from_raw(pid as i32), Signal::SIGKILL)
140 .context("Failed to send SIGKILL")?;
141 }
142 }
143
144 #[cfg(windows)]
145 {
146 let output = Command::new("taskkill")
148 .args(&["/PID", &pid.to_string(), "/F"])
149 .output()
150 .context("Failed to kill process")?;
151
152 if !output.status.success() {
153 return Err(anyhow::anyhow!("Failed to stop server"));
154 }
155 }
156
157 fs::remove_file(&self.paths.pid_file).ok();
159 info!("Server stopped");
160
161 Ok(())
162 }
163
164 pub async fn restart(&self) -> Result<()> {
166 info!("Restarting server...");
167
168 if self.get_running_pid()?.is_some() {
170 self.stop().await?;
171 tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
172 }
173
174 self.start_daemon(true).await?;
176
177 Ok(())
178 }
179
180 pub async fn status(&self, detailed: bool) -> Result<()> {
182 match self.get_running_pid()? {
183 Some(pid) => {
184 println!("● Server is running");
185 println!(" PID: {}", pid);
186
187 if detailed {
188 println!(" PID file: {}", self.paths.pid_file.display());
189 println!(" Log file: {}", self.paths.log_file.display());
190
191 if self.paths.log_file.exists() {
193 println!("\nRecent logs:");
194 self.show_logs(5, false).await?;
195 }
196 }
197 }
198 None => {
199 println!("○ Server is not running");
200
201 if detailed {
202 println!(" PID file: {}", self.paths.pid_file.display());
203 println!(" Log file: {}", self.paths.log_file.display());
204 }
205 }
206 }
207
208 Ok(())
209 }
210
211 pub async fn show_logs(&self, lines: usize, follow: bool) -> Result<()> {
213 if !self.paths.log_file.exists() {
214 return Err(anyhow::anyhow!("Log file not found"));
215 }
216
217 if follow {
218 let file = fs::File::open(&self.paths.log_file)?;
220 let reader = BufReader::new(file);
221
222 println!("Following logs (Ctrl+C to stop)...");
223 for line in reader.lines() {
224 println!("{}", line?);
225 }
226 } else {
227 let content = fs::read_to_string(&self.paths.log_file)?;
229 let all_lines: Vec<&str> = content.lines().collect();
230 let start = if all_lines.len() > lines { all_lines.len() - lines } else { 0 };
231
232 for line in &all_lines[start..] {
233 println!("{}", line);
234 }
235 }
236
237 Ok(())
238 }
239
240 pub async fn install_service(&self, service_type: Option<String>) -> Result<()> {
242 let service_type = service_type.unwrap_or_else(|| {
243 #[cfg(target_os = "linux")]
244 return "systemd".to_string();
245 #[cfg(target_os = "macos")]
246 return "launchd".to_string();
247 #[cfg(target_os = "windows")]
248 return "windows".to_string();
249 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
250 return "none".to_string();
251 });
252
253 match service_type.as_str() {
254 "systemd" => self.install_systemd_service().await,
255 "launchd" => self.install_launchd_service().await,
256 "windows" => self.install_windows_service().await,
257 _ => Err(anyhow::anyhow!("Unsupported service type: {}", service_type)),
258 }
259 }
260
261 async fn install_systemd_service(&self) -> Result<()> {
263 #[cfg(target_os = "linux")]
264 {
265 let service_content = format!(
266 r#"[Unit]
267Description=Codex Memory System
268After=network.target postgresql.service
269
270[Service]
271Type=simple
272ExecStart={} start
273ExecStop={} manager stop
274Restart=on-failure
275RestartSec=10
276StandardOutput=append:{}
277StandardError=append:{}
278
279[Install]
280WantedBy=multi-user.target
281"#,
282 std::env::current_exe()?.display(),
283 std::env::current_exe()?.display(),
284 self.paths.log_file.display(),
285 self.paths.log_file.display(),
286 );
287
288 let service_path = PathBuf::from("/etc/systemd/system/codex-memory.service");
289
290 fs::write(&service_path, service_content)
292 .context("Failed to write service file (need sudo?)")?;
293
294 Command::new("systemctl")
296 .args(&["daemon-reload"])
297 .status()
298 .context("Failed to reload systemd")?;
299
300 Command::new("systemctl")
302 .args(&["enable", "codex-memory.service"])
303 .status()
304 .context("Failed to enable service")?;
305
306 info!("Systemd service installed successfully");
307 info!("Start with: systemctl start codex-memory");
308 Ok(())
309 }
310
311 #[cfg(not(target_os = "linux"))]
312 Err(anyhow::anyhow!("Systemd is only available on Linux"))
313 }
314
315 async fn install_launchd_service(&self) -> Result<()> {
317 #[cfg(target_os = "macos")]
318 {
319 let plist_content = format!(
320 r#"<?xml version="1.0" encoding="UTF-8"?>
321<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
322<plist version="1.0">
323<dict>
324 <key>Label</key>
325 <string>com.codex.memory</string>
326 <key>ProgramArguments</key>
327 <array>
328 <string>{}</string>
329 <string>start</string>
330 </array>
331 <key>StandardOutPath</key>
332 <string>{}</string>
333 <key>StandardErrorPath</key>
334 <string>{}</string>
335 <key>RunAtLoad</key>
336 <false/>
337 <key>KeepAlive</key>
338 <dict>
339 <key>SuccessfulExit</key>
340 <false/>
341 </dict>
342</dict>
343</plist>
344"#,
345 std::env::current_exe()?.display(),
346 self.paths.log_file.display(),
347 self.paths.log_file.display(),
348 );
349
350 let plist_path = dirs::home_dir()
351 .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?
352 .join("Library/LaunchAgents/com.codex.memory.plist");
353
354 fs::write(&plist_path, plist_content)
356 .context("Failed to write plist file")?;
357
358 Command::new("launchctl")
360 .args(&["load", plist_path.to_str().unwrap()])
361 .status()
362 .context("Failed to load launchd service")?;
363
364 info!("Launchd service installed successfully");
365 info!("Start with: launchctl start com.codex.memory");
366 Ok(())
367 }
368
369 #[cfg(not(target_os = "macos"))]
370 Err(anyhow::anyhow!("Launchd is only available on macOS"))
371 }
372
373 async fn install_windows_service(&self) -> Result<()> {
375 #[cfg(target_os = "windows")]
376 {
377 let exe_path = std::env::current_exe()?;
379
380 Command::new("sc")
381 .args(&[
382 "create",
383 "CodexMemory",
384 &format!("binPath= \"{}\" start", exe_path.display()),
385 "DisplayName= \"Codex Memory System\"",
386 "start= auto",
387 ])
388 .status()
389 .context("Failed to create Windows service")?;
390
391 info!("Windows service installed successfully");
392 info!("Start with: sc start CodexMemory");
393 Ok(())
394 }
395
396 #[cfg(not(target_os = "windows"))]
397 Err(anyhow::anyhow!("Windows service is only available on Windows"))
398 }
399
400 pub async fn uninstall_service(&self) -> Result<()> {
402 #[cfg(target_os = "linux")]
403 {
404 Command::new("systemctl")
405 .args(&["disable", "codex-memory.service"])
406 .status()?;
407
408 fs::remove_file("/etc/systemd/system/codex-memory.service")?;
409
410 Command::new("systemctl")
411 .args(&["daemon-reload"])
412 .status()?;
413
414 info!("Systemd service uninstalled");
415 }
416
417 #[cfg(target_os = "macos")]
418 {
419 let plist_path = dirs::home_dir()
420 .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?
421 .join("Library/LaunchAgents/com.codex.memory.plist");
422
423 Command::new("launchctl")
424 .args(&["unload", plist_path.to_str().unwrap()])
425 .status()?;
426
427 fs::remove_file(plist_path)?;
428
429 info!("Launchd service uninstalled");
430 }
431
432 #[cfg(target_os = "windows")]
433 {
434 Command::new("sc")
435 .args(&["delete", "CodexMemory"])
436 .status()?;
437
438 info!("Windows service uninstalled");
439 }
440
441 Ok(())
442 }
443
444 fn get_running_pid(&self) -> Result<Option<u32>> {
446 if !self.paths.pid_file.exists() {
447 return Ok(None);
448 }
449
450 let pid_str = fs::read_to_string(&self.paths.pid_file)?;
451 let pid: u32 = pid_str.trim().parse()
452 .context("Invalid PID in file")?;
453
454 if self.is_process_running(pid)? {
456 Ok(Some(pid))
457 } else {
458 fs::remove_file(&self.paths.pid_file).ok();
460 Ok(None)
461 }
462 }
463
464 fn is_process_running(&self, pid: u32) -> Result<bool> {
466 #[cfg(unix)]
467 {
468 use nix::sys::signal::{self, Signal};
469 use nix::unistd::Pid;
470
471 match signal::kill(Pid::from_raw(pid as i32), Signal::SIGCONT) {
473 Ok(_) => Ok(true),
474 Err(nix::errno::Errno::ESRCH) => Ok(false),
475 Err(e) => Err(anyhow::anyhow!("Failed to check process: {}", e)),
476 }
477 }
478
479 #[cfg(windows)]
480 {
481 use std::process::Command;
482
483 let output = Command::new("tasklist")
484 .args(&["/FI", &format!("PID eq {}", pid)])
485 .output()?;
486
487 Ok(String::from_utf8_lossy(&output.stdout).contains(&pid.to_string()))
488 }
489 }
490}