opencode_orchestrator_mcp/
version.rs1use 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";
8pub const OPENCODE_BINARY_ARGS_ENV: &str = "OPENCODE_BINARY_ARGS";
17
18#[derive(Debug, Clone)]
24pub struct LauncherConfig {
25 pub binary: String,
27 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
87pub 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
109pub 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 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 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 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 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 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 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 unsafe { std::env::set_var(OPENCODE_BINARY_ARGS_ENV, " ") };
209 assert!(parse_launcher_args().is_empty());
210
211 unsafe { std::env::remove_var(OPENCODE_BINARY_ARGS_ENV) };
213 }
214
215 #[test]
216 #[serial(env)]
217 fn resolve_launcher_config_launcher_mode() {
218 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 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 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 unsafe {
258 std::env::remove_var(OPENCODE_BINARY_ENV);
259 std::env::remove_var(OPENCODE_BINARY_ARGS_ENV);
260 }
261 }
262}