use crate::util::http_client::shared_http_client;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtensionEntry {
pub source: String,
pub version: String,
pub installed_at: String,
pub wasm_file: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExtensionRegistry {
pub extensions: std::collections::HashMap<String, ExtensionEntry>,
}
impl ExtensionRegistry {
pub fn load() -> Result<Self> {
let path = Self::registry_path()?;
if !path.exists() {
return Ok(Self::default());
}
let data = std::fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?;
serde_json::from_str(&data).with_context(|| format!("Failed to parse {}", path.display()))
}
pub fn save(&self) -> Result<()> {
let path = Self::registry_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let data = serde_json::to_string_pretty(self)?;
std::fs::write(&path, data)
.with_context(|| format!("Failed to write {}", path.display()))?;
Ok(())
}
pub fn registry_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("Cannot determine home directory")?;
Ok(home.join(".oxi").join("extensions").join("registry.json"))
}
pub fn extensions_dir() -> Result<PathBuf> {
let home = dirs::home_dir().context("Cannot determine home directory")?;
Ok(home.join(".oxi").join("extensions"))
}
}
#[derive(Debug, Deserialize)]
struct GitHubRelease {
tag_name: String,
assets: Vec<GitHubAsset>,
prerelease: bool,
draft: bool,
}
#[derive(Debug, Deserialize)]
struct GitHubAsset {
name: String,
browser_download_url: String,
size: u64,
}
async fn fetch_latest_release(source: &str, include_prerelease: bool) -> Result<GitHubRelease> {
let url = format!("https://api.github.com/repos/{}/releases", source);
let client = shared_http_client();
let mut request = client.get(&url).header("User-Agent", "oxi-ext");
if let Ok(token) = std::env::var("GITHUB_TOKEN").or_else(|_| std::env::var("GH_TOKEN")) {
request = request.header("Authorization", format!("Bearer {}", token));
}
let releases: Vec<GitHubRelease> = request
.send()
.await
.with_context(|| format!("Failed to fetch releases for {}", source))?
.json()
.await
.with_context(|| format!("Failed to parse releases for {}", source))?;
releases
.into_iter()
.filter(|r| !r.draft)
.filter(|r| include_prerelease || !r.prerelease)
.find(|r| r.assets.iter().any(|a| a.name.ends_with(".wasm")))
.context(format!("No release with .wasm asset found for {}", source))
}
async fn download_file(url: &str, dest: &Path) -> Result<()> {
let response = reqwest::get(url)
.await
.with_context(|| format!("Failed to download {}", url))?;
if !response.status().is_success() {
anyhow::bail!("Download failed with status: {}", response.status());
}
let bytes = response
.bytes()
.await
.context("Failed to read download response")?;
std::fs::write(dest, &bytes).with_context(|| format!("Failed to write {}", dest.display()))?;
Ok(())
}
#[derive(Debug)]
pub struct InstallResult {
pub name: String,
pub version: String,
pub source: String,
pub wasm_file: String,
}
pub async fn install_extension(source: &str, include_prerelease: bool) -> Result<InstallResult> {
let (repo, wanted_version) = if let Some((r, v)) = source.split_once('@') {
(r, Some(v.to_string()))
} else {
(source, None)
};
if !repo.contains('/') || repo.split('/').count() != 2 {
anyhow::bail!(
"Invalid source format: '{}'. Use 'owner/repo' (e.g. 'a7garden/oxi-web-search')",
repo
);
}
let release = if let Some(tag) = &wanted_version {
let url = format!(
"https://api.github.com/repos/{}/releases/tags/{}",
repo, tag
);
let client = shared_http_client();
let mut request = client.get(&url).header("User-Agent", "oxi-ext");
if let Ok(token) = std::env::var("GITHUB_TOKEN").or_else(|_| std::env::var("GH_TOKEN")) {
request = request.header("Authorization", format!("Bearer {}", token));
}
request
.send()
.await
.with_context(|| format!("Failed to fetch release {} for {}", tag, repo))?
.json()
.await
.with_context(|| format!("Release {} not found for {}", tag, repo))?
} else {
fetch_latest_release(repo, include_prerelease).await?
};
let wasm_asset = release
.assets
.iter()
.find(|a| a.name.ends_with(".wasm"))
.context(format!(
"No .wasm file found in release {} of {}",
release.tag_name, repo
))?;
let ext_name = wasm_asset
.name
.strip_suffix(".wasm")
.unwrap_or(&wasm_asset.name)
.to_string();
let extensions_dir = ExtensionRegistry::extensions_dir()?;
std::fs::create_dir_all(&extensions_dir)?;
let dest = extensions_dir.join(&wasm_asset.name);
println!(
"Downloading {} v{} ({:.1} KB)...",
ext_name,
release.tag_name,
wasm_asset.size as f64 / 1024.0
);
download_file(&wasm_asset.browser_download_url, &dest).await?;
let mut registry = ExtensionRegistry::load()?;
let entry = ExtensionEntry {
source: repo.to_string(),
version: release.tag_name.clone(),
installed_at: chrono::Utc::now().to_rfc3339(),
wasm_file: wasm_asset.name.clone(),
};
registry.extensions.insert(ext_name.clone(), entry);
registry.save()?;
Ok(InstallResult {
name: ext_name,
version: release.tag_name,
source: repo.to_string(),
wasm_file: wasm_asset.name.clone(),
})
}
pub fn remove_extension(name: &str) -> Result<()> {
let mut registry = ExtensionRegistry::load()?;
let entry = registry
.extensions
.remove(name)
.context(format!("Extension '{}' not found in registry", name))?;
let extensions_dir = ExtensionRegistry::extensions_dir()?;
let wasm_path = extensions_dir.join(&entry.wasm_file);
if wasm_path.exists() {
std::fs::remove_file(&wasm_path)
.with_context(|| format!("Failed to delete {}", wasm_path.display()))?;
}
registry.save()?;
Ok(())
}
pub fn list_extensions() -> Result<Vec<(String, ExtensionEntry)>> {
let registry = ExtensionRegistry::load()?;
let mut entries: Vec<_> = registry.extensions.into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
Ok(entries)
}
pub async fn update_extension(name: Option<&str>) -> Result<Vec<InstallResult>> {
let registry = ExtensionRegistry::load()?;
let mut results = Vec::new();
let targets: Vec<(String, ExtensionEntry)> = if let Some(name) = name {
let entry = registry
.extensions
.get(name)
.cloned()
.context(format!("Extension '{}' not found", name))?;
vec![(name.to_string(), entry)]
} else {
registry.extensions.into_iter().collect()
};
for (ext_name, entry) in targets {
match install_extension(&entry.source, false).await {
Ok(result) => {
println!("Updated {} to {}", ext_name, result.version);
results.push(result);
}
Err(e) => {
eprintln!("Failed to update {}: {}", ext_name, e);
}
}
}
Ok(results)
}
pub async fn info_extension(source: &str) -> Result<()> {
let release = fetch_latest_release(source, true).await?;
println!("Extension: {}", source);
println!("Latest version: {}", release.tag_name);
println!("Pre-release: {}", release.prerelease);
println!("Assets:");
for asset in &release.assets {
println!(" {} ({:.1} KB)", asset.name, asset.size as f64 / 1024.0);
}
Ok(())
}