Files
openagent/src/api/system.rs
2026-01-18 10:34:26 +00:00

1100 lines
41 KiB
Rust

//! System component management API.
//!
//! Provides endpoints to query and update system components like OpenCode
//! and oh-my-opencode.
use std::pin::Pin;
use std::sync::Arc;
use axum::{
extract::{Path, State},
http::StatusCode,
response::{
sse::{Event, Sse},
Json,
},
routing::{get, post},
Router,
};
use futures::stream::Stream;
use serde::Serialize;
use tokio::process::Command;
use super::routes::AppState;
/// Default repo path for Open Agent source
const OPEN_AGENT_REPO_PATH: &str = "/opt/open_agent/vaduz-v1";
/// Information about a system component.
#[derive(Debug, Clone, Serialize)]
pub struct ComponentInfo {
pub name: String,
pub version: Option<String>,
pub installed: bool,
pub update_available: Option<String>,
pub path: Option<String>,
pub status: ComponentStatus,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentStatus {
Ok,
UpdateAvailable,
NotInstalled,
Error,
}
/// Response for the system components endpoint.
#[derive(Debug, Clone, Serialize)]
pub struct SystemComponentsResponse {
pub components: Vec<ComponentInfo>,
}
/// Response for update progress events.
#[derive(Debug, Clone, Serialize)]
pub struct UpdateProgressEvent {
pub event_type: String, // "log", "progress", "complete", "error"
pub message: String,
pub progress: Option<u8>, // 0-100
}
/// Information about an installed OpenCode plugin.
#[derive(Debug, Clone, Serialize)]
pub struct InstalledPluginInfo {
/// Plugin package name (e.g., "opencode-gemini-auth")
pub package: String,
/// Full spec from config (e.g., "opencode-gemini-auth@latest")
pub spec: String,
/// Currently installed version (if detectable)
pub installed_version: Option<String>,
/// Latest version available on npm
pub latest_version: Option<String>,
/// Whether an update is available
pub update_available: bool,
}
/// Response for installed plugins endpoint.
#[derive(Debug, Clone, Serialize)]
pub struct InstalledPluginsResponse {
pub plugins: Vec<InstalledPluginInfo>,
}
// Type alias for the boxed stream to avoid opaque type mismatch
type UpdateStream = Pin<Box<dyn Stream<Item = Result<Event, std::convert::Infallible>> + Send>>;
/// Create routes for system management.
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/components", get(get_components))
.route("/components/:name/update", post(update_component))
.route("/plugins/installed", get(get_installed_plugins))
.route("/plugins/:package/update", post(update_plugin))
}
/// Get information about all system components.
async fn get_components(State(state): State<Arc<AppState>>) -> Json<SystemComponentsResponse> {
let mut components = Vec::new();
// Open Agent (self)
let current_version = env!("CARGO_PKG_VERSION");
let update_available = check_open_agent_update(Some(current_version)).await;
let status = if update_available.is_some() {
ComponentStatus::UpdateAvailable
} else {
ComponentStatus::Ok
};
components.push(ComponentInfo {
name: "open_agent".to_string(),
version: Some(current_version.to_string()),
installed: true,
update_available,
path: Some("/usr/local/bin/open_agent".to_string()),
status,
});
// OpenCode
let opencode_info = get_opencode_info(&state.config).await;
components.push(opencode_info);
// oh-my-opencode
let omo_info = get_oh_my_opencode_info().await;
components.push(omo_info);
Json(SystemComponentsResponse { components })
}
/// Get OpenCode version and status.
async fn get_opencode_info(config: &crate::config::Config) -> ComponentInfo {
// Try to get version from the health endpoint
let client = reqwest::Client::new();
let health_url = format!("{}/global/health", config.opencode_base_url);
match client.get(&health_url).send().await {
Ok(resp) if resp.status().is_success() => {
if let Ok(json) = resp.json::<serde_json::Value>().await {
let version = json
.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// Check for updates by querying the latest release
let update_available = check_opencode_update(version.as_deref()).await;
let status = if update_available.is_some() {
ComponentStatus::UpdateAvailable
} else {
ComponentStatus::Ok
};
return ComponentInfo {
name: "opencode".to_string(),
version,
installed: true,
update_available,
path: Some("/usr/local/bin/opencode".to_string()),
status,
};
}
}
_ => {}
}
// Fallback: try to run opencode --version
match Command::new("opencode").arg("--version").output().await {
Ok(output) if output.status.success() => {
let version_str = String::from_utf8_lossy(&output.stdout);
let version = version_str.lines().next().map(|l| {
l.trim()
.replace("opencode version ", "")
.replace("opencode ", "")
});
let update_available = check_opencode_update(version.as_deref()).await;
let status = if update_available.is_some() {
ComponentStatus::UpdateAvailable
} else {
ComponentStatus::Ok
};
ComponentInfo {
name: "opencode".to_string(),
version,
installed: true,
update_available,
path: Some("/usr/local/bin/opencode".to_string()),
status,
}
}
_ => ComponentInfo {
name: "opencode".to_string(),
version: None,
installed: false,
update_available: None,
path: None,
status: ComponentStatus::NotInstalled,
},
}
}
/// Check if there's a newer version of OpenCode available.
async fn check_opencode_update(current_version: Option<&str>) -> Option<String> {
let current = current_version?;
// Fetch latest release from opencode.ai or GitHub
let client = reqwest::Client::new();
// Check the anomalyco/opencode GitHub releases (the actual OpenCode source)
// Note: anthropics/claude-code is a different project
let resp = client
.get("https://api.github.com/repos/anomalyco/opencode/releases/latest")
.header("User-Agent", "open-agent")
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let json: serde_json::Value = resp.json().await.ok()?;
let latest = json.get("tag_name")?.as_str()?;
let latest_version = latest.trim_start_matches('v');
// Simple version comparison (assumes semver-like format)
if latest_version != current && version_is_newer(latest_version, current) {
Some(latest_version.to_string())
} else {
None
}
}
/// Check if there's a newer version of Open Agent available.
/// First checks GitHub releases, then falls back to git tags if no releases exist.
async fn check_open_agent_update(current_version: Option<&str>) -> Option<String> {
let current = current_version?;
// First, try GitHub releases API
let client = reqwest::Client::new();
let resp = client
.get("https://api.github.com/repos/Th0rgal/openagent/releases/latest")
.header("User-Agent", "open-agent")
.send()
.await
.ok();
if let Some(resp) = resp {
if resp.status().is_success() {
if let Ok(json) = resp.json::<serde_json::Value>().await {
if let Some(latest) = json.get("tag_name").and_then(|t| t.as_str()) {
let latest_version = latest.trim_start_matches('v');
if latest_version != current && version_is_newer(latest_version, current) {
return Some(latest_version.to_string());
}
}
}
}
}
// Fallback: check git tags from the repo if it exists
let repo_path = std::path::Path::new(OPEN_AGENT_REPO_PATH);
if !repo_path.exists() {
return None;
}
// Fetch tags first
let _ = Command::new("git")
.args(["fetch", "--tags", "origin"])
.current_dir(OPEN_AGENT_REPO_PATH)
.output()
.await;
// Get the latest tag
let tag_result = Command::new("git")
.args(["describe", "--tags", "--abbrev=0", "origin/master"])
.current_dir(OPEN_AGENT_REPO_PATH)
.output()
.await
.ok()?;
if !tag_result.status.success() {
return None;
}
let latest_tag = String::from_utf8_lossy(&tag_result.stdout)
.trim()
.to_string();
let latest_version = latest_tag.trim_start_matches('v');
if latest_version != current && version_is_newer(latest_version, current) {
Some(latest_version.to_string())
} else {
None
}
}
/// Simple semver comparison (newer returns true if a > b).
fn version_is_newer(a: &str, b: &str) -> bool {
let parse = |v: &str| -> Vec<u32> { v.split('.').filter_map(|s| s.parse().ok()).collect() };
let va = parse(a);
let vb = parse(b);
for i in 0..va.len().max(vb.len()) {
let a_part = va.get(i).copied().unwrap_or(0);
let b_part = vb.get(i).copied().unwrap_or(0);
if a_part > b_part {
return true;
}
if a_part < b_part {
return false;
}
}
false
}
/// Get oh-my-opencode version and status.
async fn get_oh_my_opencode_info() -> ComponentInfo {
// Check if oh-my-opencode is installed by looking for the config file
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
let config_path = format!("{}/.config/opencode/oh-my-opencode.json", home);
let installed = tokio::fs::metadata(&config_path).await.is_ok();
if !installed {
return ComponentInfo {
name: "oh_my_opencode".to_string(),
version: None,
installed: false,
update_available: None,
path: None,
status: ComponentStatus::NotInstalled,
};
}
// Try to get version from the package
// oh-my-opencode doesn't have a --version flag, so we check npm/bun
let version = get_oh_my_opencode_version().await;
let update_available = check_oh_my_opencode_update(version.as_deref()).await;
let status = if update_available.is_some() {
ComponentStatus::UpdateAvailable
} else {
ComponentStatus::Ok
};
ComponentInfo {
name: "oh_my_opencode".to_string(),
version,
installed: true,
update_available,
path: Some(config_path),
status,
}
}
/// Get the installed version of oh-my-opencode.
async fn get_oh_my_opencode_version() -> Option<String> {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
// First, try to find the version from bun's cache (most reliable for the actual installed version)
// Run: find ~/.bun -name 'package.json' -path '*oh-my-opencode*' and parse version
let output = Command::new("bash")
.args([
"-c",
&format!(
"find {}/.bun -name 'package.json' -path '*oh-my-opencode*' 2>/dev/null | head -1 | xargs cat 2>/dev/null",
home
),
])
.output()
.await
.ok()?;
if output.status.success() {
let content = String::from_utf8_lossy(&output.stdout);
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(version) = json.get("version").and_then(|v| v.as_str()) {
return Some(version.to_string());
}
}
}
// Fallback: try running bunx to check the version (may be buggy in some versions)
let output = Command::new("bunx")
.args(["oh-my-opencode", "--version"])
.output()
.await
.ok()?;
if output.status.success() {
let version_str = String::from_utf8_lossy(&output.stdout);
return version_str.lines().next().map(|l| l.trim().to_string());
}
None
}
/// Check if there's a newer version of oh-my-opencode available.
async fn check_oh_my_opencode_update(current_version: Option<&str>) -> Option<String> {
// Query npm registry for latest version
let client = reqwest::Client::new();
let resp = client
.get("https://registry.npmjs.org/oh-my-opencode/latest")
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let json: serde_json::Value = resp.json().await.ok()?;
let latest = json.get("version")?.as_str()?;
match current_version {
Some(current) if latest != current && version_is_newer(latest, current) => {
Some(latest.to_string())
}
None => Some(latest.to_string()), // If no current version, suggest the latest
_ => None,
}
}
/// Update a system component.
async fn update_component(
State(_state): State<Arc<AppState>>,
Path(name): Path<String>,
) -> Result<Sse<UpdateStream>, (StatusCode, String)> {
match name.as_str() {
"open_agent" => Ok(Sse::new(Box::pin(stream_open_agent_update()))),
"opencode" => Ok(Sse::new(Box::pin(stream_opencode_update()))),
"oh_my_opencode" => Ok(Sse::new(Box::pin(stream_oh_my_opencode_update()))),
_ => Err((
StatusCode::BAD_REQUEST,
format!("Unknown component: {}", name),
)),
}
}
/// Stream the Open Agent update process.
/// Builds from source using git tags (no pre-built binaries needed).
fn stream_open_agent_update() -> impl Stream<Item = Result<Event, std::convert::Infallible>> {
async_stream::stream! {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: "Starting Open Agent update...".to_string(),
progress: Some(0),
}).unwrap()));
// Check if source repo exists
let repo_path = std::path::Path::new(OPEN_AGENT_REPO_PATH);
if !repo_path.exists() {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!("Source repo not found at {}. Clone the repo first (see INSTALL.md).", OPEN_AGENT_REPO_PATH),
progress: None,
}).unwrap()));
return;
}
// Fetch latest from git
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: "Fetching latest changes from git...".to_string(),
progress: Some(5),
}).unwrap()));
let fetch_result = Command::new("git")
.args(["fetch", "--tags", "origin"])
.current_dir(OPEN_AGENT_REPO_PATH)
.output()
.await;
match fetch_result {
Ok(output) if output.status.success() => {}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!("Failed to fetch: {}", stderr),
progress: None,
}).unwrap()));
return;
}
Err(e) => {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!("Failed to run git fetch: {}", e),
progress: None,
}).unwrap()));
return;
}
}
// Get the latest tag
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: "Finding latest release tag...".to_string(),
progress: Some(10),
}).unwrap()));
let tag_result = Command::new("git")
.args(["describe", "--tags", "--abbrev=0", "origin/master"])
.current_dir(OPEN_AGENT_REPO_PATH)
.output()
.await;
let latest_tag = match tag_result {
Ok(output) if output.status.success() => {
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
_ => {
// No tags found, use origin/master
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: "No release tags found, using origin/master...".to_string(),
progress: Some(12),
}).unwrap()));
"origin/master".to_string()
}
};
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: format!("Checking out {}...", latest_tag),
progress: Some(15),
}).unwrap()));
// Reset any local changes before checkout to prevent conflicts
let _ = Command::new("git")
.args(["reset", "--hard", "HEAD"])
.current_dir(OPEN_AGENT_REPO_PATH)
.output()
.await;
// Clean untracked files that might interfere
let _ = Command::new("git")
.args(["clean", "-fd"])
.current_dir(OPEN_AGENT_REPO_PATH)
.output()
.await;
// Checkout the tag/branch
let checkout_result = Command::new("git")
.args(["checkout", &latest_tag])
.current_dir(OPEN_AGENT_REPO_PATH)
.output()
.await;
match checkout_result {
Ok(output) if output.status.success() => {}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!("Failed to checkout: {}", stderr),
progress: None,
}).unwrap()));
return;
}
Err(e) => {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!("Failed to run git checkout: {}", e),
progress: None,
}).unwrap()));
return;
}
}
// If using origin/master, pull latest
if latest_tag == "origin/master" {
let pull_result = Command::new("git")
.args(["pull", "origin", "master"])
.current_dir(OPEN_AGENT_REPO_PATH)
.output()
.await;
if let Ok(output) = pull_result {
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: format!("Warning: git pull failed: {}", stderr),
progress: Some(18),
}).unwrap()));
}
}
}
// Build the project
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: "Building Open Agent (this may take a few minutes)...".to_string(),
progress: Some(20),
}).unwrap()));
// Source cargo env and build
let build_result = Command::new("bash")
.args(["-c", "source /root/.cargo/env && cargo build --bin open_agent --bin workspace-mcp --bin desktop-mcp"])
.current_dir(OPEN_AGENT_REPO_PATH)
.output()
.await;
match build_result {
Ok(output) if output.status.success() => {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: "Build complete".to_string(),
progress: Some(70),
}).unwrap()));
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
// Show last few lines of error
let last_lines: Vec<&str> = stderr.lines().rev().take(10).collect();
let error_summary = last_lines.into_iter().rev().collect::<Vec<_>>().join("\n");
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!("Build failed:\n{}", error_summary),
progress: None,
}).unwrap()));
return;
}
Err(e) => {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!("Failed to run cargo build: {}", e),
progress: None,
}).unwrap()));
return;
}
}
// Install binaries
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: "Installing binaries...".to_string(),
progress: Some(75),
}).unwrap()));
let binaries = [
("open_agent", "/usr/local/bin/open_agent"),
("workspace-mcp", "/usr/local/bin/workspace-mcp"),
("desktop-mcp", "/usr/local/bin/desktop-mcp"),
];
for (name, dest) in binaries {
let src = format!("{}/target/debug/{}", OPEN_AGENT_REPO_PATH, name);
let install_result = Command::new("install")
.args(["-m", "0755", &src, dest])
.output()
.await;
match install_result {
Ok(output) if output.status.success() => {}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!("Failed to install {}: {}", name, stderr),
progress: None,
}).unwrap()));
return;
}
Err(e) => {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!("Failed to install {}: {}", name, e),
progress: None,
}).unwrap()));
return;
}
}
}
// Send restart event before restarting - the SSE connection will drop when the
// service restarts since this process will be terminated by systemctl. The client
// should detect the connection drop at progress 100% and treat it as success.
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "restarting".to_string(),
message: format!("Binaries installed, restarting service to complete update to {}...", latest_tag),
progress: Some(100),
}).unwrap()));
// Small delay to ensure the SSE event is flushed before we restart
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
// Restart the service - this will terminate our process, so no code after this
// will execute. The client should poll /api/health to confirm the new version.
let _ = Command::new("systemctl")
.args(["restart", "open_agent.service"])
.output()
.await;
}
}
/// Stream the OpenCode update process.
fn stream_opencode_update() -> impl Stream<Item = Result<Event, std::convert::Infallible>> {
async_stream::stream! {
// Send initial progress
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: "Starting OpenCode update...".to_string(),
progress: Some(0),
}).unwrap()));
// Download and install OpenCode
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: "Downloading latest OpenCode release...".to_string(),
progress: Some(10),
}).unwrap()));
// Run the install script
let install_result = Command::new("bash")
.args(["-c", "curl -fsSL https://opencode.ai/install | bash -s -- --no-modify-path"])
.output()
.await;
match install_result {
Ok(output) if output.status.success() => {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: "Download complete, installing...".to_string(),
progress: Some(50),
}).unwrap()));
// Copy to /usr/local/bin
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
let install_result = Command::new("install")
.args(["-m", "0755", &format!("{}/.opencode/bin/opencode", home), "/usr/local/bin/opencode"])
.output()
.await;
match install_result {
Ok(output) if output.status.success() => {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: "Binary installed, restarting service...".to_string(),
progress: Some(80),
}).unwrap()));
// Restart the opencode service
let restart_result = Command::new("systemctl")
.args(["restart", "opencode.service"])
.output()
.await;
match restart_result {
Ok(output) if output.status.success() => {
// Wait a moment for the service to start
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "complete".to_string(),
message: "OpenCode updated successfully!".to_string(),
progress: Some(100),
}).unwrap()));
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!("Failed to restart service: {}", stderr),
progress: None,
}).unwrap()));
}
Err(e) => {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!("Failed to restart service: {}", e),
progress: None,
}).unwrap()));
}
}
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!("Failed to install binary: {}", stderr),
progress: None,
}).unwrap()));
}
Err(e) => {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!("Failed to install binary: {}", e),
progress: None,
}).unwrap()));
}
}
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!("Failed to download OpenCode: {}", stderr),
progress: None,
}).unwrap()));
}
Err(e) => {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!("Failed to run install script: {}", e),
progress: None,
}).unwrap()));
}
}
}
}
/// Stream the oh-my-opencode update process.
fn stream_oh_my_opencode_update() -> impl Stream<Item = Result<Event, std::convert::Infallible>> {
async_stream::stream! {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: "Starting oh-my-opencode update...".to_string(),
progress: Some(0),
}).unwrap()));
// First, clear the bun cache for oh-my-opencode to force fetching the latest version
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: "Clearing package cache...".to_string(),
progress: Some(10),
}).unwrap()));
// Clear bun cache to force re-download
let _ = Command::new("bun")
.args(["pm", "cache", "rm"])
.output()
.await;
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: "Running bunx oh-my-opencode@latest install...".to_string(),
progress: Some(20),
}).unwrap()));
// Run the install command with @latest to force the newest version
// Enable all providers by default for updates
let install_result = Command::new("bunx")
.args([
"oh-my-opencode@latest",
"install",
"--no-tui",
"--claude=yes",
"--chatgpt=yes",
"--gemini=yes",
])
.output()
.await;
match install_result {
Ok(output) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout);
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: format!("Installation output: {}", stdout.lines().take(5).collect::<Vec<_>>().join("\n")),
progress: Some(80),
}).unwrap()));
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "complete".to_string(),
message: "oh-my-opencode updated successfully!".to_string(),
progress: Some(100),
}).unwrap()));
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!("Failed to update oh-my-opencode: {} {}", stderr, stdout),
progress: None,
}).unwrap()));
}
Err(e) => {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!("Failed to run update: {}", e),
progress: None,
}).unwrap()));
}
}
}
}
/// Get all installed OpenCode plugins with version information.
async fn get_installed_plugins() -> Json<InstalledPluginsResponse> {
let plugin_specs = crate::opencode_config::get_installed_plugins().await;
let mut plugins = Vec::new();
for spec in plugin_specs {
let (package, _pinned_version) = split_package_spec(&spec);
// Get installed version from bun cache
let installed_version = get_plugin_installed_version(&package).await;
// Get latest version from npm
let latest_version = get_plugin_latest_version(&package).await;
// Determine if update is available
let update_available = match (&installed_version, &latest_version) {
(Some(installed), Some(latest)) => {
installed != latest && version_is_newer(latest, installed)
}
(None, Some(_)) => true, // Not installed locally but available
_ => false,
};
plugins.push(InstalledPluginInfo {
package: package.clone(),
spec: spec.clone(),
installed_version,
latest_version,
update_available,
});
}
Json(InstalledPluginsResponse { plugins })
}
/// Split a package spec into (name, version).
/// E.g., "opencode-gemini-auth@1.2.3" -> ("opencode-gemini-auth", Some("1.2.3"))
fn split_package_spec(spec: &str) -> (String, Option<String>) {
// Handle scoped packages like @scope/name@version
if spec.starts_with('@') {
if let Some(slash_idx) = spec.find('/') {
let after_scope = &spec[slash_idx + 1..];
if let Some(at_idx) = after_scope.find('@') {
let name = format!("{}/{}", &spec[..slash_idx], &after_scope[..at_idx]);
let version = &after_scope[at_idx + 1..];
if !version.is_empty() && version != "latest" {
return (name, Some(version.to_string()));
}
return (name, None);
}
}
return (spec.to_string(), None);
}
// Non-scoped package
if let Some(at_idx) = spec.find('@') {
let name = &spec[..at_idx];
let version = &spec[at_idx + 1..];
if !version.is_empty() && version != "latest" {
return (name.to_string(), Some(version.to_string()));
}
return (name.to_string(), None);
}
(spec.to_string(), None)
}
/// Get the installed version of a plugin from bun cache.
async fn get_plugin_installed_version(package: &str) -> Option<String> {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
// Search bun cache for the package
let output = Command::new("bash")
.args([
"-c",
&format!(
"find {}/.bun/install/cache -maxdepth 1 -name '{}@*' 2>/dev/null | sort -V | tail -1",
home,
package.replace('/', "-") // Handle scoped packages in filesystem
),
])
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if path.is_empty() {
return None;
}
// Extract version from directory name (e.g., "opencode-gemini-auth@1.3.7@@@1" -> "1.3.7")
let dirname = std::path::Path::new(&path).file_name()?.to_str()?;
// Find the version part between first @ and second @
let after_name = dirname.strip_prefix(&format!("{}@", package.replace('/', "-")))?;
let version = after_name.split('@').next()?;
Some(version.to_string())
}
/// Get the latest version of a plugin from npm registry.
async fn get_plugin_latest_version(package: &str) -> Option<String> {
let client = reqwest::Client::new();
let url = format!("https://registry.npmjs.org/{}/latest", package);
let resp = client.get(&url).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let json: serde_json::Value = resp.json().await.ok()?;
json.get("version")?.as_str().map(|s| s.to_string())
}
/// Update a plugin to the latest version.
async fn update_plugin(
Path(package): Path<String>,
) -> Result<Sse<UpdateStream>, (StatusCode, String)> {
Ok(Sse::new(Box::pin(stream_plugin_update(package))))
}
/// Stream the plugin update process.
fn stream_plugin_update(
package: String,
) -> impl Stream<Item = Result<Event, std::convert::Infallible>> {
async_stream::stream! {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: format!("Starting {} update...", package),
progress: Some(0),
}).unwrap()));
// Clear bun cache for this package
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: "Clearing package cache...".to_string(),
progress: Some(10),
}).unwrap()));
let _ = Command::new("bun")
.args(["pm", "cache", "rm"])
.output()
.await;
// Update the plugin by reinstalling with @latest
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: format!("Installing {}@latest...", package),
progress: Some(30),
}).unwrap()));
// Use bunx to trigger the plugin install (which will cache the latest version)
let install_result = Command::new("bunx")
.args([&format!("{}@latest", package), "--help"])
.output()
.await;
match install_result {
Ok(_) => {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "log".to_string(),
message: "Package downloaded, updating config...".to_string(),
progress: Some(70),
}).unwrap()));
// Update the opencode config to use @latest
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
let config_path = format!("{}/.config/opencode/opencode.json", home);
if let Ok(contents) = tokio::fs::read_to_string(&config_path).await {
if let Ok(mut root) = serde_json::from_str::<serde_json::Value>(&contents) {
if let Some(plugins) = root.get_mut("plugin").and_then(|v| v.as_array_mut()) {
// Update the plugin spec to @latest
for p in plugins.iter_mut() {
if let Some(s) = p.as_str() {
// Match exact package name or package@version to avoid prefix collisions
if s == package || s.starts_with(&format!("{}@", package)) {
*p = serde_json::json!(format!("{}@latest", package));
}
}
}
if let Ok(payload) = serde_json::to_string_pretty(&root) {
let _ = tokio::fs::write(&config_path, payload).await;
}
}
}
}
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "complete".to_string(),
message: format!("{} updated successfully!", package),
progress: Some(100),
}).unwrap()));
}
Err(e) => {
yield Ok(Event::default().data(serde_json::to_string(&UpdateProgressEvent {
event_type: "error".to_string(),
message: format!("Failed to update {}: {}", package, e),
progress: None,
}).unwrap()));
}
}
}
}