Avoid overwriting invalid or missing OpenCode config

This commit is contained in:
2026-01-24 02:40:00 +00:00
parent e9d6a34b43
commit 05bf62c88d

View File

@@ -300,25 +300,106 @@ fn resolve_opencode_config_path() -> PathBuf {
.join("opencode.json")
}
pub async fn ensure_global_config(mcp: &McpRegistry) -> anyhow::Result<()> {
let config_path = resolve_opencode_config_path();
if let Some(parent) = config_path.parent() {
enum ConfigLoadResult {
Missing,
Invalid(String),
Ok(Value),
}
async fn load_config_object(path: &Path) -> ConfigLoadResult {
if !path.exists() {
return ConfigLoadResult::Missing;
}
let contents = match tokio::fs::read_to_string(path).await {
Ok(contents) => contents,
Err(err) => return ConfigLoadResult::Invalid(err.to_string()),
};
let parsed: Value = match serde_json::from_str(&contents) {
Ok(value) => value,
Err(err) => return ConfigLoadResult::Invalid(err.to_string()),
};
if !parsed.is_object() {
return ConfigLoadResult::Invalid("OpenCode config root is not a JSON object".to_string());
}
ConfigLoadResult::Ok(parsed)
}
async fn backup_config(path: &Path) {
if !path.exists() {
return;
}
let parent = match path.parent() {
Some(parent) => parent,
None => return,
};
let filename = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("opencode.json");
let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S");
let backup_path = parent.join(format!("{}.bak.{}", filename, timestamp));
if let Err(err) = tokio::fs::copy(path, &backup_path).await {
tracing::warn!(path = %path.display(), error = %err, "Failed to backup OpenCode config");
}
}
async fn write_json_atomic(path: &Path, root: &Value) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let mut root: Value = if config_path.exists() {
let contents = tokio::fs::read_to_string(&config_path)
.await
.unwrap_or_default();
serde_json::from_str(&contents).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
backup_config(path).await;
if !root.is_object() {
root = json!({});
let payload = serde_json::to_string_pretty(root)?;
let parent = path
.parent()
.ok_or_else(|| anyhow::anyhow!("OpenCode config path has no parent"))?;
let filename = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("opencode.json");
let tmp_path = parent.join(format!(
".{}.tmp.{}",
filename,
chrono::Utc::now().timestamp_millis()
));
tokio::fs::write(&tmp_path, payload).await?;
if let Err(err) = tokio::fs::rename(&tmp_path, path).await {
let _ = tokio::fs::remove_file(&tmp_path).await;
return Err(err.into());
}
Ok(())
}
pub async fn ensure_global_config(mcp: &McpRegistry) -> anyhow::Result<()> {
let config_path = resolve_opencode_config_path();
let mut root: Value = match load_config_object(&config_path).await {
ConfigLoadResult::Ok(value) => value,
ConfigLoadResult::Missing => {
tracing::warn!(
path = %config_path.display(),
"OpenCode config is missing; skipping global config sync"
);
return Ok(());
}
ConfigLoadResult::Invalid(err) => {
tracing::warn!(
path = %config_path.display(),
error = %err,
"OpenCode config is invalid; skipping global config sync"
);
return Ok(());
}
};
let mcp_configs = mcp.list_configs().await;
let mut mcp_entries = serde_json::Map::new();
for config in mcp_configs.iter().filter(|c| c.enabled) {
@@ -347,14 +428,15 @@ pub async fn ensure_global_config(mcp: &McpRegistry) -> anyhow::Result<()> {
.or_insert_with(|| json!({}))
.as_object_mut()
.expect("tools object");
tools_obj.insert("bash".to_string(), json!(false));
if !tools_obj.contains_key("bash") {
tools_obj.insert("bash".to_string(), json!(false));
}
tools_obj.insert("desktop_*".to_string(), json!(true));
tools_obj.insert("playwright_*".to_string(), json!(true));
tools_obj.insert("browser_*".to_string(), json!(true));
tools_obj.insert("workspace_*".to_string(), json!(true));
let payload = serde_json::to_string_pretty(&root)?;
tokio::fs::write(&config_path, payload).await?;
write_json_atomic(&config_path, &root).await?;
tracing::info!(path = %config_path.display(), "Ensured OpenCode global config");
Ok(())
@@ -379,23 +461,25 @@ fn package_base(spec: &str) -> String {
pub async fn sync_global_plugins(plugins: &HashMap<String, Plugin>) -> anyhow::Result<()> {
let config_path = resolve_opencode_config_path();
if let Some(parent) = config_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let mut root: Value = if config_path.exists() {
let contents = tokio::fs::read_to_string(&config_path)
.await
.unwrap_or_default();
serde_json::from_str(&contents).unwrap_or_else(|_| json!({}))
} else {
json!({})
let mut root: Value = match load_config_object(&config_path).await {
ConfigLoadResult::Ok(value) => value,
ConfigLoadResult::Missing => {
tracing::warn!(
path = %config_path.display(),
"OpenCode config is missing; skipping plugin sync"
);
return Ok(());
}
ConfigLoadResult::Invalid(err) => {
tracing::warn!(
path = %config_path.display(),
error = %err,
"OpenCode config is invalid; skipping plugin sync"
);
return Ok(());
}
};
if !root.is_object() {
root = json!({});
}
let root_obj = root.as_object_mut().expect("config object");
let existing_plugins: Vec<String> = root_obj
.get("plugin")
@@ -430,8 +514,7 @@ pub async fn sync_global_plugins(plugins: &HashMap<String, Plugin>) -> anyhow::R
root_obj.insert("plugin".to_string(), json!(merged));
let payload = serde_json::to_string_pretty(&root)?;
tokio::fs::write(&config_path, payload).await?;
write_json_atomic(&config_path, &root).await?;
tracing::info!(path = %config_path.display(), "Synced OpenCode global plugins");
Ok(())