Skip to main content

opencode_orchestrator_mcp/
version.rs

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