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