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