1use crate::cmd::SyncCommandErrorContext;
2#[cfg(any(test, not(target_os = "macos")))]
3use anyhow::Context;
4#[cfg(any(test, not(target_os = "macos")))]
5use std::path::{Path, PathBuf};
6
7fn service(action: &str) -> anyhow::Result<()> {
9 let (program, args) = if cfg!(target_os = "macos") {
10 ("brew", vec!["services", action, "orch"])
11 } else {
12 let action = if action == "info" { "status" } else { action };
14 ("systemctl", vec!["--user", action, "orch"])
15 };
16
17 let status = std::process::Command::new(program)
18 .args(&args)
19 .status_with_context()?;
20
21 if !status.success() {
22 anyhow::bail!("{program} {} failed", args.join(" "));
23 }
24
25 Ok(())
26}
27
28pub fn start() -> anyhow::Result<()> {
30 service("start")
31}
32
33pub fn stop() -> anyhow::Result<()> {
35 service("stop")
36}
37
38pub fn restart() -> anyhow::Result<()> {
40 service("restart")
41}
42
43pub fn status() -> anyhow::Result<()> {
45 service("info")
46}
47
48pub fn install(force: bool) -> anyhow::Result<()> {
50 #[cfg(target_os = "macos")]
51 {
52 let _ = force;
53 macos_service_owned_by_homebrew();
54 Ok(())
55 }
56 #[cfg(not(target_os = "macos"))]
57 {
58 linux_install(
59 &systemd_user_dir()?,
60 ¤t_binary()?,
61 Path::new("systemctl"),
62 force,
63 )
64 }
65}
66
67pub fn uninstall() -> anyhow::Result<()> {
69 #[cfg(target_os = "macos")]
70 {
71 macos_service_owned_by_homebrew();
72 Ok(())
73 }
74 #[cfg(not(target_os = "macos"))]
75 {
76 linux_uninstall(&systemd_user_dir()?, Path::new("systemctl"))
77 }
78}
79
80#[cfg(any(test, target_os = "macos"))]
81fn macos_service_owned_by_homebrew() {
82 println!("The Homebrew formula already installs the orch service.");
83}
84
85#[cfg(test)]
86fn install_os(
87 macos: bool,
88 unit_dir: &Path,
89 binary: &Path,
90 systemctl: &Path,
91 force: bool,
92) -> anyhow::Result<()> {
93 if macos {
94 macos_service_owned_by_homebrew();
95 return Ok(());
96 }
97 linux_install(unit_dir, binary, systemctl, force)
98}
99
100#[cfg(test)]
101fn uninstall_os(macos: bool, unit_dir: &Path, systemctl: &Path) -> anyhow::Result<()> {
102 if macos {
103 macos_service_owned_by_homebrew();
104 return Ok(());
105 }
106 linux_uninstall(unit_dir, systemctl)
107}
108
109#[cfg(any(test, not(target_os = "macos")))]
110fn linux_install(
111 unit_dir: &Path,
112 binary: &Path,
113 systemctl: &Path,
114 force: bool,
115) -> anyhow::Result<()> {
116 let unit_path = unit_dir.join("orch.service");
117 let unit = unit_contents(binary);
118 if unit_path.exists() && !force {
119 let existing = std::fs::read_to_string(&unit_path)
120 .with_context(|| format!("reading {}", unit_path.display()))?;
121 if existing != unit {
122 print_unit_diff(&existing, &unit);
123 }
124 anyhow::bail!(
125 "unit already exists at {}; pass --force to overwrite",
126 unit_path.display()
127 );
128 }
129 std::fs::create_dir_all(unit_dir)
130 .with_context(|| format!("creating {}", unit_dir.display()))?;
131 std::fs::write(&unit_path, &unit)
132 .with_context(|| format!("writing {}", unit_path.display()))?;
133 println!("Wrote {}", unit_path.display());
134 run_systemctl(systemctl, &["daemon-reload"])?;
135 run_systemctl(systemctl, &["enable", "--now", "orch"])?;
136 println!("Enabled and started the orch user service.");
137 println!("A user service stops at logout unless lingering is on.");
138 println!(" loginctl enable-linger $USER");
139 Ok(())
140}
141
142#[cfg(any(test, not(target_os = "macos")))]
143fn linux_uninstall(unit_dir: &Path, systemctl: &Path) -> anyhow::Result<()> {
144 let unit_path = unit_dir.join("orch.service");
145 if !unit_path.exists() {
146 let _ = run_systemctl(systemctl, &["disable", "--now", "orch"]);
147 println!("No orch user unit at {}", unit_path.display());
148 return Ok(());
149 }
150 run_systemctl(systemctl, &["disable", "--now", "orch"])?;
151 std::fs::remove_file(&unit_path)
152 .with_context(|| format!("removing {}", unit_path.display()))?;
153 println!("Removed {}", unit_path.display());
154 run_systemctl(systemctl, &["daemon-reload"])?;
155 Ok(())
156}
157
158#[cfg(any(test, not(target_os = "macos")))]
159fn unit_contents(binary: &Path) -> String {
160 format!(
161 "[Unit]\n\
162 Description=orch agent orchestrator\n\
163 After=network-online.target\n\
164 \n\
165 [Service]\n\
166 ExecStart={} serve\n\
167 Restart=always\n\
168 RestartSec=5\n\
169 \n\
170 [Install]\n\
171 WantedBy=default.target\n",
172 exec_start_bin(binary)
173 )
174}
175
176#[cfg(any(test, not(target_os = "macos")))]
177fn exec_start_bin(binary: &Path) -> String {
178 let path = binary.display().to_string();
179 if path.chars().any(char::is_whitespace) {
180 format!("\"{path}\"")
181 } else {
182 path
183 }
184}
185
186#[cfg(any(test, not(target_os = "macos")))]
187fn print_unit_diff(existing: &str, proposed: &str) {
188 println!("--- existing");
189 println!("+++ proposed");
190 let old: Vec<&str> = existing.lines().collect();
191 let new: Vec<&str> = proposed.lines().collect();
192 let len = old.len().max(new.len());
193 for i in 0..len {
194 match (old.get(i), new.get(i)) {
195 (Some(a), Some(b)) if a == b => println!(" {a}"),
196 (Some(a), Some(b)) => {
197 println!("-{a}");
198 println!("+{b}");
199 }
200 (Some(a), None) => println!("-{a}"),
201 (None, Some(b)) => println!("+{b}"),
202 (None, None) => {}
203 }
204 }
205}
206
207#[cfg(any(test, not(target_os = "macos")))]
208fn run_systemctl(systemctl: &Path, args: &[&str]) -> anyhow::Result<()> {
209 let status = std::process::Command::new(systemctl)
210 .arg("--user")
211 .args(args)
212 .status_with_context()?;
213 if !status.success() {
214 anyhow::bail!("{} --user {} failed", systemctl.display(), args.join(" "));
215 }
216 Ok(())
217}
218
219#[cfg(not(target_os = "macos"))]
220fn systemd_user_dir() -> anyhow::Result<PathBuf> {
221 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
222 if !xdg.is_empty() {
223 return Ok(PathBuf::from(xdg).join("systemd").join("user"));
224 }
225 }
226 let home = dirs::home_dir().context("cannot determine home directory")?;
227 Ok(home.join(".config").join("systemd").join("user"))
228}
229
230#[cfg(not(target_os = "macos"))]
231fn current_binary() -> anyhow::Result<PathBuf> {
232 let exe = std::env::current_exe().context("cannot resolve the orch binary path")?;
233 Ok(exe.canonicalize().unwrap_or(exe))
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use std::fs;
240 use std::os::unix::fs::PermissionsExt;
241
242 fn stub_systemctl(dir: &Path) -> PathBuf {
243 let stub = dir.join("systemctl");
244 let log = dir.join("systemctl.log");
245 fs::write(
246 &stub,
247 format!(
248 "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"{}\"\nexit 0\n",
249 log.display()
250 ),
251 )
252 .unwrap();
253 fs::set_permissions(&stub, fs::Permissions::from_mode(0o755)).unwrap();
254 stub
255 }
256
257 fn setup() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) {
258 let root = tempfile::tempdir().unwrap();
259 let unit_dir = root.path().join("user");
260 fs::create_dir_all(&unit_dir).unwrap();
261 let stub = stub_systemctl(root.path());
262 let binary = PathBuf::from("/opt/orch/bin/orch");
263 (root, unit_dir, binary, stub)
264 }
265
266 fn unit_path(unit_dir: &Path) -> PathBuf {
267 unit_dir.join("orch.service")
268 }
269
270 #[test]
271 fn install_writes_unit_with_binary_path_and_serve() {
272 let (root, unit_dir, binary, stub) = setup();
273 install_os(false, &unit_dir, &binary, &stub, false).unwrap();
274 let unit = fs::read_to_string(unit_path(&unit_dir)).unwrap();
275 assert!(
276 unit.contains("ExecStart=/opt/orch/bin/orch serve"),
277 "{unit}"
278 );
279 assert!(unit.contains("Restart=always"), "{unit}");
280 assert!(unit.contains("WantedBy=default.target"), "{unit}");
281 assert!(!unit.contains("Environment"), "{unit}");
282 let log = fs::read_to_string(root.path().join("systemctl.log")).unwrap();
283 assert!(log.contains("daemon-reload"), "{log}");
284 assert!(log.contains("enable --now orch"), "{log}");
285 }
286
287 #[test]
288 fn second_install_without_force_refuses_and_leaves_file() {
289 let (_root, unit_dir, binary, stub) = setup();
290 install_os(false, &unit_dir, &binary, &stub, false).unwrap();
291 let path = unit_path(&unit_dir);
292 let original = fs::read_to_string(&path).unwrap();
293 fs::write(&path, original.replace("RestartSec=5", "RestartSec=99")).unwrap();
294 let changed = fs::read_to_string(&path).unwrap();
295 let err = install_os(false, &unit_dir, &binary, &stub, false).unwrap_err();
296 assert!(
297 err.to_string().contains("--force"),
298 "error should mention --force: {err}"
299 );
300 assert_eq!(fs::read_to_string(&path).unwrap(), changed);
301 }
302
303 #[test]
304 fn uninstall_removes_the_unit() {
305 let (_root, unit_dir, binary, stub) = setup();
306 install_os(false, &unit_dir, &binary, &stub, false).unwrap();
307 assert!(unit_path(&unit_dir).exists());
308 uninstall_os(false, &unit_dir, &stub).unwrap();
309 assert!(!unit_path(&unit_dir).exists());
310 }
311
312 #[test]
313 fn macos_install_changes_nothing() {
314 let (_root, unit_dir, binary, stub) = setup();
315 #[cfg(target_os = "macos")]
316 {
317 install(false).unwrap();
318 uninstall().unwrap();
319 }
320 install_os(true, &unit_dir, &binary, &stub, false).unwrap();
321 install_os(true, &unit_dir, &binary, &stub, true).unwrap();
322 assert!(!unit_path(&unit_dir).exists());
323 assert!(unit_dir.read_dir().unwrap().next().is_none());
324 }
325}