use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::path::{Path, PathBuf};
pub(crate) const OPENCODE_MISE_TOOL: &str = "npm:@opencode-ai/cli";
pub(crate) const OPENCODE_MISE_SPEC: &str = "npm:@opencode-ai/cli@next";
const OPENCODE_ALLOW_BUILDS: &str = "@opencode-ai/cli";
pub(crate) fn ensure_opencode_allow_builds() -> Result<bool> {
let mut changed = false;
for path in mise_config_files()? {
changed |= patch_config_file(&path)?;
}
Ok(changed)
}
fn mise_config_files() -> Result<Vec<PathBuf>> {
let output = std::process::Command::new("mise")
.args(["config", "ls", "--json"])
.output()
.context("failed to run `mise config ls --json`")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!(
"`mise config ls --json` failed with exit code {}: {}",
output.status.code().unwrap_or(1),
stderr.trim()
);
}
parse_mise_config_listing(&output.stdout)
}
fn parse_mise_config_listing(bytes: &[u8]) -> Result<Vec<PathBuf>> {
let listing: Vec<MiseConfigFile> =
serde_json::from_slice(bytes).context("failed to parse `mise config ls --json` output")?;
Ok(listing
.into_iter()
.filter(|file| file.tools.iter().any(|tool| tool == OPENCODE_MISE_TOOL))
.map(|file| PathBuf::from(file.path))
.collect())
}
#[derive(Debug, Deserialize)]
struct MiseConfigFile {
path: String,
#[serde(default)]
tools: Vec<String>,
}
fn patch_config_file(path: &Path) -> Result<bool> {
let config = match std::fs::read_to_string(path) {
Ok(config) => config,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(err) => return Err(err).with_context(|| format!("failed to read {}", path.display())),
};
let patched = patch_opencode_entry(&config);
if patched == config {
return Ok(false);
}
std::fs::write(path, patched).with_context(|| format!("failed to write {}", path.display()))?;
Ok(true)
}
fn patch_opencode_entry(config: &str) -> String {
let plain = format!("{OPENCODE_MISE_TOOL:?} = \"next\"");
let entry = format!(
"{OPENCODE_MISE_TOOL:?} = {{ version = \"next\", allow_builds = [{OPENCODE_ALLOW_BUILDS:?}] }}"
);
config.replace(&plain, &entry)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn patches_the_plain_entry_written_by_mise_use() {
let config = "[tools]\n\"npm:@opencode-ai/cli\" = \"next\"\nnode = \"latest\"\n";
assert_eq!(
patch_opencode_entry(config),
"[tools]\n\"npm:@opencode-ai/cli\" = { version = \"next\", allow_builds = [\"@opencode-ai/cli\"] }\nnode = \"latest\"\n"
);
}
#[test]
fn leaves_entries_that_already_allow_builds_untouched() {
let config = "[tools]\n\"npm:@opencode-ai/cli\" = { version = \"next\", allow_builds = [\"@opencode-ai/cli\"] }\n";
assert_eq!(patch_opencode_entry(config), config);
}
#[test]
fn leaves_configs_without_the_tool_untouched() {
let config = "[tools]\nnode = \"latest\"\n";
assert_eq!(patch_opencode_entry(config), config);
}
#[test]
fn config_listing_keeps_only_files_declaring_the_opencode_tool() {
let listing = br#"[
{
"path": "/work/project/.mise.toml",
"tools": ["rust", "npm:@opencode-ai/cli"]
},
{
"path": "/work/project/mise.dev.toml",
"tools": ["node"]
},
{
"path": "/home/user/.config/mise/config.toml",
"tools": ["npm:@opencode-ai/cli", "just"]
}
]"#;
assert_eq!(
parse_mise_config_listing(listing).unwrap(),
vec![
PathBuf::from("/work/project/.mise.toml"),
PathBuf::from("/home/user/.config/mise/config.toml"),
]
);
}
#[test]
fn config_listing_accepts_entries_without_a_tools_list() {
let listing = br#"[{"path": "/work/project/.mise.toml"}]"#;
assert_eq!(
parse_mise_config_listing(listing).unwrap(),
Vec::<PathBuf>::new()
);
}
#[test]
fn patches_a_plain_local_config_file_in_place() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("mise.toml");
std::fs::write(
&path,
"[tools]\n\"npm:@opencode-ai/cli\" = \"next\"\nnode = \"latest\"\n",
)
.unwrap();
assert!(patch_config_file(&path).unwrap());
let patched = std::fs::read_to_string(&path).unwrap();
assert!(patched.contains("allow_builds"));
assert!(
!patch_config_file(&path).unwrap(),
"second patch must be a no-op"
);
}
}