@opencode-cloud/core 1.0.7 → 1.0.10

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,282 @@
1
+ //! SSH config file parsing and writing
2
+ //!
3
+ //! Parses ~/.ssh/config to auto-fill host settings and can write new entries.
4
+
5
+ use std::fs::{self, File, OpenOptions};
6
+ use std::io::{BufReader, Write};
7
+ use std::path::PathBuf;
8
+
9
+ use ssh2_config::{ParseRule, SshConfig};
10
+
11
+ use super::error::HostError;
12
+
13
+ /// Settings found in user's SSH config for a host
14
+ #[derive(Debug, Clone, Default)]
15
+ pub struct SshConfigMatch {
16
+ /// User from SSH config
17
+ pub user: Option<String>,
18
+ /// Port from SSH config
19
+ pub port: Option<u16>,
20
+ /// Identity file path from SSH config
21
+ pub identity_file: Option<String>,
22
+ /// ProxyJump (jump host) from SSH config
23
+ pub proxy_jump: Option<String>,
24
+ /// Whether any match was found
25
+ pub matched: bool,
26
+ }
27
+
28
+ impl SshConfigMatch {
29
+ /// Check if any useful settings were found
30
+ pub fn has_settings(&self) -> bool {
31
+ self.user.is_some()
32
+ || self.port.is_some()
33
+ || self.identity_file.is_some()
34
+ || self.proxy_jump.is_some()
35
+ }
36
+
37
+ /// Format found settings for display
38
+ pub fn display_settings(&self) -> String {
39
+ let mut parts = Vec::new();
40
+
41
+ if let Some(user) = &self.user {
42
+ parts.push(format!("User={user}"));
43
+ }
44
+ if let Some(port) = self.port {
45
+ parts.push(format!("Port={port}"));
46
+ }
47
+ if let Some(key) = &self.identity_file {
48
+ parts.push(format!("IdentityFile={key}"));
49
+ }
50
+ if let Some(jump) = &self.proxy_jump {
51
+ parts.push(format!("ProxyJump={jump}"));
52
+ }
53
+
54
+ parts.join(", ")
55
+ }
56
+ }
57
+
58
+ /// Get the path to the user's SSH config file
59
+ pub fn get_ssh_config_path() -> Option<PathBuf> {
60
+ dirs::home_dir().map(|home| home.join(".ssh").join("config"))
61
+ }
62
+
63
+ /// Parse user's SSH config and query for a hostname
64
+ ///
65
+ /// Returns settings found for the given hostname, applying SSH config
66
+ /// precedence rules (first match wins).
67
+ pub fn query_ssh_config(hostname: &str) -> Result<SshConfigMatch, HostError> {
68
+ let config_path = match get_ssh_config_path() {
69
+ Some(path) if path.exists() => path,
70
+ _ => {
71
+ tracing::debug!("No SSH config file found");
72
+ return Ok(SshConfigMatch::default());
73
+ }
74
+ };
75
+
76
+ let file = File::open(&config_path).map_err(|e| {
77
+ HostError::SshConfigRead(format!("Failed to open {}: {}", config_path.display(), e))
78
+ })?;
79
+
80
+ let mut reader = BufReader::new(file);
81
+
82
+ // Use ALLOW_UNKNOWN_FIELDS to be lenient with SSH config options we don't support
83
+ let config = SshConfig::default()
84
+ .parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)
85
+ .map_err(|e| HostError::SshConfigRead(format!("Failed to parse SSH config: {e}")))?;
86
+
87
+ // Query for the hostname
88
+ let params = config.query(hostname);
89
+
90
+ let mut result = SshConfigMatch {
91
+ matched: true,
92
+ ..Default::default()
93
+ };
94
+
95
+ // Extract relevant fields
96
+ if let Some(user) = params.user {
97
+ result.user = Some(user);
98
+ }
99
+ if let Some(port) = params.port {
100
+ result.port = Some(port);
101
+ }
102
+ if let Some(files) = params.identity_file {
103
+ // SSH config can have multiple identity files; take the first
104
+ if let Some(first) = files.first() {
105
+ result.identity_file = Some(first.to_string_lossy().to_string());
106
+ }
107
+ }
108
+ if let Some(jump) = params.proxy_jump {
109
+ // SSH config can have multiple jump hosts chained; join them
110
+ if !jump.is_empty() {
111
+ result.proxy_jump = Some(jump.join(","));
112
+ }
113
+ }
114
+
115
+ // Check if we actually found anything useful
116
+ if !result.has_settings() {
117
+ result.matched = false;
118
+ }
119
+
120
+ Ok(result)
121
+ }
122
+
123
+ /// Write a new host entry to the user's SSH config file
124
+ ///
125
+ /// Appends a Host block to ~/.ssh/config with the provided settings.
126
+ /// Creates the file and directory if they don't exist.
127
+ pub fn write_ssh_config_entry(
128
+ alias: &str,
129
+ hostname: &str,
130
+ user: Option<&str>,
131
+ port: Option<u16>,
132
+ identity_file: Option<&str>,
133
+ jump_host: Option<&str>,
134
+ ) -> Result<PathBuf, HostError> {
135
+ let config_path = get_ssh_config_path().ok_or_else(|| {
136
+ HostError::SshConfigWrite("Could not determine home directory".to_string())
137
+ })?;
138
+
139
+ // Ensure .ssh directory exists with proper permissions
140
+ if let Some(ssh_dir) = config_path.parent() {
141
+ if !ssh_dir.exists() {
142
+ fs::create_dir_all(ssh_dir).map_err(|e| {
143
+ HostError::SshConfigWrite(format!("Failed to create .ssh directory: {e}"))
144
+ })?;
145
+
146
+ // Set directory permissions to 700 on Unix
147
+ #[cfg(unix)]
148
+ {
149
+ use std::os::unix::fs::PermissionsExt;
150
+ let perms = fs::Permissions::from_mode(0o700);
151
+ fs::set_permissions(ssh_dir, perms).map_err(|e| {
152
+ HostError::SshConfigWrite(format!("Failed to set .ssh permissions: {e}"))
153
+ })?;
154
+ }
155
+ }
156
+ }
157
+
158
+ // Build the config entry
159
+ let mut entry = String::new();
160
+ entry.push_str(&format!("\n# Added by opencode-cloud for host '{alias}'\n"));
161
+ entry.push_str(&format!("Host {alias}\n"));
162
+ entry.push_str(&format!(" HostName {hostname}\n"));
163
+
164
+ if let Some(u) = user {
165
+ entry.push_str(&format!(" User {u}\n"));
166
+ }
167
+ if let Some(p) = port {
168
+ if p != 22 {
169
+ entry.push_str(&format!(" Port {p}\n"));
170
+ }
171
+ }
172
+ if let Some(key) = identity_file {
173
+ entry.push_str(&format!(" IdentityFile {key}\n"));
174
+ }
175
+ if let Some(jump) = jump_host {
176
+ entry.push_str(&format!(" ProxyJump {jump}\n"));
177
+ }
178
+
179
+ // Append to config file (create if doesn't exist)
180
+ let mut file = OpenOptions::new()
181
+ .create(true)
182
+ .append(true)
183
+ .open(&config_path)
184
+ .map_err(|e| {
185
+ HostError::SshConfigWrite(format!("Failed to open {}: {}", config_path.display(), e))
186
+ })?;
187
+
188
+ // Set file permissions to 600 on Unix if we just created it
189
+ #[cfg(unix)]
190
+ {
191
+ use std::os::unix::fs::PermissionsExt;
192
+ let metadata = file
193
+ .metadata()
194
+ .map_err(|e| HostError::SshConfigWrite(format!("Failed to get file metadata: {e}")))?;
195
+ if metadata.len() == 0 {
196
+ let perms = fs::Permissions::from_mode(0o600);
197
+ fs::set_permissions(&config_path, perms).map_err(|e| {
198
+ HostError::SshConfigWrite(format!("Failed to set config permissions: {e}"))
199
+ })?;
200
+ }
201
+ }
202
+
203
+ file.write_all(entry.as_bytes()).map_err(|e| {
204
+ HostError::SshConfigWrite(format!(
205
+ "Failed to write to {}: {}",
206
+ config_path.display(),
207
+ e
208
+ ))
209
+ })?;
210
+
211
+ tracing::info!(
212
+ "Added host '{}' to SSH config at {}",
213
+ alias,
214
+ config_path.display()
215
+ );
216
+
217
+ Ok(config_path)
218
+ }
219
+
220
+ /// Check if a host alias already exists in SSH config
221
+ pub fn host_exists_in_ssh_config(alias: &str) -> bool {
222
+ let config_path = match get_ssh_config_path() {
223
+ Some(path) if path.exists() => path,
224
+ _ => return false,
225
+ };
226
+
227
+ let Ok(file) = File::open(&config_path) else {
228
+ return false;
229
+ };
230
+
231
+ let mut reader = BufReader::new(file);
232
+
233
+ let Ok(config) = SshConfig::default().parse(&mut reader, ParseRule::ALLOW_UNKNOWN_FIELDS)
234
+ else {
235
+ return false;
236
+ };
237
+
238
+ // Query returns default params if not found, so we check if hostname is set
239
+ let params = config.query(alias);
240
+ params.host_name.is_some()
241
+ }
242
+
243
+ #[cfg(test)]
244
+ mod tests {
245
+ use super::*;
246
+
247
+ #[test]
248
+ fn test_ssh_config_match_display() {
249
+ let m = SshConfigMatch {
250
+ user: Some("ubuntu".to_string()),
251
+ port: Some(2222),
252
+ identity_file: Some("~/.ssh/mykey.pem".to_string()),
253
+ proxy_jump: None,
254
+ matched: true,
255
+ };
256
+
257
+ let display = m.display_settings();
258
+ assert!(display.contains("User=ubuntu"));
259
+ assert!(display.contains("Port=2222"));
260
+ assert!(display.contains("IdentityFile=~/.ssh/mykey.pem"));
261
+ }
262
+
263
+ #[test]
264
+ fn test_ssh_config_match_has_settings() {
265
+ let empty = SshConfigMatch::default();
266
+ assert!(!empty.has_settings());
267
+
268
+ let with_user = SshConfigMatch {
269
+ user: Some("test".to_string()),
270
+ ..Default::default()
271
+ };
272
+ assert!(with_user.has_settings());
273
+ }
274
+
275
+ #[test]
276
+ fn test_get_ssh_config_path() {
277
+ let path = get_ssh_config_path();
278
+ assert!(path.is_some());
279
+ let path = path.unwrap();
280
+ assert!(path.ends_with(".ssh/config"));
281
+ }
282
+ }
@@ -0,0 +1,118 @@
1
+ //! Host configuration storage
2
+ //!
3
+ //! Load and save hosts.json file.
4
+
5
+ use std::fs::{self, File};
6
+ use std::io::{Read, Write};
7
+
8
+ use super::error::HostError;
9
+ use super::schema::HostsFile;
10
+ use crate::config::paths::get_hosts_path;
11
+
12
+ /// Load hosts configuration from hosts.json
13
+ ///
14
+ /// Returns empty HostsFile if file doesn't exist.
15
+ pub fn load_hosts() -> Result<HostsFile, HostError> {
16
+ let hosts_path = get_hosts_path()
17
+ .ok_or_else(|| HostError::LoadFailed("Could not determine hosts file path".to_string()))?;
18
+
19
+ if !hosts_path.exists() {
20
+ tracing::debug!(
21
+ "Hosts file not found, returning empty: {}",
22
+ hosts_path.display()
23
+ );
24
+ return Ok(HostsFile::new());
25
+ }
26
+
27
+ let mut file = File::open(&hosts_path).map_err(|e| {
28
+ HostError::LoadFailed(format!("Failed to open {}: {}", hosts_path.display(), e))
29
+ })?;
30
+
31
+ let mut contents = String::new();
32
+ file.read_to_string(&mut contents).map_err(|e| {
33
+ HostError::LoadFailed(format!("Failed to read {}: {}", hosts_path.display(), e))
34
+ })?;
35
+
36
+ let hosts: HostsFile = serde_json::from_str(&contents).map_err(|e| {
37
+ HostError::LoadFailed(format!("Invalid JSON in {}: {}", hosts_path.display(), e))
38
+ })?;
39
+
40
+ tracing::debug!(
41
+ "Loaded {} hosts from {}",
42
+ hosts.hosts.len(),
43
+ hosts_path.display()
44
+ );
45
+ Ok(hosts)
46
+ }
47
+
48
+ /// Save hosts configuration to hosts.json
49
+ ///
50
+ /// Creates the config directory if it doesn't exist.
51
+ /// Creates a backup (.bak) if file already exists.
52
+ pub fn save_hosts(hosts: &HostsFile) -> Result<(), HostError> {
53
+ let hosts_path = get_hosts_path()
54
+ .ok_or_else(|| HostError::SaveFailed("Could not determine hosts file path".to_string()))?;
55
+
56
+ // Ensure config directory exists
57
+ if let Some(parent) = hosts_path.parent() {
58
+ if !parent.exists() {
59
+ fs::create_dir_all(parent)
60
+ .map_err(|e| HostError::SaveFailed(format!("Failed to create directory: {e}")))?;
61
+ }
62
+ }
63
+
64
+ // Create backup if file exists
65
+ if hosts_path.exists() {
66
+ let backup_path = hosts_path.with_extension("json.bak");
67
+ fs::copy(&hosts_path, &backup_path)
68
+ .map_err(|e| HostError::SaveFailed(format!("Failed to create backup: {e}")))?;
69
+ tracing::debug!("Created hosts backup: {}", backup_path.display());
70
+ }
71
+
72
+ // Serialize with pretty formatting
73
+ let json = serde_json::to_string_pretty(hosts)
74
+ .map_err(|e| HostError::SaveFailed(format!("Failed to serialize: {e}")))?;
75
+
76
+ // Write to file
77
+ let mut file = File::create(&hosts_path).map_err(|e| {
78
+ HostError::SaveFailed(format!("Failed to create {}: {}", hosts_path.display(), e))
79
+ })?;
80
+
81
+ file.write_all(json.as_bytes()).map_err(|e| {
82
+ HostError::SaveFailed(format!("Failed to write {}: {}", hosts_path.display(), e))
83
+ })?;
84
+
85
+ tracing::debug!(
86
+ "Saved {} hosts to {}",
87
+ hosts.hosts.len(),
88
+ hosts_path.display()
89
+ );
90
+ Ok(())
91
+ }
92
+
93
+ #[cfg(test)]
94
+ mod tests {
95
+ use super::*;
96
+ use crate::host::schema::HostConfig;
97
+
98
+ #[test]
99
+ fn test_load_nonexistent_returns_empty() {
100
+ // This test relies on hosts.json not existing in a fresh environment
101
+ // In CI/testing, we'd mock the path, but for basic test:
102
+ let result = load_hosts();
103
+ // Should succeed with empty or existing hosts
104
+ assert!(result.is_ok());
105
+ }
106
+
107
+ #[test]
108
+ fn test_serialize_format() {
109
+ let mut hosts = HostsFile::new();
110
+ hosts.add_host("test", HostConfig::new("test.example.com"));
111
+
112
+ let json = serde_json::to_string_pretty(&hosts).unwrap();
113
+
114
+ // Verify it's valid JSON that can be read back
115
+ let parsed: HostsFile = serde_json::from_str(&json).unwrap();
116
+ assert!(parsed.has_host("test"));
117
+ }
118
+ }
@@ -0,0 +1,268 @@
1
+ //! SSH tunnel management
2
+ //!
3
+ //! Creates and manages SSH tunnels to remote Docker daemons.
4
+
5
+ use std::net::TcpListener;
6
+ use std::process::{Child, Command, Stdio};
7
+ use std::time::Duration;
8
+
9
+ use super::error::HostError;
10
+ use super::schema::HostConfig;
11
+
12
+ /// SSH tunnel to a remote Docker daemon
13
+ ///
14
+ /// The tunnel forwards a local port to the remote Docker socket.
15
+ /// Implements Drop to ensure the SSH process is killed on cleanup.
16
+ pub struct SshTunnel {
17
+ child: Child,
18
+ local_port: u16,
19
+ host_name: String,
20
+ }
21
+
22
+ impl SshTunnel {
23
+ /// Create SSH tunnel to remote Docker socket
24
+ ///
25
+ /// Spawns an SSH process with local port forwarding:
26
+ /// `ssh -L local_port:/var/run/docker.sock -N host`
27
+ ///
28
+ /// Uses BatchMode=yes to fail fast if key not in agent.
29
+ pub fn new(host: &HostConfig, host_name: &str) -> Result<Self, HostError> {
30
+ // Find available local port
31
+ let local_port = find_available_port()?;
32
+
33
+ // Build SSH command
34
+ let mut cmd = Command::new("ssh");
35
+
36
+ // Local port forward: local_port -> remote docker.sock
37
+ cmd.arg("-L")
38
+ .arg(format!("{local_port}:/var/run/docker.sock"));
39
+
40
+ // No command, just forward
41
+ cmd.arg("-N");
42
+
43
+ // Suppress prompts, fail fast on auth issues
44
+ cmd.arg("-o").arg("BatchMode=yes");
45
+
46
+ // Accept new host keys automatically (first connection)
47
+ cmd.arg("-o").arg("StrictHostKeyChecking=accept-new");
48
+
49
+ // Connection timeout
50
+ cmd.arg("-o").arg("ConnectTimeout=10");
51
+
52
+ // Prevent SSH from reading stdin (fixes issues with background operation)
53
+ cmd.arg("-o").arg("RequestTTY=no");
54
+
55
+ // Jump host support
56
+ if let Some(jump) = &host.jump_host {
57
+ cmd.arg("-J").arg(jump);
58
+ }
59
+
60
+ // Identity file
61
+ if let Some(key) = &host.identity_file {
62
+ cmd.arg("-i").arg(key);
63
+ }
64
+
65
+ // Custom port
66
+ if let Some(port) = host.port {
67
+ cmd.arg("-p").arg(port.to_string());
68
+ }
69
+
70
+ // Target: user@hostname
71
+ cmd.arg(format!("{}@{}", host.user, host.hostname));
72
+
73
+ // Configure stdio
74
+ cmd.stdin(Stdio::null())
75
+ .stdout(Stdio::null())
76
+ .stderr(Stdio::piped());
77
+
78
+ tracing::debug!(
79
+ "Spawning SSH tunnel: ssh -L {}:/var/run/docker.sock {}@{}",
80
+ local_port,
81
+ host.user,
82
+ host.hostname
83
+ );
84
+
85
+ let child = cmd.spawn().map_err(|e| {
86
+ if e.kind() == std::io::ErrorKind::NotFound {
87
+ HostError::SshSpawn("SSH not found. Install OpenSSH client.".to_string())
88
+ } else {
89
+ HostError::SshSpawn(e.to_string())
90
+ }
91
+ })?;
92
+
93
+ Ok(Self {
94
+ child,
95
+ local_port,
96
+ host_name: host_name.to_string(),
97
+ })
98
+ }
99
+
100
+ /// Get the local port for Docker connection
101
+ pub fn local_port(&self) -> u16 {
102
+ self.local_port
103
+ }
104
+
105
+ /// Get the Docker connection URL
106
+ pub fn docker_url(&self) -> String {
107
+ format!("tcp://127.0.0.1:{}", self.local_port)
108
+ }
109
+
110
+ /// Get the host name this tunnel connects to
111
+ pub fn host_name(&self) -> &str {
112
+ &self.host_name
113
+ }
114
+
115
+ /// Wait for tunnel to be ready (port accepting connections)
116
+ ///
117
+ /// Retries with exponential backoff: 100ms, 200ms, 400ms (3 attempts)
118
+ pub async fn wait_ready(&self) -> Result<(), HostError> {
119
+ let max_attempts = 3;
120
+ let initial_delay_ms = 100;
121
+
122
+ for attempt in 0..max_attempts {
123
+ if attempt > 0 {
124
+ let delay = Duration::from_millis(initial_delay_ms * 2u64.pow(attempt));
125
+ tracing::debug!("Tunnel wait attempt {} after {:?}", attempt + 1, delay);
126
+ tokio::time::sleep(delay).await;
127
+ }
128
+
129
+ // Try to connect to the local port
130
+ match std::net::TcpStream::connect_timeout(
131
+ &format!("127.0.0.1:{}", self.local_port).parse().unwrap(),
132
+ Duration::from_secs(1),
133
+ ) {
134
+ Ok(_) => {
135
+ tracing::debug!("SSH tunnel ready on port {}", self.local_port);
136
+ return Ok(());
137
+ }
138
+ Err(e) => {
139
+ tracing::debug!("Tunnel not ready: {}", e);
140
+ }
141
+ }
142
+ }
143
+
144
+ Err(HostError::TunnelTimeout(max_attempts))
145
+ }
146
+
147
+ /// Check if the SSH process is still running
148
+ pub fn is_alive(&mut self) -> bool {
149
+ matches!(self.child.try_wait(), Ok(None))
150
+ }
151
+ }
152
+
153
+ impl Drop for SshTunnel {
154
+ fn drop(&mut self) {
155
+ tracing::debug!(
156
+ "Cleaning up SSH tunnel to {} (port {})",
157
+ self.host_name,
158
+ self.local_port
159
+ );
160
+ if let Err(e) = self.child.kill() {
161
+ // Process may have already exited
162
+ tracing::debug!("SSH tunnel kill result: {}", e);
163
+ }
164
+ // Wait to reap the zombie process
165
+ let _ = self.child.wait();
166
+ }
167
+ }
168
+
169
+ /// Find an available local port for the tunnel
170
+ fn find_available_port() -> Result<u16, HostError> {
171
+ // Bind to port 0 to get OS-assigned port
172
+ let listener =
173
+ TcpListener::bind("127.0.0.1:0").map_err(|e| HostError::PortAllocation(e.to_string()))?;
174
+
175
+ let port = listener
176
+ .local_addr()
177
+ .map_err(|e| HostError::PortAllocation(e.to_string()))?
178
+ .port();
179
+
180
+ // Drop listener to free the port
181
+ drop(listener);
182
+
183
+ Ok(port)
184
+ }
185
+
186
+ /// Test SSH connection to a host
187
+ ///
188
+ /// Runs `ssh user@host docker version` to verify:
189
+ /// 1. SSH connection works
190
+ /// 2. Docker is available on remote
191
+ pub async fn test_connection(host: &HostConfig) -> Result<String, HostError> {
192
+ let mut cmd = Command::new("ssh");
193
+
194
+ // Standard options
195
+ cmd.arg("-o")
196
+ .arg("BatchMode=yes")
197
+ .arg("-o")
198
+ .arg("ConnectTimeout=10")
199
+ .arg("-o")
200
+ .arg("StrictHostKeyChecking=accept-new");
201
+
202
+ // Host-specific options (port, identity, jump, user@host)
203
+ cmd.args(host.ssh_args());
204
+
205
+ // Docker version command
206
+ cmd.arg("docker")
207
+ .arg("version")
208
+ .arg("--format")
209
+ .arg("{{.Server.Version}}");
210
+
211
+ cmd.stdin(Stdio::null())
212
+ .stdout(Stdio::piped())
213
+ .stderr(Stdio::piped());
214
+
215
+ let output = cmd.output().map_err(|e| {
216
+ if e.kind() == std::io::ErrorKind::NotFound {
217
+ HostError::SshSpawn("SSH not found. Install OpenSSH client.".to_string())
218
+ } else {
219
+ HostError::SshSpawn(e.to_string())
220
+ }
221
+ })?;
222
+
223
+ if output.status.success() {
224
+ let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
225
+ tracing::info!("Docker version on remote: {}", version);
226
+ Ok(version)
227
+ } else {
228
+ let stderr = String::from_utf8_lossy(&output.stderr);
229
+
230
+ // Detect authentication failures
231
+ if stderr.contains("Permission denied") || stderr.contains("Host key verification failed") {
232
+ return Err(HostError::AuthFailed {
233
+ key_hint: host.identity_file.clone(),
234
+ });
235
+ }
236
+
237
+ // Detect Docker not available
238
+ if stderr.contains("command not found") || stderr.contains("not found") {
239
+ return Err(HostError::RemoteDockerUnavailable(
240
+ "Docker is not installed on remote host".to_string(),
241
+ ));
242
+ }
243
+
244
+ Err(HostError::ConnectionFailed(stderr.to_string()))
245
+ }
246
+ }
247
+
248
+ #[cfg(test)]
249
+ mod tests {
250
+ use super::*;
251
+
252
+ #[test]
253
+ fn test_find_available_port() {
254
+ let port = find_available_port().unwrap();
255
+ assert!(port > 0);
256
+
257
+ // Port should be available (we can bind to it)
258
+ let listener = TcpListener::bind(format!("127.0.0.1:{port}"));
259
+ assert!(listener.is_ok());
260
+ }
261
+
262
+ #[test]
263
+ fn test_docker_url_format() {
264
+ // We can't easily test tunnel creation without SSH, but we can test the URL format
265
+ let url = format!("tcp://127.0.0.1:{}", 12345);
266
+ assert_eq!(url, "tcp://127.0.0.1:12345");
267
+ }
268
+ }
package/src/lib.rs CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  pub mod config;
7
7
  pub mod docker;
