feat(acp): auto-pin @latest plugin specs to installed versions after install

After a successful plugin install, read the actual installed version from
node_modules and replace @latest in opencode.json with the pinned version.
This prevents opencode from hitting the npm registry on every startup.

Also add a preflight warning when @latest specs are detected, guiding
the user to install via the plugin manager to auto-pin.
This commit is contained in:
xintaofei
2026-04-12 11:47:01 +08:00
parent 6459e5286a
commit 4397b0eae7
2 changed files with 123 additions and 1 deletions

View File

@@ -310,6 +310,76 @@ pub(crate) fn atomic_rewrite_opencode_json(
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";
@@ -446,6 +516,32 @@ pub async fn install_missing_plugins(
})?;
if exit_status.success() {
// Pin @latest specs to actual installed versions to avoid
// opencode hitting the npm registry on every startup.
let spec_pairs: Vec<(String, String)> = missing
.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,

View File

@@ -350,7 +350,7 @@ async fn check_binary_environment(
// OpenCode plugin checks
if agent_type == AgentType::OpenCode {
use crate::acp::opencode_plugins::{self, PluginStatus};
use crate::acp::opencode_plugins::{self, PluginStatus, spec_has_floating_version};
match opencode_plugins::check_opencode_plugins(None) {
Ok(summary) => {
let missing: Vec<_> = summary
@@ -395,6 +395,32 @@ async fn check_binary_environment(
});
}
// Warn about @latest specs that cause slow startup
let floating: Vec<&str> = summary
.plugins
.iter()
.filter(|p| spec_has_floating_version(&p.declared_spec))
.map(|p| p.name.as_str())
.collect();
if !floating.is_empty() {
checks.push(CheckItem {
check_id: "opencode_plugins_floating".into(),
label: "Plugin versions".into(),
status: CheckStatus::Warn,
message: format!(
"{} plugin(s) use @latest which forces a network check on every startup: {}. \
Install via the plugin manager to auto-pin versions.",
floating.len(),
floating.join(", ")
),
fixes: vec![FixAction {
label: "Install Plugins".into(),
kind: FixActionKind::InstallOpencodePlugins,
payload: String::new(),
}],
});
}
// Project-level config hint
if summary.has_project_config_hint {
checks.push(CheckItem {