Skip to main content

oxicode/extensions/
ext_cli.rs

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