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