Skip to main content

opencode_orchestrator_mcp/
version.rs

1use anyhow::Context;
2use anyhow::anyhow;
3pub use opencode_rs::version::OPENCODE_BINARY_ARGS_ENV;
4pub use opencode_rs::version::OPENCODE_BINARY_ENV;
5pub use opencode_rs::version::PINNED_OPENCODE_VERSION;
6pub use opencode_rs::version::validate_exact_version;
7use std::path::Path;
8use std::path::PathBuf;
9
10/// Configuration for launching the `OpenCode` server.
11///
12/// Supports both direct binary invocation and launcher-based invocation:
13/// - Direct: `binary = "/path/to/opencode"`, `launcher_args = []`
14/// - Launcher: `binary = "bunx"`, `launcher_args = ["--yes", "opencode-ai@1.14.33"]`
15#[derive(Debug, Clone)]
16pub struct LauncherConfig {
17    /// Path to the binary (or launcher binary like `bunx`).
18    pub binary: String,
19    /// Extra arguments inserted between the binary and `serve` command.
20    pub launcher_args: Vec<String>,
21}
22
23#[cfg(test)]
24pub fn normalize_version(raw: &str) -> &str {
25    opencode_rs::version::normalize_version(raw)
26}
27
28pub fn default_pinned_binary_path(base_dir: &Path) -> PathBuf {
29    base_dir
30        .join(".opencode")
31        .join("bin")
32        .join(format!("opencode-v{PINNED_OPENCODE_VERSION}"))
33}
34
35pub fn resolve_opencode_binary(base_dir: &Path) -> anyhow::Result<PathBuf> {
36    if let Ok(value) = std::env::var(OPENCODE_BINARY_ENV) {
37        let value = value.trim();
38        if !value.is_empty() {
39            let path = PathBuf::from(value);
40            return path.canonicalize().with_context(|| {
41                format!("OPENCODE_BINARY points to missing path: {}", path.display())
42            });
43        }
44    }
45
46    let candidate = default_pinned_binary_path(base_dir);
47    if candidate.exists() {
48        return candidate
49            .canonicalize()
50            .with_context(|| format!("Failed to canonicalize {}", candidate.display()));
51    }
52
53    // Fall back to "opencode" in PATH
54    tracing::warn!(
55        "No pinned OpenCode binary found at {}; falling back to 'opencode' in PATH",
56        candidate.display()
57    );
58    Ok(PathBuf::from("opencode"))
59}
60
61/// Parse launcher args from `OPENCODE_BINARY_ARGS` environment variable.
62///
63/// Splits on whitespace. Returns empty Vec if unset or empty.
64///
65/// Note: This uses simple whitespace splitting and does not support shell-style
66/// quoting. Arguments containing spaces (e.g., `--message "hello world"`) will
67/// be incorrectly split. This is acceptable for the documented use case
68/// (`--yes opencode-ai@1.14.33`).
69pub fn parse_launcher_args() -> Vec<String> {
70    match std::env::var(OPENCODE_BINARY_ARGS_ENV) {
71        Ok(value) => {
72            let value = value.trim();
73            if value.is_empty() {
74                Vec::new()
75            } else {
76                value.split_whitespace().map(String::from).collect()
77            }
78        }
79        Err(_) => Vec::new(),
80    }
81}
82
83/// Resolve the full launcher configuration for starting `OpenCode`.
84///
85/// When `OPENCODE_BINARY_ARGS` is set, the binary is used as a launcher
86/// (e.g., `bunx`) and is not canonicalized (it should be in PATH).
87///
88/// When `OPENCODE_BINARY_ARGS` is not set, falls back to resolving a direct
89/// binary path via `resolve_opencode_binary`.
90pub fn resolve_launcher_config(base_dir: &Path) -> anyhow::Result<LauncherConfig> {
91    let launcher_args = parse_launcher_args();
92
93    if !launcher_args.is_empty() {
94        // Launcher mode: binary is expected to be in PATH (e.g., bunx, npx)
95        // Don't canonicalize - it's not a file path, it's a command
96        let binary = std::env::var(OPENCODE_BINARY_ENV)
97            .map_or_else(|_| "opencode".to_string(), |v| v.trim().to_string());
98
99        if binary.is_empty() {
100            return Err(anyhow!(
101                "OPENCODE_BINARY_ARGS is set but OPENCODE_BINARY is empty.\n\
102                 When using launcher args, set OPENCODE_BINARY to the launcher command (e.g., 'bunx')."
103            ));
104        }
105
106        tracing::debug!(
107            binary = %binary,
108            launcher_args = ?launcher_args,
109            "using launcher mode for OpenCode"
110        );
111
112        return Ok(LauncherConfig {
113            binary,
114            launcher_args,
115        });
116    }
117
118    // Direct binary mode: resolve and canonicalize the path
119    let binary = resolve_opencode_binary(base_dir)?;
120    Ok(LauncherConfig {
121        binary: binary.to_string_lossy().to_string(),
122        launcher_args: Vec::new(),
123    })
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use serial_test::serial;
130
131    #[test]
132    fn normalize_strips_v_prefix() {
133        assert_eq!(normalize_version("v1.14.33"), "1.14.33");
134        assert_eq!(normalize_version("1.14.33"), "1.14.33");
135        assert_eq!(normalize_version("  v1.14.33 "), "1.14.33");
136    }
137
138    #[test]
139    fn validate_exact_version_enforces_pinned() {
140        validate_exact_version(Some(PINNED_OPENCODE_VERSION)).unwrap();
141        validate_exact_version(Some(&format!("v{PINNED_OPENCODE_VERSION}"))).unwrap();
142        assert!(validate_exact_version(Some("1.14.18")).is_err());
143        assert!(validate_exact_version(None).is_err());
144    }
145
146    #[test]
147    fn default_pinned_binary_path_uses_repo_local_recipe() {
148        let base = Path::new("/tmp/project");
149        assert_eq!(
150            default_pinned_binary_path(base),
151            PathBuf::from(format!(
152                "/tmp/project/.opencode/bin/opencode-v{PINNED_OPENCODE_VERSION}"
153            ))
154        );
155    }
156
157    #[test]
158    #[serial(env)]
159    fn parse_launcher_args_empty_when_unset() {
160        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
161        unsafe { std::env::remove_var(OPENCODE_BINARY_ARGS_ENV) };
162        assert!(parse_launcher_args().is_empty());
163    }
164
165    #[test]
166    #[serial(env)]
167    fn parse_launcher_args_splits_on_whitespace() {
168        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
169        unsafe { std::env::set_var(OPENCODE_BINARY_ARGS_ENV, "opencode-ai@1.14.33") };
170        assert_eq!(parse_launcher_args(), vec!["opencode-ai@1.14.33"]);
171
172        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
173        unsafe { std::env::set_var(OPENCODE_BINARY_ARGS_ENV, "--yes opencode-ai@1.14.33") };
174        assert_eq!(parse_launcher_args(), vec!["--yes", "opencode-ai@1.14.33"]);
175
176        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
177        unsafe { std::env::remove_var(OPENCODE_BINARY_ARGS_ENV) };
178    }
179
180    #[test]
181    #[serial(env)]
182    fn parse_launcher_args_empty_string_returns_empty() {
183        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
184        unsafe { std::env::set_var(OPENCODE_BINARY_ARGS_ENV, "   ") };
185        assert!(parse_launcher_args().is_empty());
186
187        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
188        unsafe { std::env::remove_var(OPENCODE_BINARY_ARGS_ENV) };
189    }
190
191    #[test]
192    #[serial(env)]
193    fn resolve_launcher_config_launcher_mode() {
194        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
195        unsafe {
196            std::env::set_var(OPENCODE_BINARY_ENV, "bunx");
197            std::env::set_var(OPENCODE_BINARY_ARGS_ENV, "opencode-ai@1.14.33");
198        }
199
200        let base = Path::new("/tmp/project");
201        let config = resolve_launcher_config(base).unwrap();
202
203        assert_eq!(config.binary, "bunx");
204        assert_eq!(config.launcher_args, vec!["opencode-ai@1.14.33"]);
205
206        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
207        unsafe {
208            std::env::remove_var(OPENCODE_BINARY_ENV);
209            std::env::remove_var(OPENCODE_BINARY_ARGS_ENV);
210        }
211    }
212
213    #[test]
214    #[serial(env)]
215    fn resolve_launcher_config_errors_when_args_set_but_binary_empty() {
216        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
217        unsafe {
218            std::env::set_var(OPENCODE_BINARY_ENV, "   ");
219            std::env::set_var(OPENCODE_BINARY_ARGS_ENV, "opencode-ai@1.14.33");
220        }
221
222        let base = Path::new("/tmp/project");
223        let result = resolve_launcher_config(base);
224        assert!(result.is_err());
225        assert!(
226            result
227                .unwrap_err()
228                .to_string()
229                .contains("OPENCODE_BINARY is empty")
230        );
231
232        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
233        unsafe {
234            std::env::remove_var(OPENCODE_BINARY_ENV);
235            std::env::remove_var(OPENCODE_BINARY_ARGS_ENV);
236        }
237    }
238}