1use crate::cmd::SyncCommandErrorContext;
2#[cfg(any(test, not(target_os = "macos")))]
3use anyhow::Context;
4#[cfg(not(target_os = "macos"))]
5use std::env;
6#[cfg(any(test, not(target_os = "macos")))]
7use std::fs;
8#[cfg(any(test, not(target_os = "macos")))]
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12fn service(action: &str) -> anyhow::Result<()> {
14 let (program, args) = if cfg!(target_os = "macos") {
15 ("brew", vec!["services", action, "orch"])
16 } else {
17 let action = if action == "info" { "status" } else { action };
19 ("systemctl", vec!["--user", action, "orch"])
20 };
21
22 let status = Command::new(program).args(&args).status_with_context()?;
23
24 if !status.success() {
25 anyhow::bail!("{program} {} failed", args.join(" "));
26 }
27
28 Ok(())
29}
30
31pub fn start() -> anyhow::Result<()> {
33 service("start")
34}
35
36pub fn stop() -> anyhow::Result<()> {
38 service("stop")
39}
40
41pub fn restart() -> anyhow::Result<()> {
43 service("restart")
44}
45
46pub fn status() -> anyhow::Result<()> {
48 service("info")
49}
50
51pub fn install(force: bool) -> anyhow::Result<()> {
53 #[cfg(target_os = "macos")]
54 {
55 let _ = force;
56 macos_service_owned_by_homebrew();
57 Ok(())
58 }
59 #[cfg(not(target_os = "macos"))]
60 {
61 linux_install(
62 &systemd_user_dir()?,
63 ¤t_binary()?,
64 Path::new("systemctl"),
65 force,
66 )
67 }
68}
69
70pub fn uninstall() -> anyhow::Result<()> {
72 #[cfg(target_os = "macos")]
73 {
74 macos_service_owned_by_homebrew();
75 Ok(())
76 }
77 #[cfg(not(target_os = "macos"))]
78 {
79 linux_uninstall(&systemd_user_dir()?, Path::new("systemctl"))
80 }
81}
82
83#[cfg(any(test, target_os = "macos"))]
84fn macos_service_owned_by_homebrew() {
85 println!("The Homebrew formula already installs the orch service.");
86}
87
88#[cfg(test)]
89fn install_os(
90 macos: bool,
91 unit_dir: &Path,
92 binary: &Path,
93 systemctl: &Path,
94 force: bool,
95) -> anyhow::Result<()> {
96 if macos {
97 macos_service_owned_by_homebrew();
98 return Ok(());
99 }
100 linux_install(unit_dir, binary, systemctl, force)
101}
102
103#[cfg(test)]
104fn uninstall_os(macos: bool, unit_dir: &Path, systemctl: &Path) -> anyhow::Result<()> {
105 if macos {
106 macos_service_owned_by_homebrew();
107 return Ok(());
108 }
109 linux_uninstall(unit_dir, systemctl)
110}
111
112#[cfg(any(test, not(target_os = "macos")))]
113fn linux_install(
114 unit_dir: &Path,
115 binary: &Path,
116 systemctl: &Path,
117 force: bool,
118) -> anyhow::Result<()> {
119 let unit_path = unit_dir.join("orch.service");
120 let unit = unit_contents(binary);
121 if unit_path.exists() && !force {
122 let existing = fs::read_to_string(&unit_path)
123 .with_context(|| format!("reading {}", unit_path.display()))?;
124 if existing != unit {
125 print_unit_diff(&existing, &unit);
126 }
127 anyhow::bail!(
128 "unit already exists at {}; pass --force to overwrite",
129 unit_path.display()
130 );
131 }
132 fs::create_dir_all(unit_dir).with_context(|| format!("creating {}", unit_dir.display()))?;
133 fs::write(&unit_path, &unit).with_context(|| format!("writing {}", unit_path.display()))?;
134 println!("Wrote {}", unit_path.display());
135 run_systemctl(systemctl, &["daemon-reload"])?;
136 run_systemctl(systemctl, &["enable", "--now", "orch"])?;
137 println!("Enabled and started the orch user service.");
138 println!("A user service stops at logout unless lingering is on.");
139 println!(" loginctl enable-linger $USER");
140 Ok(())
141}
142
143#[cfg(any(test, not(target_os = "macos")))]
144fn linux_uninstall(unit_dir: &Path, systemctl: &Path) -> anyhow::Result<()> {
145 let unit_path = unit_dir.join("orch.service");
146 if !unit_path.exists() {
147 let _ = run_systemctl(systemctl, &["disable", "--now", "orch"]);
148 println!("No orch user unit at {}", unit_path.display());
149 return Ok(());
150 }
151 run_systemctl(systemctl, &["disable", "--now", "orch"])?;
152 fs::remove_file(&unit_path).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 = 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) = env::var("XDG_CONFIG_HOME")
222 && !xdg.is_empty()
223 {
224 return Ok(PathBuf::from(xdg).join("systemd").join("user"));
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 = 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}