Skip to main content

oxi/extensions/
ext_cli.rs

1//! Extension package manager — install, update, remove WASM extensions from GitHub releases.
2//!
3//! `oxi ext install user/repo` — download .wasm from GitHub releases
4//! `oxi ext list`               — show installed extensions
5//! `oxi ext update`             — update all or specific extension
6//! `oxi ext remove user/repo`   — uninstall extension
7//!
8//! Metadata stored in `~/.oxi/extensions/registry.json`.
9
10use crate::util::http_client::shared_http_client;
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13use std::path::{Path, PathBuf};
14
15// ── Registry ──────────────────────────────────────────────────────
16
17/// Per-extension metadata stored in registry.json.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ExtensionEntry {
20    /// GitHub source (e.g. "a7garden/oxi-web-search").
21    pub source: String,
22    /// Installed version.
23    pub version: String,
24    /// Installation timestamp (ISO 8601).
25    pub installed_at: String,
26    /// WASM filename in extensions dir.
27    pub wasm_file: String,
28}
29
30/// The on-disk registry of installed extensions.
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
32pub struct ExtensionRegistry {
33    pub extensions: std::collections::HashMap<String, ExtensionEntry>,
34}
35
36impl ExtensionRegistry {
37    /// Load registry from `~/.oxi/extensions/registry.json`.
38    pub fn load() -> Result<Self> {
39        let path = Self::registry_path()?;
40        if !path.exists() {
41            return Ok(Self::default());
42        }
43        let data = std::fs::read_to_string(&path)
44            .with_context(|| format!("Failed to read {}", path.display()))?;
45        serde_json::from_str(&data).with_context(|| format!("Failed to parse {}", path.display()))
46    }
47
48    /// Save registry to disk.
49    pub fn save(&self) -> Result<()> {
50        let path = Self::registry_path()?;
51        if let Some(parent) = path.parent() {
52            std::fs::create_dir_all(parent)?;
53        }
54        let data = serde_json::to_string_pretty(self)?;
55        std::fs::write(&path, data)
56            .with_context(|| format!("Failed to write {}", path.display()))?;
57        Ok(())
58    }
59
60    /// Path to `~/.oxi/extensions/registry.json`.
61    pub fn registry_path() -> Result<PathBuf> {
62        let home = dirs::home_dir().context("Cannot determine home directory")?;
63        Ok(home.join(".oxi").join("extensions").join("registry.json"))
64    }
65
66    /// Path to `~/.oxi/extensions/`.
67    pub fn extensions_dir() -> Result<PathBuf> {
68        let home = dirs::home_dir().context("Cannot determine home directory")?;
69        Ok(home.join(".oxi").join("extensions"))
70    }
71}
72
73// ── GitHub Release API ────────────────────────────────────────────
74
75#[derive(Debug, Deserialize)]
76struct GitHubRelease {
77    tag_name: String,
78    assets: Vec<GitHubAsset>,
79    prerelease: bool,
80    draft: bool,
81}
82
83#[derive(Debug, Deserialize)]
84struct GitHubAsset {
85    name: String,
86    browser_download_url: String,
87    size: u64,
88}
89
90/// Fetch latest release from a GitHub repo.
91async fn fetch_latest_release(source: &str, include_prerelease: bool) -> Result<GitHubRelease> {
92    let url = format!("https://api.github.com/repos/{}/releases", source);
93    let client = shared_http_client();
94    let mut request = client.get(&url).header("User-Agent", "oxi-ext");
95
96    // Use GITHUB_TOKEN if available for higher rate limits
97    if let Ok(token) = std::env::var("GITHUB_TOKEN").or_else(|_| std::env::var("GH_TOKEN")) {
98        request = request.header("Authorization", format!("Bearer {}", token));
99    }
100
101    let releases: Vec<GitHubRelease> = request
102        .send()
103        .await
104        .with_context(|| format!("Failed to fetch releases for {}", source))?
105        .json()
106        .await
107        .with_context(|| format!("Failed to parse releases for {}", source))?;
108
109    releases
110        .into_iter()
111        .filter(|r| !r.draft)
112        .filter(|r| include_prerelease || !r.prerelease)
113        .find(|r| r.assets.iter().any(|a| a.name.ends_with(".wasm")))
114        .context(format!("No release with .wasm asset found for {}", source))
115}
116
117/// Download a file from URL to a local path.
118async fn download_file(url: &str, dest: &Path) -> Result<()> {
119    let response = reqwest::get(url)
120        .await
121        .with_context(|| format!("Failed to download {}", url))?;
122
123    if !response.status().is_success() {
124        anyhow::bail!("Download failed with status: {}", response.status());
125    }
126
127    let bytes = response
128        .bytes()
129        .await
130        .context("Failed to read download response")?;
131
132    std::fs::write(dest, &bytes).with_context(|| format!("Failed to write {}", dest.display()))?;
133
134    Ok(())
135}
136
137// ── Public API ────────────────────────────────────────────────────
138
139/// Result of an install operation.
140#[derive(Debug)]
141pub struct InstallResult {
142    pub name: String,
143    pub version: String,
144    pub source: String,
145    pub wasm_file: String,
146}
147
148/// Install an extension from a GitHub repo.
149///
150/// `source` should be in "owner/repo" format.
151/// Optionally specify version as "owner/repo@version".
152pub async fn install_extension(source: &str, include_prerelease: bool) -> Result<InstallResult> {
153    let (repo, wanted_version) = if let Some((r, v)) = source.split_once('@') {
154        (r, Some(v.to_string()))
155    } else {
156        (source, None)
157    };
158
159    // Validate source format
160    if !repo.contains('/') || repo.split('/').count() != 2 {
161        anyhow::bail!(
162            "Invalid source format: '{}'. Use 'owner/repo' (e.g. 'a7garden/oxi-web-search')",
163            repo
164        );
165    }
166
167    let release = if let Some(tag) = &wanted_version {
168        // Fetch specific release by tag
169        let url = format!(
170            "https://api.github.com/repos/{}/releases/tags/{}",
171            repo, tag
172        );
173        let client = shared_http_client();
174        let mut request = client.get(&url).header("User-Agent", "oxi-ext");
175        if let Ok(token) = std::env::var("GITHUB_TOKEN").or_else(|_| std::env::var("GH_TOKEN")) {
176            request = request.header("Authorization", format!("Bearer {}", token));
177        }
178        request
179            .send()
180            .await
181            .with_context(|| format!("Failed to fetch release {} for {}", tag, repo))?
182            .json()
183            .await
184            .with_context(|| format!("Release {} not found for {}", tag, repo))?
185    } else {
186        fetch_latest_release(repo, include_prerelease).await?
187    };
188
189    // Find .wasm asset
190    let wasm_asset = release
191        .assets
192        .iter()
193        .find(|a| a.name.ends_with(".wasm"))
194        .context(format!(
195            "No .wasm file found in release {} of {}",
196            release.tag_name, repo
197        ))?;
198
199    // Determine extension name from wasm filename
200    let ext_name = wasm_asset
201        .name
202        .strip_suffix(".wasm")
203        .unwrap_or(&wasm_asset.name)
204        .to_string();
205
206    // Download to extensions dir
207    let extensions_dir = ExtensionRegistry::extensions_dir()?;
208    std::fs::create_dir_all(&extensions_dir)?;
209    let dest = extensions_dir.join(&wasm_asset.name);
210
211    println!(
212        "Downloading {} v{} ({:.1} KB)...",
213        ext_name,
214        release.tag_name,
215        wasm_asset.size as f64 / 1024.0
216    );
217
218    download_file(&wasm_asset.browser_download_url, &dest).await?;
219
220    // Update registry
221    let mut registry = ExtensionRegistry::load()?;
222    let entry = ExtensionEntry {
223        source: repo.to_string(),
224        version: release.tag_name.clone(),
225        installed_at: chrono::Utc::now().to_rfc3339(),
226        wasm_file: wasm_asset.name.clone(),
227    };
228    registry.extensions.insert(ext_name.clone(), entry);
229    registry.save()?;
230
231    Ok(InstallResult {
232        name: ext_name,
233        version: release.tag_name,
234        source: repo.to_string(),
235        wasm_file: wasm_asset.name.clone(),
236    })
237}
238
239/// Remove an installed extension.
240pub fn remove_extension(name: &str) -> Result<()> {
241    let mut registry = ExtensionRegistry::load()?;
242
243    let entry = registry
244        .extensions
245        .remove(name)
246        .context(format!("Extension '{}' not found in registry", name))?;
247
248    // Delete the .wasm file
249    let extensions_dir = ExtensionRegistry::extensions_dir()?;
250    let wasm_path = extensions_dir.join(&entry.wasm_file);
251    if wasm_path.exists() {
252        std::fs::remove_file(&wasm_path)
253            .with_context(|| format!("Failed to delete {}", wasm_path.display()))?;
254    }
255
256    registry.save()?;
257    Ok(())
258}
259
260/// List installed extensions.
261pub fn list_extensions() -> Result<Vec<(String, ExtensionEntry)>> {
262    let registry = ExtensionRegistry::load()?;
263    let mut entries: Vec<_> = registry.extensions.into_iter().collect();
264    entries.sort_by(|a, b| a.0.cmp(&b.0));
265    Ok(entries)
266}
267
268/// Update an extension (or all if name is None).
269pub async fn update_extension(name: Option<&str>) -> Result<Vec<InstallResult>> {
270    let registry = ExtensionRegistry::load()?;
271    let mut results = Vec::new();
272
273    let targets: Vec<(String, ExtensionEntry)> = if let Some(name) = name {
274        let entry = registry
275            .extensions
276            .get(name)
277            .cloned()
278            .context(format!("Extension '{}' not found", name))?;
279        vec![(name.to_string(), entry)]
280    } else {
281        registry.extensions.into_iter().collect()
282    };
283
284    for (ext_name, entry) in targets {
285        match install_extension(&entry.source, false).await {
286            Ok(result) => {
287                println!("Updated {} to {}", ext_name, result.version);
288                results.push(result);
289            }
290            Err(e) => {
291                eprintln!("Failed to update {}: {}", ext_name, e);
292            }
293        }
294    }
295
296    Ok(results)
297}
298
299/// Show info about a remote extension (without installing).
300pub async fn info_extension(source: &str) -> Result<()> {
301    let release = fetch_latest_release(source, true).await?;
302    println!("Extension: {}", source);
303    println!("Latest version: {}", release.tag_name);
304    println!("Pre-release: {}", release.prerelease);
305    println!("Assets:");
306    for asset in &release.assets {
307        println!("  {} ({:.1} KB)", asset.name, asset.size as f64 / 1024.0);
308    }
309    Ok(())
310}