Files
codeg/src-tauri/src/acp/opencode_plugins.rs
xintaofei 9ab090a2b2 fix(acp): avoid false-positive comment detection on URLs in opencode.json
The naive // and /* check triggered on URLs like https://... inside
string values. Now we only reject the file as JSONC when serde_json
parsing fails AND comment-like sequences are found.
2026-04-12 12:00:47 +08:00

700 lines
22 KiB
Rust

use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use serde::Serialize;
use tokio::io::AsyncBufReadExt;
use crate::web::event_bridge::{emit_event, EventEmitter};
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginStatus {
Installed,
Missing,
}
#[derive(Debug, Clone, Serialize)]
pub struct PluginInfo {
pub name: String,
pub declared_spec: String,
pub installed_version: Option<String>,
pub status: PluginStatus,
}
#[derive(Debug, Clone, Serialize)]
pub struct PluginCheckSummary {
pub config_path: PathBuf,
pub cache_dir: PathBuf,
pub plugins: Vec<PluginInfo>,
pub has_project_config_hint: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum PluginInstallEventKind {
Started,
Log,
Completed,
Failed,
}
#[derive(Debug, Clone, Serialize)]
pub struct PluginInstallEvent {
pub task_id: String,
pub kind: PluginInstallEventKind,
pub payload: String,
}
/// Well-known paths for opencode configuration and cache.
///
/// OpenCode follows XDG conventions on all platforms:
/// config: $XDG_CONFIG_HOME/opencode or ~/.config/opencode
/// cache: $XDG_CACHE_HOME/opencode or ~/.cache/opencode
///
/// We must NOT use `dirs::config_dir()` / `dirs::cache_dir()` because on
/// macOS those return ~/Library/Application Support and ~/Library/Caches,
/// while opencode always uses the XDG paths.
fn opencode_config_path() -> Option<PathBuf> {
xdg_config_home().map(|d| d.join("opencode").join("opencode.json"))
}
fn opencode_cache_dir() -> Option<PathBuf> {
xdg_cache_home().map(|d| d.join("opencode"))
}
fn xdg_config_home() -> Option<PathBuf> {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
}
fn xdg_cache_home() -> Option<PathBuf> {
std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|h| h.join(".cache")))
}
/// Check whether a project directory contains any opencode configuration file.
fn has_project_opencode_config(project_root: &Path) -> bool {
let candidates = [
project_root.join("opencode.json"),
project_root.join("opencode.jsonc"),
project_root.join(".opencode").join("opencode.json"),
project_root.join(".opencode").join("opencode.jsonc"),
];
candidates.iter().any(|p| p.exists())
}
/// Inspect `~/.config/opencode/opencode.json` and `~/.cache/opencode/node_modules/`
/// to determine which declared plugins are installed and which are missing.
pub fn check_opencode_plugins(
project_root: Option<&Path>,
) -> Result<PluginCheckSummary, String> {
let config_path = opencode_config_path()
.ok_or_else(|| "Cannot determine opencode config directory".to_string())?;
let cache_dir = opencode_cache_dir()
.ok_or_else(|| "Cannot determine opencode cache directory".to_string())?;
let has_project_config_hint = project_root
.map(has_project_opencode_config)
.unwrap_or(false);
// If config file doesn't exist, there's nothing to check
if !config_path.exists() {
return Ok(PluginCheckSummary {
config_path,
cache_dir,
plugins: vec![],
has_project_config_hint,
});
}
// Read and parse JSON
let raw = std::fs::read_to_string(&config_path)
.map_err(|e| format!("Failed to read {}: {e}", config_path.display()))?;
let doc: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| format!("Failed to parse {}: {e}", config_path.display()))?;
// Extract plugin[] array
let plugin_array = match doc.get("plugin") {
Some(serde_json::Value::Array(arr)) => arr,
Some(_) => {
return Ok(PluginCheckSummary {
config_path,
cache_dir,
plugins: vec![],
has_project_config_hint,
});
}
None => {
return Ok(PluginCheckSummary {
config_path,
cache_dir,
plugins: vec![],
has_project_config_hint,
});
}
};
// Parse specs, dedup by name
let mut seen_names = HashSet::new();
let mut plugins = Vec::new();
for item in plugin_array {
let spec_str = match item.as_str() {
Some(s) => s,
None => {
eprintln!("[opencode_plugins] Skipping non-string plugin entry: {item}");
continue;
}
};
let (name, declared_spec) = match parse_plugin_spec(spec_str) {
Some(pair) => pair,
None => {
eprintln!("[opencode_plugins] Skipping invalid plugin spec: {spec_str:?}");
continue;
}
};
if !seen_names.insert(name.clone()) {
continue; // duplicate, skip
}
// Check node_modules/<name>/package.json
let pkg_json_path = cache_dir
.join("node_modules")
.join(&name)
.join("package.json");
let (status, installed_version) = if pkg_json_path.exists() {
let version = std::fs::read_to_string(&pkg_json_path)
.ok()
.and_then(|content| {
serde_json::from_str::<serde_json::Value>(&content)
.ok()?
.get("version")?
.as_str()
.map(|s| s.to_string())
});
(PluginStatus::Installed, version)
} else {
(PluginStatus::Missing, None)
};
plugins.push(PluginInfo {
name,
declared_spec,
installed_version,
status,
});
}
Ok(PluginCheckSummary {
config_path,
cache_dir,
plugins,
has_project_config_hint,
})
}
/// Locate a usable bun binary.
/// Priority: opencode-bundled bun → system bun → error.
pub fn resolve_bun_binary() -> Result<PathBuf, String> {
let cache_dir = opencode_cache_dir();
// Try opencode-bundled bun
if let Some(ref dir) = cache_dir {
let candidates = if cfg!(windows) {
vec![dir.join("bin").join("bun.exe")]
} else {
vec![dir.join("bin").join("bun")]
};
for candidate in candidates {
if candidate.exists() {
return Ok(candidate);
}
}
}
// Fallback to system bun
if let Ok(system_bun) = which::which("bun") {
return Ok(system_bun);
}
Err(
"bun binary not found. Neither opencode-bundled bun (~/.cache/opencode/bin/bun) \
nor system bun is available."
.to_string(),
)
}
/// Detect whether a JSON string contains comments (// or /*).
fn json_has_comments(raw: &str) -> bool {
raw.contains("//") || raw.contains("/*")
}
/// Write a timestamped backup of a file, keeping only the most recent `keep` copies.
fn write_backup_and_prune(path: &Path, content: &str, keep: usize) -> Result<(), String> {
let now = chrono::Local::now().format("%Y-%m-%dT%H-%M-%S");
let backup_path = path.with_file_name(format!(
"{}.bak.{now}",
path.file_name().unwrap_or_default().to_string_lossy()
));
fs::write(&backup_path, content)
.map_err(|e| format!("Failed to write backup {}: {e}", backup_path.display()))?;
// Prune old backups
let parent = path.parent().ok_or("No parent directory")?;
let stem = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let prefix = format!("{stem}.bak.");
let mut backups: Vec<_> = fs::read_dir(parent)
.map_err(|e| e.to_string())?
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.file_name()
.to_string_lossy()
.starts_with(&prefix)
})
.collect();
// Sort by name descending (timestamp in name → newest first)
backups.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
for old in backups.iter().skip(keep) {
let _ = fs::remove_file(old.path());
}
Ok(())
}
/// Atomically rewrite opencode.json: read → backup → mutate → write temp → rename.
pub(crate) fn atomic_rewrite_opencode_json(
path: &Path,
mutator: impl FnOnce(&mut serde_json::Value) -> Result<(), String>,
) -> Result<(), String> {
let raw = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
// Try parsing first. If serde_json succeeds the file is valid JSON
// and any "//" or "/*" sequences live inside string values — not real
// comments. Only reject when parsing *fails* and the raw text looks
// like it might be JSONC (actual comments would cause a parse error).
if serde_json::from_str::<serde_json::Value>(&raw).is_err() && json_has_comments(&raw) {
return Err(
"opencode.json appears to be JSONC (contains comments). Refusing to rewrite to avoid \
data loss. Please edit the file manually."
.to_string(),
);
}
write_backup_and_prune(path, &raw, 3)?;
let mut doc: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| format!("Failed to parse {}: {e}", path.display()))?;
mutator(&mut doc)?;
let new_raw = serde_json::to_string_pretty(&doc)
.map_err(|e| format!("Failed to serialize JSON: {e}"))?;
let tmp_path = path.with_extension("json.tmp");
fs::write(&tmp_path, &new_raw)
.map_err(|e| format!("Failed to write temp file: {e}"))?;
fs::rename(&tmp_path, path)
.map_err(|e| format!("Failed to rename temp file: {e}"))?;
Ok(())
}
/// Check whether a plugin spec uses a floating version tag like `@latest`.
pub fn spec_has_floating_version(spec: &str) -> bool {
if let Some((_, full)) = parse_plugin_spec(spec) {
full.ends_with("@latest")
} else {
false
}
}
/// After a successful install, replace `@latest` specs in opencode.json with
/// the actual installed version read from node_modules. This prevents
/// opencode from hitting the npm registry on every startup.
fn pin_latest_specs(
config_path: &Path,
cache_dir: &Path,
specs: &[(String, String)], // (name, original_declared_spec)
) -> Result<usize, String> {
let mut pinned = 0;
// Collect name → installed_version for specs that have @latest
let mut pin_map: Vec<(String, String)> = Vec::new();
for (name, declared) in specs {
if !declared.ends_with("@latest") {
continue;
}
let pkg_json = cache_dir
.join("node_modules")
.join(name)
.join("package.json");
if let Ok(content) = fs::read_to_string(&pkg_json) {
if let Some(version) = serde_json::from_str::<serde_json::Value>(&content)
.ok()
.and_then(|v| v.get("version")?.as_str().map(|s| s.to_string()))
{
pin_map.push((name.clone(), version));
}
}
}
if pin_map.is_empty() {
return Ok(0);
}
atomic_rewrite_opencode_json(config_path, |doc| {
if let Some(arr) = doc
.as_object_mut()
.and_then(|obj| obj.get_mut("plugin"))
.and_then(|v| v.as_array_mut())
{
for item in arr.iter_mut() {
if let Some(spec_str) = item.as_str() {
if let Some((parsed_name, _)) = parse_plugin_spec(spec_str) {
if let Some((_, version)) =
pin_map.iter().find(|(n, _)| *n == parsed_name)
{
*item = serde_json::Value::String(format!(
"{parsed_name}@{version}"
));
pinned += 1;
}
}
}
}
}
Ok(())
})?;
Ok(pinned)
}
static PLUGIN_OP_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
const PLUGIN_INSTALL_EVENT: &str = "app://opencode-plugin-install";
/// Packages that must never be uninstalled (opencode internals).
fn is_protected_package(name: &str) -> bool {
name.starts_with("@opencode-ai/")
}
fn emit_plugin_event(
emitter: &EventEmitter,
task_id: &str,
kind: PluginInstallEventKind,
payload: impl Into<String>,
) {
emit_event(
emitter,
PLUGIN_INSTALL_EVENT,
PluginInstallEvent {
task_id: task_id.to_string(),
kind,
payload: payload.into(),
},
);
}
/// Install missing plugins by running `bun add` in the opencode cache directory.
/// Streams progress events to the given emitter.
pub async fn install_missing_plugins(
names: Option<Vec<String>>,
task_id: String,
emitter: &EventEmitter,
) -> Result<(), String> {
let _guard = PLUGIN_OP_LOCK.try_lock().map_err(|_| {
"Another plugin operation is in progress".to_string()
})?;
emit_plugin_event(emitter, &task_id, PluginInstallEventKind::Started, "");
// Re-check current state
let summary = check_opencode_plugins(None).inspect_err(|e| {
emit_plugin_event(emitter, &task_id, PluginInstallEventKind::Failed, e);
})?;
let missing: Vec<&PluginInfo> = summary
.plugins
.iter()
.filter(|p| p.status == PluginStatus::Missing)
.filter(|p| match &names {
Some(list) => list.contains(&p.name),
None => true,
})
.collect();
if missing.is_empty() {
// Nothing to install, but still pin any @latest specs
let all_specs: Vec<(String, String)> = summary
.plugins
.iter()
.map(|p| (p.name.clone(), p.declared_spec.clone()))
.collect();
match pin_latest_specs(&summary.config_path, &summary.cache_dir, &all_specs) {
Ok(n) if n > 0 => {
emit_plugin_event(
emitter,
&task_id,
PluginInstallEventKind::Log,
format!("Pinned {n} @latest plugin(s) to installed versions in opencode.json"),
);
emit_plugin_event(
emitter,
&task_id,
PluginInstallEventKind::Completed,
format!("Pinned {n} @latest plugin(s) — no missing plugins to install"),
);
}
Err(e) => {
emit_plugin_event(
emitter,
&task_id,
PluginInstallEventKind::Failed,
format!("Failed to pin @latest versions: {e}"),
);
}
_ => {
emit_plugin_event(
emitter,
&task_id,
PluginInstallEventKind::Completed,
"Nothing to install — all plugins are already present",
);
}
}
return Ok(());
}
let specs: Vec<String> = missing.iter().map(|p| p.declared_spec.clone()).collect();
let names_display: Vec<&str> = missing.iter().map(|p| p.name.as_str()).collect();
// Resolve bun
let bun = resolve_bun_binary().inspect_err(|e| {
emit_plugin_event(emitter, &task_id, PluginInstallEventKind::Failed, e);
})?;
emit_plugin_event(
emitter,
&task_id,
PluginInstallEventKind::Log,
format!("Installing: {}", names_display.join(", ")),
);
// Spawn bun add
let mut cmd = crate::process::tokio_command(&bun);
cmd.arg("add")
.args(&specs)
.current_dir(&summary.cache_dir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().map_err(|e| {
let msg = format!("Failed to spawn bun: {e}");
emit_plugin_event(emitter, &task_id, PluginInstallEventKind::Failed, &msg);
msg
})?;
// Stream stdout and stderr concurrently
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let emitter_clone = emitter.clone();
let task_id_clone = task_id.clone();
let stdout_handle = tokio::spawn({
let emitter = emitter_clone.clone();
let task_id = task_id_clone.clone();
async move {
if let Some(stdout) = stdout {
let reader = tokio::io::BufReader::new(stdout);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
emit_plugin_event(&emitter, &task_id, PluginInstallEventKind::Log, &line);
}
}
}
});
let stderr_handle = tokio::spawn({
let emitter = emitter_clone;
let task_id = task_id_clone;
async move {
if let Some(stderr) = stderr {
let reader = tokio::io::BufReader::new(stderr);
let mut lines = reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
emit_plugin_event(&emitter, &task_id, PluginInstallEventKind::Log, &line);
}
}
}
});
let _ = tokio::join!(stdout_handle, stderr_handle);
let exit_status = child.wait().await.map_err(|e| {
let msg = format!("Failed to wait for bun process: {e}");
emit_plugin_event(emitter, &task_id, PluginInstallEventKind::Failed, &msg);
msg
})?;
if exit_status.success() {
// Pin @latest specs to actual installed versions to avoid
// opencode hitting the npm registry on every startup.
// Pin ALL plugins (not just the ones we installed), so already-installed
// @latest plugins also get pinned.
let spec_pairs: Vec<(String, String)> = summary
.plugins
.iter()
.map(|p| (p.name.clone(), p.declared_spec.clone()))
.collect();
match pin_latest_specs(&summary.config_path, &summary.cache_dir, &spec_pairs) {
Ok(n) if n > 0 => {
emit_plugin_event(
emitter,
&task_id,
PluginInstallEventKind::Log,
format!("Pinned {n} @latest plugin(s) to installed versions in opencode.json"),
);
}
Err(e) => {
emit_plugin_event(
emitter,
&task_id,
PluginInstallEventKind::Log,
format!("Warning: could not pin @latest versions: {e}"),
);
}
_ => {}
}
emit_plugin_event(
emitter,
&task_id,
PluginInstallEventKind::Completed,
"All plugins installed successfully",
);
Ok(())
} else {
let msg = format!("bun exited with code {}", exit_status.code().unwrap_or(-1));
emit_plugin_event(emitter, &task_id, PluginInstallEventKind::Failed, &msg);
Err(msg)
}
}
/// Uninstall a single plugin: remove from opencode.json, then `bun remove` from cache.
pub async fn uninstall_plugin(name: String) -> Result<PluginCheckSummary, String> {
let _guard = PLUGIN_OP_LOCK.try_lock().map_err(|_| {
"Another plugin operation is in progress".to_string()
})?;
if is_protected_package(&name) {
return Err(format!("Cannot uninstall {name}: it is an internal opencode package"));
}
let config_path = opencode_config_path()
.ok_or_else(|| "Cannot determine opencode config directory".to_string())?;
let cache_dir = opencode_cache_dir()
.ok_or_else(|| "Cannot determine opencode cache directory".to_string())?;
// Step 1: Remove from opencode.json if declared
if config_path.exists() {
let _ = atomic_rewrite_opencode_json(&config_path, |doc| {
if let Some(arr) = doc
.as_object_mut()
.and_then(|obj| obj.get_mut("plugin"))
.and_then(|v| v.as_array_mut())
{
arr.retain(|item| {
if let Some(spec) = item.as_str() {
match parse_plugin_spec(spec) {
Some((parsed_name, _)) => parsed_name != name,
None => true,
}
} else {
true
}
});
}
Ok(())
});
}
// Step 2: bun remove
let bun = resolve_bun_binary()?;
let output = crate::process::tokio_command(&bun)
.arg("remove")
.arg(&name)
.current_dir(&cache_dir)
.output()
.await
.map_err(|e| format!("Failed to run bun remove: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("not found") {
return Err(format!("bun remove failed: {stderr}"));
}
}
// Return fresh summary
check_opencode_plugins(None)
}
/// Parse a plugin spec string from opencode.json `plugin[]` into (package_name, full_spec).
///
/// Examples:
/// - `"foo"` → `Some(("foo", "foo"))`
/// - `"foo@latest"` → `Some(("foo", "foo@latest"))`
/// - `"foo@1.2.3"` → `Some(("foo", "foo@1.2.3"))`
/// - `"@scope/name"` → `Some(("@scope/name", "@scope/name"))`
/// - `"@scope/name@1.2.3"` → `Some(("@scope/name", "@scope/name@1.2.3"))`
/// - `""` → `None`
pub fn parse_plugin_spec(spec: &str) -> Option<(String, String)> {
let spec = spec.trim();
if spec.is_empty() {
return None;
}
let full_spec = spec.to_string();
if spec.starts_with('@') {
// Scoped package: @scope/name or @scope/name@version
let without_at = spec.strip_prefix('@')?;
let slash_pos = without_at.find('/')?;
let after_slash = &without_at[slash_pos + 1..];
// Look for @ that separates name from version
if let Some(version_at) = after_slash.find('@') {
let name = &spec[..1 + slash_pos + 1 + version_at]; // @scope/name
Some((name.to_string(), full_spec))
} else {
// No version part
Some((spec.to_string(), full_spec))
}
} else {
// Unscoped: name or name@version
if let Some(at_pos) = spec.find('@') {
let name = &spec[..at_pos];
if name.is_empty() {
return None; // bare "@" is invalid
}
Some((name.to_string(), full_spec))
} else {
Some((spec.to_string(), full_spec))
}
}
}