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.17";
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.17 serve --hostname ... --port ...`
12///
13/// Example: `OPENCODE_BINARY=bunx OPENCODE_BINARY_ARGS="--yes opencode-ai@1.3.17"`
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.17"]`
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    // Fall back to "opencode" in PATH
79    tracing::warn!(
80        "No pinned OpenCode binary found at {}; falling back to 'opencode' in PATH",
81        candidate.display()
82    );
83    Ok(PathBuf::from("opencode"))
84}
85
86/// Parse launcher args from `OPENCODE_BINARY_ARGS` environment variable.
87///
88/// Splits on whitespace. Returns empty Vec if unset or empty.
89///
90/// Note: This uses simple whitespace splitting and does not support shell-style
91/// quoting. Arguments containing spaces (e.g., `--message "hello world"`) will
92/// be incorrectly split. This is acceptable for the documented use case
93/// (`--yes opencode-ai@1.3.17`).
94pub fn parse_launcher_args() -> Vec<String> {
95    match std::env::var(OPENCODE_BINARY_ARGS_ENV) {
96        Ok(value) => {
97            let value = value.trim();
98            if value.is_empty() {
99                Vec::new()
100            } else {
101                value.split_whitespace().map(String::from).collect()
102            }
103        }
104        Err(_) => Vec::new(),
105    }
106}
107
108/// Resolve the full launcher configuration for starting `OpenCode`.
109///
110/// When `OPENCODE_BINARY_ARGS` is set, the binary is used as a launcher
111/// (e.g., `bunx`) and is not canonicalized (it should be in PATH).
112///
113/// When `OPENCODE_BINARY_ARGS` is not set, falls back to resolving a direct
114/// binary path via `resolve_opencode_binary`.
115pub fn resolve_launcher_config(base_dir: &Path) -> anyhow::Result<LauncherConfig> {
116    let launcher_args = parse_launcher_args();
117
118    if !launcher_args.is_empty() {
119        // Launcher mode: binary is expected to be in PATH (e.g., bunx, npx)
120        // Don't canonicalize - it's not a file path, it's a command
121        let binary = std::env::var(OPENCODE_BINARY_ENV)
122            .map_or_else(|_| "opencode".to_string(), |v| v.trim().to_string());
123
124        if binary.is_empty() {
125            return Err(anyhow!(
126                "OPENCODE_BINARY_ARGS is set but OPENCODE_BINARY is empty.\n\
127                 When using launcher args, set OPENCODE_BINARY to the launcher command (e.g., 'bunx')."
128            ));
129        }
130
131        tracing::debug!(
132            binary = %binary,
133            launcher_args = ?launcher_args,
134            "using launcher mode for OpenCode"
135        );
136
137        return Ok(LauncherConfig {
138            binary,
139            launcher_args,
140        });
141    }
142
143    // Direct binary mode: resolve and canonicalize the path
144    let binary = resolve_opencode_binary(base_dir)?;
145    Ok(LauncherConfig {
146        binary: binary.to_string_lossy().to_string(),
147        launcher_args: Vec::new(),
148    })
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use serial_test::serial;
155
156    #[test]
157    fn normalize_strips_v_prefix() {
158        assert_eq!(normalize_version("v1.3.17"), "1.3.17");
159        assert_eq!(normalize_version("1.3.17"), "1.3.17");
160        assert_eq!(normalize_version("  v1.3.17 "), "1.3.17");
161    }
162
163    #[test]
164    fn validate_exact_version_enforces_pinned() {
165        validate_exact_version(Some(PINNED_OPENCODE_VERSION)).unwrap();
166        validate_exact_version(Some(&format!("v{PINNED_OPENCODE_VERSION}"))).unwrap();
167        assert!(validate_exact_version(Some("1.3.14")).is_err());
168        assert!(validate_exact_version(None).is_err());
169    }
170
171    #[test]
172    fn default_pinned_binary_path_uses_repo_local_recipe() {
173        let base = Path::new("/tmp/project");
174        assert_eq!(
175            default_pinned_binary_path(base),
176            PathBuf::from(format!(
177                "/tmp/project/.opencode/bin/opencode-v{PINNED_OPENCODE_VERSION}"
178            ))
179        );
180    }
181
182    #[test]
183    #[serial(env)]
184    fn parse_launcher_args_empty_when_unset() {
185        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
186        unsafe { std::env::remove_var(OPENCODE_BINARY_ARGS_ENV) };
187        assert!(parse_launcher_args().is_empty());
188    }
189
190    #[test]
191    #[serial(env)]
192    fn parse_launcher_args_splits_on_whitespace() {
193        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
194        unsafe { std::env::set_var(OPENCODE_BINARY_ARGS_ENV, "opencode-ai@1.3.17") };
195        assert_eq!(parse_launcher_args(), vec!["opencode-ai@1.3.17"]);
196
197        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
198        unsafe { std::env::set_var(OPENCODE_BINARY_ARGS_ENV, "--yes opencode-ai@1.3.17") };
199        assert_eq!(parse_launcher_args(), vec!["--yes", "opencode-ai@1.3.17"]);
200
201        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
202        unsafe { std::env::remove_var(OPENCODE_BINARY_ARGS_ENV) };
203    }
204
205    #[test]
206    #[serial(env)]
207    fn parse_launcher_args_empty_string_returns_empty() {
208        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
209        unsafe { std::env::set_var(OPENCODE_BINARY_ARGS_ENV, "   ") };
210        assert!(parse_launcher_args().is_empty());
211
212        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
213        unsafe { std::env::remove_var(OPENCODE_BINARY_ARGS_ENV) };
214    }
215
216    #[test]
217    #[serial(env)]
218    fn resolve_launcher_config_launcher_mode() {
219        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
220        unsafe {
221            std::env::set_var(OPENCODE_BINARY_ENV, "bunx");
222            std::env::set_var(OPENCODE_BINARY_ARGS_ENV, "opencode-ai@1.3.17");
223        }
224
225        let base = Path::new("/tmp/project");
226        let config = resolve_launcher_config(base).unwrap();
227
228        assert_eq!(config.binary, "bunx");
229        assert_eq!(config.launcher_args, vec!["opencode-ai@1.3.17"]);
230
231        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
232        unsafe {
233            std::env::remove_var(OPENCODE_BINARY_ENV);
234            std::env::remove_var(OPENCODE_BINARY_ARGS_ENV);
235        }
236    }
237
238    #[test]
239    #[serial(env)]
240    fn resolve_launcher_config_errors_when_args_set_but_binary_empty() {
241        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
242        unsafe {
243            std::env::set_var(OPENCODE_BINARY_ENV, "   ");
244            std::env::set_var(OPENCODE_BINARY_ARGS_ENV, "opencode-ai@1.3.17");
245        }
246
247        let base = Path::new("/tmp/project");
248        let result = resolve_launcher_config(base);
249        assert!(result.is_err());
250        assert!(
251            result
252                .unwrap_err()
253                .to_string()
254                .contains("OPENCODE_BINARY is empty")
255        );
256
257        // SAFETY: Test serialized by #[serial(env)], preventing concurrent env access.
258        unsafe {
259            std::env::remove_var(OPENCODE_BINARY_ENV);
260            std::env::remove_var(OPENCODE_BINARY_ARGS_ENV);
261        }
262    }
263}