@opencode-cloud/core 0.1.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,363 @@
1
+ //! macOS launchd service manager implementation
2
+ //!
3
+ //! Manages launchd user agents and system daemons for service registration.
4
+ //! Uses plist serialization for property list generation and launchctl for
5
+ //! service lifecycle management.
6
+
7
+ use std::fs::{self, File};
8
+ use std::path::{Path, PathBuf};
9
+ use std::process::Command;
10
+
11
+ use anyhow::{Result, anyhow};
12
+ use serde::Serialize;
13
+
14
+ use super::{InstallResult, ServiceConfig, ServiceManager};
15
+
16
+ /// Service label used for launchd registration
17
+ const SERVICE_LABEL: &str = "com.opencode-cloud.service";
18
+
19
+ /// Plist structure for launchd service definition
20
+ #[derive(Serialize)]
21
+ #[serde(rename_all = "PascalCase")]
22
+ struct LaunchdPlist {
23
+ label: String,
24
+ program_arguments: Vec<String>,
25
+ run_at_load: bool,
26
+ #[serde(skip_serializing_if = "Option::is_none")]
27
+ keep_alive: Option<KeepAliveConfig>,
28
+ #[serde(skip_serializing_if = "Option::is_none")]
29
+ throttle_interval: Option<u32>,
30
+ #[serde(skip_serializing_if = "Option::is_none")]
31
+ standard_out_path: Option<String>,
32
+ #[serde(skip_serializing_if = "Option::is_none")]
33
+ standard_error_path: Option<String>,
34
+ }
35
+
36
+ /// KeepAlive configuration for restart behavior
37
+ #[derive(Serialize)]
38
+ #[serde(rename_all = "PascalCase")]
39
+ struct KeepAliveConfig {
40
+ /// Restart only on non-zero exit (false = restart on crash)
41
+ #[serde(skip_serializing_if = "Option::is_none")]
42
+ successful_exit: Option<bool>,
43
+ /// Restart on signal-based crash (SIGSEGV, etc.)
44
+ #[serde(skip_serializing_if = "Option::is_none")]
45
+ crashed: Option<bool>,
46
+ }
47
+
48
+ /// macOS launchd service manager
49
+ ///
50
+ /// Manages launchd user agents (~/Library/LaunchAgents/) or system daemons
51
+ /// (/Library/LaunchDaemons/) depending on the boot mode.
52
+ pub struct LaunchdManager {
53
+ /// true = user mode (~/Library/LaunchAgents/), false = system (/Library/LaunchDaemons/)
54
+ user_mode: bool,
55
+ }
56
+
57
+ impl LaunchdManager {
58
+ /// Create a new LaunchdManager
59
+ ///
60
+ /// # Arguments
61
+ /// * `boot_mode` - "user" for user agents, "system" for system daemons
62
+ pub fn new(boot_mode: &str) -> Self {
63
+ Self {
64
+ user_mode: boot_mode != "system",
65
+ }
66
+ }
67
+
68
+ /// Get the directory where service files are stored
69
+ fn service_dir(&self) -> PathBuf {
70
+ if self.user_mode {
71
+ directories::BaseDirs::new()
72
+ .expect("Could not determine base directories")
73
+ .home_dir()
74
+ .join("Library/LaunchAgents")
75
+ } else {
76
+ PathBuf::from("/Library/LaunchDaemons")
77
+ }
78
+ }
79
+
80
+ /// Get the service label
81
+ fn label(&self) -> &str {
82
+ SERVICE_LABEL
83
+ }
84
+
85
+ /// Get the log path for a given stream (stdout or stderr)
86
+ fn log_path(&self, stream: &str) -> PathBuf {
87
+ directories::BaseDirs::new()
88
+ .expect("Could not determine base directories")
89
+ .home_dir()
90
+ .join(format!("Library/Logs/opencode-cloud.{stream}.log"))
91
+ }
92
+
93
+ /// Generate plist content from service configuration
94
+ fn generate_plist(&self, config: &ServiceConfig) -> LaunchdPlist {
95
+ LaunchdPlist {
96
+ label: self.label().to_string(),
97
+ program_arguments: vec![
98
+ config.executable_path.display().to_string(),
99
+ "start".to_string(),
100
+ "--no-daemon".to_string(),
101
+ ],
102
+ run_at_load: true,
103
+ keep_alive: Some(KeepAliveConfig {
104
+ // Only restart on non-zero exit (crash)
105
+ successful_exit: Some(false),
106
+ // Restart on signal-based crash
107
+ crashed: Some(true),
108
+ }),
109
+ throttle_interval: Some(config.restart_delay),
110
+ standard_out_path: Some(self.log_path("stdout").display().to_string()),
111
+ standard_error_path: Some(self.log_path("stderr").display().to_string()),
112
+ }
113
+ }
114
+ }
115
+
116
+ /// Get the current user's UID
117
+ fn get_user_id() -> Result<u32> {
118
+ let output = Command::new("id").arg("-u").output()?;
119
+ let uid_str = String::from_utf8_lossy(&output.stdout);
120
+ uid_str
121
+ .trim()
122
+ .parse()
123
+ .map_err(|e| anyhow!("Failed to parse UID: {}", e))
124
+ }
125
+
126
+ impl ServiceManager for LaunchdManager {
127
+ fn install(&self, config: &ServiceConfig) -> Result<InstallResult> {
128
+ // Check permissions for system-level install
129
+ if !self.user_mode {
130
+ let uid = get_user_id()?;
131
+ if uid != 0 {
132
+ return Err(anyhow!(
133
+ "System-level installation requires root. Run with sudo."
134
+ ));
135
+ }
136
+ }
137
+
138
+ // Create service directory if needed
139
+ let service_dir = self.service_dir();
140
+ if !service_dir.exists() {
141
+ fs::create_dir_all(&service_dir)?;
142
+ }
143
+
144
+ let plist_path = self.service_file_path();
145
+
146
+ // If service is already loaded, bootout first
147
+ if plist_path.exists() {
148
+ // Ignore errors during bootout - service might not be running
149
+ let _ = self.bootout();
150
+ }
151
+
152
+ // Generate and write plist
153
+ let plist = self.generate_plist(config);
154
+ let file = File::create(&plist_path)?;
155
+ plist::to_writer_xml(file, &plist)?;
156
+
157
+ // Bootstrap the service
158
+ self.bootstrap(&plist_path)?;
159
+
160
+ Ok(InstallResult {
161
+ service_file_path: plist_path,
162
+ service_name: self.label().to_string(),
163
+ started: true,
164
+ requires_root: !self.user_mode,
165
+ })
166
+ }
167
+
168
+ fn uninstall(&self) -> Result<()> {
169
+ let plist_path = self.service_file_path();
170
+
171
+ // Bootout service if running (ignore errors for idempotency)
172
+ let _ = self.bootout();
173
+
174
+ // Remove plist file if it exists
175
+ if plist_path.exists() {
176
+ fs::remove_file(&plist_path)?;
177
+ }
178
+
179
+ Ok(())
180
+ }
181
+
182
+ fn is_installed(&self) -> Result<bool> {
183
+ Ok(self.service_file_path().exists())
184
+ }
185
+
186
+ fn service_file_path(&self) -> PathBuf {
187
+ self.service_dir().join(format!("{}.plist", self.label()))
188
+ }
189
+
190
+ fn service_name(&self) -> &str {
191
+ self.label()
192
+ }
193
+ }
194
+
195
+ impl LaunchdManager {
196
+ /// Bootstrap the service using modern launchctl syntax
197
+ fn bootstrap(&self, plist_path: &Path) -> Result<()> {
198
+ let output = if self.user_mode {
199
+ let uid = get_user_id()?;
200
+ let domain = format!("gui/{uid}");
201
+ Command::new("launchctl")
202
+ .args(["bootstrap", &domain, &plist_path.display().to_string()])
203
+ .output()?
204
+ } else {
205
+ Command::new("launchctl")
206
+ .args(["bootstrap", "system", &plist_path.display().to_string()])
207
+ .output()?
208
+ };
209
+
210
+ if !output.status.success() {
211
+ let stderr = String::from_utf8_lossy(&output.stderr);
212
+ // Handle "already loaded" error gracefully - service is running
213
+ if stderr.contains("already loaded") || stderr.contains("service already loaded") {
214
+ return Ok(());
215
+ }
216
+ return Err(anyhow!("Failed to bootstrap service: {}", stderr.trim()));
217
+ }
218
+
219
+ Ok(())
220
+ }
221
+
222
+ /// Bootout the service using modern launchctl syntax
223
+ fn bootout(&self) -> Result<()> {
224
+ let output = if self.user_mode {
225
+ let uid = get_user_id()?;
226
+ let service_target = format!("gui/{uid}/{}", self.label());
227
+ Command::new("launchctl")
228
+ .args(["bootout", &service_target])
229
+ .output()?
230
+ } else {
231
+ let service_target = format!("system/{}", self.label());
232
+ Command::new("launchctl")
233
+ .args(["bootout", &service_target])
234
+ .output()?
235
+ };
236
+
237
+ if !output.status.success() {
238
+ let stderr = String::from_utf8_lossy(&output.stderr);
239
+ // Handle "not found" error gracefully - service wasn't running
240
+ if stderr.contains("not find") || stderr.contains("No such") {
241
+ return Ok(());
242
+ }
243
+ return Err(anyhow!("Failed to bootout service: {}", stderr.trim()));
244
+ }
245
+
246
+ Ok(())
247
+ }
248
+ }
249
+
250
+ #[cfg(test)]
251
+ mod tests {
252
+ use super::*;
253
+
254
+ #[test]
255
+ fn test_launchd_manager_user_mode() {
256
+ let manager = LaunchdManager::new("user");
257
+ assert!(manager.user_mode);
258
+ assert_eq!(manager.label(), SERVICE_LABEL);
259
+ }
260
+
261
+ #[test]
262
+ fn test_launchd_manager_system_mode() {
263
+ let manager = LaunchdManager::new("system");
264
+ assert!(!manager.user_mode);
265
+ }
266
+
267
+ #[test]
268
+ fn test_service_dir_user_mode() {
269
+ let manager = LaunchdManager::new("user");
270
+ let service_dir = manager.service_dir();
271
+ assert!(
272
+ service_dir
273
+ .to_string_lossy()
274
+ .contains("Library/LaunchAgents")
275
+ );
276
+ }
277
+
278
+ #[test]
279
+ fn test_service_dir_system_mode() {
280
+ let manager = LaunchdManager::new("system");
281
+ let service_dir = manager.service_dir();
282
+ assert_eq!(service_dir, PathBuf::from("/Library/LaunchDaemons"));
283
+ }
284
+
285
+ #[test]
286
+ fn test_service_file_path() {
287
+ let manager = LaunchdManager::new("user");
288
+ let path = manager.service_file_path();
289
+ assert!(path.to_string_lossy().ends_with(".plist"));
290
+ assert!(
291
+ path.to_string_lossy()
292
+ .contains("com.opencode-cloud.service")
293
+ );
294
+ }
295
+
296
+ #[test]
297
+ fn test_log_path() {
298
+ let manager = LaunchdManager::new("user");
299
+ let stdout_path = manager.log_path("stdout");
300
+ let stderr_path = manager.log_path("stderr");
301
+
302
+ assert!(stdout_path.to_string_lossy().contains("Library/Logs"));
303
+ assert!(
304
+ stdout_path
305
+ .to_string_lossy()
306
+ .contains("opencode-cloud.stdout.log")
307
+ );
308
+ assert!(
309
+ stderr_path
310
+ .to_string_lossy()
311
+ .contains("opencode-cloud.stderr.log")
312
+ );
313
+ }
314
+
315
+ #[test]
316
+ fn test_generate_plist() {
317
+ let manager = LaunchdManager::new("user");
318
+ let config = ServiceConfig {
319
+ executable_path: PathBuf::from("/usr/local/bin/occ"),
320
+ restart_retries: 3,
321
+ restart_delay: 5,
322
+ boot_mode: "user".to_string(),
323
+ };
324
+
325
+ let plist = manager.generate_plist(&config);
326
+
327
+ assert_eq!(plist.label, SERVICE_LABEL);
328
+ assert_eq!(plist.program_arguments.len(), 3);
329
+ assert_eq!(plist.program_arguments[0], "/usr/local/bin/occ");
330
+ assert_eq!(plist.program_arguments[1], "start");
331
+ assert_eq!(plist.program_arguments[2], "--no-daemon");
332
+ assert!(plist.run_at_load);
333
+ assert!(plist.keep_alive.is_some());
334
+ assert_eq!(plist.throttle_interval, Some(5));
335
+ }
336
+
337
+ #[test]
338
+ fn test_plist_serialization() {
339
+ let manager = LaunchdManager::new("user");
340
+ let config = ServiceConfig {
341
+ executable_path: PathBuf::from("/usr/local/bin/occ"),
342
+ restart_retries: 3,
343
+ restart_delay: 5,
344
+ boot_mode: "user".to_string(),
345
+ };
346
+
347
+ let plist = manager.generate_plist(&config);
348
+
349
+ // Serialize to XML string to verify format
350
+ let mut buffer = Vec::new();
351
+ plist::to_writer_xml(&mut buffer, &plist).expect("Failed to serialize plist");
352
+ let xml = String::from_utf8(buffer).expect("Invalid UTF-8");
353
+
354
+ // Verify key elements are present
355
+ assert!(xml.contains("<key>Label</key>"));
356
+ assert!(xml.contains("<string>com.opencode-cloud.service</string>"));
357
+ assert!(xml.contains("<key>ProgramArguments</key>"));
358
+ assert!(xml.contains("<key>RunAtLoad</key>"));
359
+ assert!(xml.contains("<true/>"));
360
+ assert!(xml.contains("<key>KeepAlive</key>"));
361
+ assert!(xml.contains("<key>ThrottleInterval</key>"));
362
+ }
363
+ }
@@ -0,0 +1,191 @@
1
+ //! Platform-specific service manager abstraction
2
+ //!
3
+ //! This module provides a unified interface for registering the opencode-cloud
4
+ //! service with platform-specific init systems (systemd on Linux, launchd on macOS).
5
+
6
+ use std::path::PathBuf;
7
+
8
+ use anyhow::Result;
9
+
10
+ #[cfg(any(
11
+ target_os = "linux",
12
+ not(any(target_os = "linux", target_os = "macos"))
13
+ ))]
14
+ use anyhow::anyhow;
15
+
16
+ #[cfg(target_os = "linux")]
17
+ mod systemd;
18
+
19
+ #[cfg(target_os = "macos")]
20
+ mod launchd;
21
+
22
+ #[cfg(target_os = "linux")]
23
+ pub use systemd::{SystemdManager, systemd_available};
24
+
25
+ #[cfg(target_os = "macos")]
26
+ pub use launchd::LaunchdManager;
27
+
28
+ /// Configuration for service installation
29
+ #[derive(Debug, Clone)]
30
+ pub struct ServiceConfig {
31
+ /// Path to the executable to run
32
+ pub executable_path: PathBuf,
33
+
34
+ /// Number of restart attempts on crash
35
+ pub restart_retries: u32,
36
+
37
+ /// Seconds between restart attempts
38
+ pub restart_delay: u32,
39
+
40
+ /// Boot mode: "user" (starts on login) or "system" (starts on boot)
41
+ pub boot_mode: String,
42
+ }
43
+
44
+ /// Result of a service installation operation
45
+ #[derive(Debug, Clone)]
46
+ pub struct InstallResult {
47
+ /// Path to the service file that was created
48
+ pub service_file_path: PathBuf,
49
+
50
+ /// Name of the service (e.g., "opencode-cloud")
51
+ pub service_name: String,
52
+
53
+ /// Whether the service was started after installation
54
+ pub started: bool,
55
+
56
+ /// Whether root/sudo is required for this installation type
57
+ pub requires_root: bool,
58
+ }
59
+
60
+ /// Trait for platform-specific service managers
61
+ ///
62
+ /// Implementations handle the details of registering services with
63
+ /// systemd (Linux) or launchd (macOS).
64
+ pub trait ServiceManager: Send + Sync {
65
+ /// Install the service with the given configuration
66
+ ///
67
+ /// Creates the service file and registers it with the init system.
68
+ /// Also starts the service immediately after registration.
69
+ fn install(&self, config: &ServiceConfig) -> Result<InstallResult>;
70
+
71
+ /// Uninstall the service
72
+ ///
73
+ /// Stops the service if running and removes the registration.
74
+ fn uninstall(&self) -> Result<()>;
75
+
76
+ /// Check if the service is currently installed
77
+ fn is_installed(&self) -> Result<bool>;
78
+
79
+ /// Get the path to the service file
80
+ fn service_file_path(&self) -> PathBuf;
81
+
82
+ /// Get the service name
83
+ fn service_name(&self) -> &str;
84
+ }
85
+
86
+ /// Get the appropriate service manager for the current platform
87
+ ///
88
+ /// Returns an error if the platform is not supported or if the
89
+ /// service manager implementation is not yet available.
90
+ pub fn get_service_manager() -> Result<Box<dyn ServiceManager>> {
91
+ #[cfg(target_os = "linux")]
92
+ {
93
+ if !systemd::systemd_available() {
94
+ return Err(anyhow!(
95
+ "systemd not available on this system. \
96
+ Service registration requires systemd as the init system."
97
+ ));
98
+ }
99
+ Ok(Box::new(systemd::SystemdManager::new("user")))
100
+ }
101
+ #[cfg(target_os = "macos")]
102
+ {
103
+ Ok(Box::new(launchd::LaunchdManager::new("user")))
104
+ }
105
+ #[cfg(not(any(target_os = "linux", target_os = "macos")))]
106
+ {
107
+ Err(anyhow!("Unsupported platform for service registration"))
108
+ }
109
+ }
110
+
111
+ /// Check if service registration is supported on the current platform
112
+ ///
113
+ /// Returns true for Linux (systemd) and macOS (launchd).
114
+ pub fn is_service_registration_supported() -> bool {
115
+ cfg!(any(target_os = "linux", target_os = "macos"))
116
+ }
117
+
118
+ #[cfg(test)]
119
+ mod tests {
120
+ use super::*;
121
+
122
+ #[test]
123
+ fn test_service_config_creation() {
124
+ let config = ServiceConfig {
125
+ executable_path: PathBuf::from("/usr/local/bin/occ"),
126
+ restart_retries: 3,
127
+ restart_delay: 5,
128
+ boot_mode: "user".to_string(),
129
+ };
130
+
131
+ assert_eq!(config.executable_path, PathBuf::from("/usr/local/bin/occ"));
132
+ assert_eq!(config.restart_retries, 3);
133
+ assert_eq!(config.restart_delay, 5);
134
+ assert_eq!(config.boot_mode, "user");
135
+ }
136
+
137
+ #[test]
138
+ fn test_install_result_creation() {
139
+ let result = InstallResult {
140
+ service_file_path: PathBuf::from("/etc/systemd/user/opencode-cloud.service"),
141
+ service_name: "opencode-cloud".to_string(),
142
+ started: true,
143
+ requires_root: false,
144
+ };
145
+
146
+ assert_eq!(
147
+ result.service_file_path,
148
+ PathBuf::from("/etc/systemd/user/opencode-cloud.service")
149
+ );
150
+ assert_eq!(result.service_name, "opencode-cloud");
151
+ assert!(result.started);
152
+ assert!(!result.requires_root);
153
+ }
154
+
155
+ #[test]
156
+ fn test_is_service_registration_supported() {
157
+ // On macOS/Linux this should return true, on other platforms false
158
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
159
+ assert!(is_service_registration_supported());
160
+
161
+ #[cfg(not(any(target_os = "linux", target_os = "macos")))]
162
+ assert!(!is_service_registration_supported());
163
+ }
164
+
165
+ #[test]
166
+ fn test_get_service_manager_behavior() {
167
+ let result = get_service_manager();
168
+
169
+ // On Linux with systemd: returns Ok(SystemdManager)
170
+ // On Linux without systemd: returns Err (systemd not available)
171
+ // On macOS: returns Ok(LaunchdManager)
172
+ // On other platforms: returns Err (unsupported)
173
+ #[cfg(target_os = "linux")]
174
+ {
175
+ // Result depends on whether systemd is available
176
+ // This test just verifies the function doesn't panic
177
+ let _ = result;
178
+ }
179
+ #[cfg(target_os = "macos")]
180
+ {
181
+ // LaunchdManager should be returned on macOS
182
+ assert!(result.is_ok());
183
+ let manager = result.unwrap();
184
+ assert_eq!(manager.service_name(), "com.opencode-cloud.service");
185
+ }
186
+ #[cfg(not(any(target_os = "linux", target_os = "macos")))]
187
+ {
188
+ assert!(result.is_err());
189
+ }
190
+ }
191
+ }