8
+ pub mod host;
8
9
  pub mod platform;
9
10
  pub mod singleton;
10
11
  pub mod version;
@@ -13,7 +14,7 @@ pub mod version;
13
14
  pub use version::{get_version, get_version_long};
14
15
 
15
16
  // Re-export config types and functions
16
- pub use config::{Config, load_config, save_config};
17
+ pub use config::{Config, get_hosts_path, load_config, save_config};
17
18
 
18
19
  // Re-export singleton types
19
20
  pub use singleton::{InstanceLock, SingletonError};
@@ -27,6 +28,14 @@ pub use platform::{
27
28
  is_service_registration_supported,
28
29
  };
29
30
 
31
+ // Re-export host types
32
+ pub use host::{
33
+ DistroFamily, DistroInfo, HostConfig, HostError, HostsFile, SshConfigMatch, SshTunnel,
34
+ detect_distro, get_docker_install_commands, get_ssh_config_path, host_exists_in_ssh_config,
35
+ install_docker, load_hosts, query_ssh_config, save_hosts, test_connection,
36
+ verify_docker_installed, write_ssh_config_entry,
37
+ };
38
+
30
39
  // Re-export bollard to ensure all crates use the same version
31
40
  pub use bollard;
32
41
 
@@ -120,7 +120,7 @@ fn get_user_id() -> Result<u32> {
120
120
  uid_str
121
121
  .trim()
122
122
  .parse()
123
- .map_err(|e| anyhow!("Failed to parse UID: {}", e))
123
+ .map_err(|e| anyhow!("Failed to parse UID: {e}"))
124
124
  }
125
125
 
126
126
  impl ServiceManager for LaunchdManager {