Skip to main content

oxi/storage/packages/
manager.rs

1//! `PackageManager` — the central facade for the package subsystem.
2//!
3//! Owns the in-memory state (installed manifests + lockfile), the
4//! filesystem layout under the user's packages directory, and the
5//! lifecycle methods (`install`, `uninstall`, `update`, `resolve`,
6//! `discover_resources`, etc.). Helpers used by this module live in
7//! sibling submodules and are imported via `super::*`.
8
9use super::discovery::{discover_extensions, discover_prompts, discover_skills, discover_themes};
10use super::fs::{copy_dir_recursive, find_single_subdir, prune_empty_parents};
11use super::git_ops::{git_clone, git_has_update, git_update};
12use super::lockfile::{
13    LockEntry, Lockfile, ResourceCounts, compute_dir_hash, verify_lockfile_integrity,
14};
15use super::npm::{NpmPackageInfo, get_latest_npm_version};
16use super::source::{ParsedSource, parse_npm_spec};
17use super::types::{
18    ConfiguredPackage, DiscoveredResource, PackageManifest, PackageUpdateInfo, PathMetadata,
19    ProgressAction, ProgressEvent, ProgressEventType, ResolvedPaths, ResolvedResource,
20    ResourceKind, ResourceOrigin, SourceScope,
21};
22use super::{LOCKFILE_NAME, MANIFEST_NAME, NPM_MANIFEST_NAME};
23use crate::util::http_client::shared_http_client;
24use anyhow::{Context, Result, bail};
25use std::collections::{BTreeMap, HashMap, HashSet};
26use std::fs;
27use std::path::{Path, PathBuf};
28
29/// Manages installation, removal, and listing of packages
30pub struct PackageManager {
31    packages_dir: PathBuf,
32    /// Base directory for project-scoped packages
33    project_dir: PathBuf,
34    installed: HashMap<String, PackageManifest>,
35    lockfile: Lockfile,
36    progress_callback: Option<Box<dyn Fn(ProgressEvent) + Send + Sync>>,
37}
38
39impl PackageManager {
40    /// Create a new PackageManager using the default packages directory
41    pub fn new() -> Result<Self> {
42        let base = dirs::home_dir().context("Cannot determine home directory")?;
43        let packages_dir = base.join(".oxi").join("packages");
44        let project_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
45        let mut mgr = Self {
46            packages_dir,
47            project_dir,
48            installed: HashMap::new(),
49            lockfile: Lockfile::new(),
50            progress_callback: None,
51        };
52        mgr.load_installed()?;
53        mgr.load_lockfile()?;
54        Ok(mgr)
55    }
56
57    /// Create a PackageManager with a custom packages directory (for testing)
58    pub fn with_dir(packages_dir: PathBuf) -> Result<Self> {
59        let project_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
60        let mut mgr = Self {
61            packages_dir,
62            project_dir,
63            installed: HashMap::new(),
64            lockfile: Lockfile::new(),
65            progress_callback: None,
66        };
67        mgr.load_installed()?;
68        mgr.load_lockfile()?;
69        Ok(mgr)
70    }
71
72    /// Set the project directory for project-scoped packages
73    pub fn set_project_dir(&mut self, dir: PathBuf) {
74        self.project_dir = dir;
75    }
76
77    /// Set a progress callback
78    pub fn set_progress_callback(&mut self, callback: Box<dyn Fn(ProgressEvent) + Send + Sync>) {
79        self.progress_callback = Some(callback);
80    }
81
82    fn emit_progress(&self, event: ProgressEvent) {
83        if let Some(ref cb) = self.progress_callback {
84            cb(event);
85        }
86    }
87
88    // ── Loading ───────────────────────────────────────────────────────
89
90    /// Load all installed package manifests from disk.
91    ///
92    /// For every installed package whose `LockEntry` records an integrity
93    /// hash, the on-disk SHA-256 is recomputed and compared. Mismatches
94    /// (caused by tampering, partial disk failure, or any non-atomic write
95    /// that survived a crash) remove the package from the in-memory `installed`
96    /// map AND invalidate the matching lockfile entry, so subsequent
97    /// `update_all` re-fetches the package instead of trusting the cached copy.
98    /// A mismatch is logged at `warn` level — the CLI keeps booting (other
99    /// packages remain usable) but the affected package is treated as
100    /// un-installed.
101    fn load_installed(&mut self) -> Result<()> {
102        if !self.packages_dir.exists() {
103            return Ok(());
104        }
105        for entry in fs::read_dir(&self.packages_dir)? {
106            let entry = entry?;
107            let manifest_path = entry.path().join(MANIFEST_NAME);
108            if manifest_path.exists() {
109                match Self::read_manifest(&manifest_path) {
110                    Ok(manifest) => {
111                        let name = manifest.name.clone();
112                        let install_dir = entry.path();
113
114                        // F-1 (audit 2026-06-21): verify lockfile integrity
115                        // before trusting the installed package. Without this,
116                        // `compute_dir_hash` is only computed at install and
117                        // never re-checked on subsequent loads — a local
118                        // attacker (or a partial-write crash) could swap files
119                        // under `~/.oxi/packages/<name>/` and the next session
120                        // would silently load the tampered manifest.
121                        if let Some(expected) = self
122                            .lockfile
123                            .packages
124                            .get(&name)
125                            .and_then(|e| e.integrity.as_ref())
126                        {
127                            match verify_lockfile_integrity(&install_dir, expected) {
128                                Ok(()) => {}
129                                Err(reason) => {
130                                    tracing::warn!(
131                                        package = %name,
132                                        expected = %expected,
133                                        reason = %reason,
134                                        "package integrity mismatch on load — treating as un-installed; re-install with `oxi pkg install`"
135                                    );
136                                    // Drop the lockfile entry so `update_all`
137                                    // re-resolves from the source.
138                                    self.lockfile.packages.remove(&name);
139                                    continue;
140                                }
141                            }
142                        }
143
144                        self.installed.insert(name, manifest);
145                    }
146                    Err(e) => {
147                        tracing::warn!(
148                            "Failed to load manifest {}: {}",
149                            manifest_path.display(),
150                            e
151                        );
152                    }
153                }
154            }
155        }
156        Ok(())
157    }
158
159    /// Load lockfile from disk
160    fn load_lockfile(&mut self) -> Result<()> {
161        let lock_path = self.packages_dir.join(LOCKFILE_NAME);
162        if let Some(lock) = Lockfile::read(&lock_path)? {
163            self.lockfile = lock;
164        }
165        Ok(())
166    }
167
168    /// Save lockfile to disk
169    fn save_lockfile(&self) -> Result<()> {
170        let lock_path = self.packages_dir.join(LOCKFILE_NAME);
171        self.lockfile.write(&lock_path)
172    }
173
174    // ── Manifest ──────────────────────────────────────────────────────
175
176    /// Read and parse a package manifest from disk
177    fn read_manifest(path: &Path) -> Result<PackageManifest> {
178        let content = fs::read_to_string(path)
179            .with_context(|| format!("Failed to read manifest {}", path.display()))?;
180        let manifest: PackageManifest = toml::from_str(&content)
181            .with_context(|| format!("Failed to parse manifest {}", path.display()))?;
182        Ok(manifest)
183    }
184
185    /// Try to read a `package.json` manifest (for npm packages)
186    fn read_package_json(dir: &Path) -> Option<serde_json::Value> {
187        let path = dir.join(NPM_MANIFEST_NAME);
188        let content = fs::read_to_string(path).ok()?;
189        serde_json::from_str(&content).ok()
190    }
191
192    // ── Path helpers ──────────────────────────────────────────────────
193
194    /// Get the installation directory for a package
195    fn pkg_install_dir(&self, name: &str) -> PathBuf {
196        let safe_name = name.replace('@', "").replace('/', "-");
197        self.packages_dir.join(safe_name)
198    }
199
200    /// Get the packages directory path
201    pub fn packages_dir(&self) -> &Path {
202        &self.packages_dir
203    }
204
205    /// Get install dir for a git source
206    fn git_install_path(&self, host: &str, path: &str, scope: SourceScope) -> PathBuf {
207        match scope {
208            SourceScope::Project => self
209                .project_dir
210                .join(".oxi")
211                .join("git")
212                .join(host)
213                .join(path),
214            SourceScope::User => self.packages_dir.join("git").join(host).join(path),
215        }
216    }
217
218    /// Get install dir for an npm source
219    fn npm_install_path(&self, name: &str, scope: SourceScope) -> PathBuf {
220        let safe_name = name.replace('@', "").replace('/', "-");
221        match scope {
222            SourceScope::Project => self.project_dir.join(".oxi").join("npm").join(safe_name),
223            SourceScope::User => self.packages_dir.join("npm").join(safe_name),
224        }
225    }
226
227    // ── Install ───────────────────────────────────────────────────────
228
229    /// Ensure packages directory exists
230    fn ensure_packages_dir(&self) -> Result<()> {
231        fs::create_dir_all(&self.packages_dir).with_context(|| {
232            format!(
233                "Failed to create packages directory {}",
234                self.packages_dir.display()
235            )
236        })
237    }
238
239    /// Install a package from a local directory path
240    pub fn install(&mut self, source: &str) -> Result<PackageManifest> {
241        let parsed = ParsedSource::parse(source);
242        match parsed {
243            ParsedSource::Local { path } => self.install_local(&path),
244            _ => bail!("Use install_from_source() for non-local packages"),
245        }
246    }
247
248    /// Install a package from a local directory path
249    fn install_local(&mut self, path: &str) -> Result<PackageManifest> {
250        let source_path = Path::new(path);
251        let manifest_path = source_path.join(MANIFEST_NAME);
252
253        let manifest = if manifest_path.exists() {
254            Self::read_manifest(&manifest_path)
255                .with_context(|| format!("No valid {} found in {}", MANIFEST_NAME, path))?
256        } else {
257            // Synthesise a minimal manifest
258            let name = source_path
259                .file_name()
260                .map(|n| n.to_string_lossy().to_string())
261                .unwrap_or_else(|| "unknown".to_string());
262            PackageManifest {
263                name,
264                version: "0.0.0".to_string(),
265                extensions: Vec::new(),
266                skills: Vec::new(),
267                prompts: Vec::new(),
268                themes: Vec::new(),
269                description: None,
270                dependencies: BTreeMap::new(),
271            }
272        };
273
274        let dest = self.pkg_install_dir(&manifest.name);
275        self.ensure_packages_dir()?;
276
277        if dest.exists() {
278            fs::remove_dir_all(&dest).with_context(|| {
279                format!("Failed to remove existing package at {}", dest.display())
280            })?;
281        }
282
283        copy_dir_recursive(source_path, &dest).with_context(|| {
284            format!("Failed to copy package from {} to {}", path, dest.display())
285        })?;
286
287        let integrity = compute_dir_hash(&dest);
288
289        self.lockfile.insert(LockEntry {
290            source: path.to_string(),
291            name: manifest.name.clone(),
292            version: manifest.version.clone(),
293            integrity,
294            scope: SourceScope::User,
295            source_type: "local".to_string(),
296            dependencies: manifest.dependencies.clone(),
297        });
298
299        self.installed
300            .insert(manifest.name.clone(), manifest.clone());
301        self.save_lockfile()
302            .context("failed to persist package lockfile")?;
303        Ok(manifest)
304    }
305
306    /// Install from any source
307    pub fn install_from_source(
308        &mut self,
309        source: &str,
310        scope: SourceScope,
311    ) -> Result<PackageManifest> {
312        let parsed = ParsedSource::parse(source);
313        self.emit_progress(ProgressEvent {
314            event_type: ProgressEventType::Start,
315            action: ProgressAction::Install,
316            source: source.to_string(),
317            message: Some(format!("Installing {}...", source)),
318        });
319        let result = match &parsed {
320            ParsedSource::Npm { .. } => run_on_fresh_runtime(self.install_npm_async(source, scope)),
321            ParsedSource::Git { repo, ref_, .. } => {
322                self.install_git_sync(source, repo, ref_.as_deref(), scope)
323            }
324            ParsedSource::Local { path } => self.install_local(path),
325            ParsedSource::Url { url } => run_on_fresh_runtime(self.install_url(url, scope)),
326        };
327        match &result {
328            Ok(_) => self.emit_progress(ProgressEvent {
329                event_type: ProgressEventType::Complete,
330                action: ProgressAction::Install,
331                source: source.to_string(),
332                message: None,
333            }),
334            Err(e) => self.emit_progress(ProgressEvent {
335                event_type: ProgressEventType::Error,
336                action: ProgressAction::Install,
337                source: source.to_string(),
338                message: Some(e.to_string()),
339            }),
340        }
341        result
342    }
343
344    /// Async install from npm using registry
345    async fn install_npm_async(
346        &mut self,
347        source: &str,
348        scope: SourceScope,
349    ) -> Result<PackageManifest> {
350        let parsed = ParsedSource::parse(source);
351        let (spec, name, pinned) = match &parsed {
352            ParsedSource::Npm { spec, name, pinned } => (spec.clone(), name.clone(), *pinned),
353            _ => bail!("Expected npm source"),
354        };
355
356        // Resolve version
357        let _version = if pinned {
358            // Extract version from spec
359            let (_, ver) = parse_npm_spec(&spec);
360            if ver {
361                spec.rsplit('@').next().unwrap_or("latest").to_string()
362            } else {
363                "latest".to_string()
364            }
365        } else {
366            get_latest_npm_version(&name)
367                .await
368                .unwrap_or_else(|_| "latest".to_string())
369        };
370
371        // Use npm pack approach
372        self.install_npm_pack(&spec, scope)
373    }
374
375    /// Install npm package using `npm pack`
376    fn install_npm_pack(&mut self, spec: &str, scope: SourceScope) -> Result<PackageManifest> {
377        let tmp_dir =
378            tempfile::tempdir().context("Failed to create temp directory for npm install")?;
379
380        let output = std::process::Command::new("npm")
381            .args(["pack", spec, "--pack-destination"])
382            .arg(tmp_dir.path())
383            .current_dir(tmp_dir.path())
384            .output()
385            .context("Failed to run npm pack")?;
386
387        if !output.status.success() {
388            let stderr = String::from_utf8_lossy(&output.stderr);
389            bail!("npm pack failed for '{}': {}", spec, stderr);
390        }
391
392        // Find the tarball
393        let tarball = fs::read_dir(tmp_dir.path())?
394            .filter_map(|e| e.ok())
395            .find(|e| {
396                e.path()
397                    .extension()
398                    .map(|ext| ext == "tgz")
399                    .unwrap_or(false)
400            })
401            .map(|e| e.path())
402            .context("No .tgz file found after npm pack")?;
403
404        // Extract tarball
405        let extract_dir = tmp_dir.path().join("extracted");
406        fs::create_dir_all(&extract_dir)?;
407
408        let tar_status = std::process::Command::new("tar")
409            .args(["-xzf", &tarball.to_string_lossy(), "-C"])
410            .arg(&extract_dir)
411            .output()
412            .context("Failed to run tar")?;
413
414        if !tar_status.status.success() {
415            let stderr = String::from_utf8_lossy(&tar_status.stderr);
416            bail!("tar extraction failed: {}", stderr);
417        }
418
419        // npm pack extracts into a "package" subdirectory
420        let pkg_source = extract_dir.join("package");
421        let source_for_copy = if pkg_source.exists() {
422            &pkg_source
423        } else {
424            // Might be just the extracted dir
425            extract_dir.as_path()
426        };
427
428        self.ensure_packages_dir()?;
429
430        // Determine package name from manifest or spec
431        let manifest = if source_for_copy.join(MANIFEST_NAME).exists() {
432            Self::read_manifest(&source_for_copy.join(MANIFEST_NAME))?
433        } else if source_for_copy.join(NPM_MANIFEST_NAME).exists() {
434            let pj = Self::read_package_json(source_for_copy);
435            let (pkg_name, pkg_version) = pj
436                .as_ref()
437                .map(|v| {
438                    (
439                        v.get("name")
440                            .and_then(|n| n.as_str())
441                            .unwrap_or(spec)
442                            .to_string(),
443                        v.get("version")
444                            .and_then(|v| v.as_str())
445                            .unwrap_or("0.0.0")
446                            .to_string(),
447                    )
448                })
449                .unwrap_or((spec.to_string(), "0.0.0".to_string()));
450
451            PackageManifest {
452                name: pkg_name,
453                version: pkg_version,
454                extensions: Vec::new(),
455                skills: Vec::new(),
456                prompts: Vec::new(),
457                themes: Vec::new(),
458                description: None,
459                dependencies: BTreeMap::new(),
460            }
461        } else {
462            PackageManifest {
463                name: spec.to_string(),
464                version: "0.0.0".to_string(),
465                extensions: Vec::new(),
466                skills: Vec::new(),
467                prompts: Vec::new(),
468                themes: Vec::new(),
469                description: None,
470                dependencies: BTreeMap::new(),
471            }
472        };
473
474        let dest = self.pkg_install_dir(&manifest.name);
475        if dest.exists() {
476            fs::remove_dir_all(&dest).with_context(|| {
477                format!("Failed to remove existing package at {}", dest.display())
478            })?;
479        }
480
481        copy_dir_recursive(source_for_copy, &dest)
482            .with_context(|| format!("Failed to copy npm package for '{}'", spec))?;
483
484        let integrity = compute_dir_hash(&dest);
485
486        self.lockfile.insert(LockEntry {
487            source: format!("npm:{}", spec),
488            name: manifest.name.clone(),
489            version: manifest.version.clone(),
490            integrity,
491            scope,
492            source_type: "npm".to_string(),
493            dependencies: manifest.dependencies.clone(),
494        });
495
496        self.installed
497            .insert(manifest.name.clone(), manifest.clone());
498        self.save_lockfile()
499            .context("failed to persist package lockfile")?;
500        Ok(manifest)
501    }
502
503    /// Install from git
504    fn install_git_sync(
505        &mut self,
506        source: &str,
507        repo: &str,
508        ref_: Option<&str>,
509        scope: SourceScope,
510    ) -> Result<PackageManifest> {
511        let parsed = ParsedSource::parse(source);
512        let (host, path) = match &parsed {
513            ParsedSource::Git { host, path, .. } => (host.clone(), path.clone()),
514            _ => bail!("Expected git source"),
515        };
516
517        let target_dir = self.git_install_path(&host, &path, scope);
518
519        if target_dir.exists() {
520            // Already installed
521            return self.load_manifest_from_dir(&target_dir, source, scope);
522        }
523
524        let Some(parent) = target_dir.parent() else {
525            bail!(
526                "Invalid install path: no parent directory for {}",
527                target_dir.display()
528            );
529        };
530        fs::create_dir_all(parent)
531            .with_context(|| format!("Failed to create parent dir for {}", target_dir.display()))?;
532
533        git_clone(repo, &target_dir, ref_)?;
534
535        // Install npm dependencies if package.json exists
536        if target_dir.join(NPM_MANIFEST_NAME).exists() {
537            let _ = std::process::Command::new("npm")
538                .args(["install", "--omit=dev"])
539                .current_dir(&target_dir)
540                .output();
541        }
542
543        self.load_manifest_from_dir(&target_dir, source, scope)
544    }
545
546    /// Load manifest from a directory and register it
547    fn load_manifest_from_dir(
548        &mut self,
549        dir: &Path,
550        source: &str,
551        scope: SourceScope,
552    ) -> Result<PackageManifest> {
553        let manifest = if dir.join(MANIFEST_NAME).exists() {
554            Self::read_manifest(&dir.join(MANIFEST_NAME))?
555        } else {
556            let name = dir
557                .file_name()
558                .map(|n| n.to_string_lossy().to_string())
559                .unwrap_or_else(|| "unknown".to_string());
560            PackageManifest {
561                name,
562                version: "0.0.0".to_string(),
563                extensions: Vec::new(),
564                skills: Vec::new(),
565                prompts: Vec::new(),
566                themes: Vec::new(),
567                description: None,
568                dependencies: BTreeMap::new(),
569            }
570        };
571
572        let integrity = compute_dir_hash(dir);
573
574        self.lockfile.insert(LockEntry {
575            source: source.to_string(),
576            name: manifest.name.clone(),
577            version: manifest.version.clone(),
578            integrity,
579            scope,
580            source_type: "git".to_string(),
581            dependencies: manifest.dependencies.clone(),
582        });
583
584        self.installed
585            .insert(manifest.name.clone(), manifest.clone());
586        self.save_lockfile()
587            .context("failed to persist package lockfile")?;
588        Ok(manifest)
589    }
590
591    /// Install from a URL (archive)
592    async fn install_url(&mut self, url: &str, scope: SourceScope) -> Result<PackageManifest> {
593        let client = shared_http_client();
594
595        let resp = client.get(url).send().await?;
596        if !resp.status().is_success() {
597            bail!("Failed to download {}: {}", url, resp.status());
598        }
599
600        let bytes = resp.bytes().await?;
601
602        let tmp_dir = tempfile::tempdir()?;
603        let archive_name = url.split('/').next_back().unwrap_or("archive");
604        let archive_path = tmp_dir.path().join(archive_name);
605        fs::write(&archive_path, &bytes)?;
606
607        let extract_dir = tmp_dir.path().join("extracted");
608        fs::create_dir_all(&extract_dir)?;
609
610        if archive_name.ends_with(".tar.gz") || archive_name.ends_with(".tgz") {
611            let status = std::process::Command::new("tar")
612                .args(["-xzf", &archive_path.to_string_lossy(), "-C"])
613                .arg(&extract_dir)
614                .output()?;
615            if !status.status.success() {
616                bail!("Failed to extract archive");
617            }
618        } else if archive_name.ends_with(".zip") {
619            // Use unzip if available
620            let status = std::process::Command::new("unzip")
621                .arg("-o")
622                .arg(&archive_path)
623                .arg("-d")
624                .arg(&extract_dir)
625                .output()?;
626            if !status.status.success() {
627                bail!("Failed to extract zip archive");
628            }
629        } else {
630            bail!("Unsupported archive format: {}", archive_name);
631        }
632
633        // Find the extracted package directory
634        let pkg_dir = find_single_subdir(&extract_dir).unwrap_or_else(|| extract_dir.to_path_buf());
635
636        self.ensure_packages_dir()?;
637
638        let manifest = if pkg_dir.join(MANIFEST_NAME).exists() {
639            Self::read_manifest(&pkg_dir.join(MANIFEST_NAME))?
640        } else {
641            let name = url
642                .split('/')
643                .next_back()
644                .unwrap_or("url-package")
645                .trim_end_matches(".tar.gz")
646                .trim_end_matches(".tgz")
647                .trim_end_matches(".zip")
648                .to_string();
649            PackageManifest {
650                name,
651                version: "0.0.0".to_string(),
652                extensions: Vec::new(),
653                skills: Vec::new(),
654                prompts: Vec::new(),
655                themes: Vec::new(),
656                description: None,
657                dependencies: BTreeMap::new(),
658            }
659        };
660
661        let dest = self.pkg_install_dir(&manifest.name);
662        if dest.exists() {
663            fs::remove_dir_all(&dest)?;
664        }
665
666        copy_dir_recursive(&pkg_dir, &dest)?;
667
668        let integrity = compute_dir_hash(&dest);
669
670        self.lockfile.insert(LockEntry {
671            source: url.to_string(),
672            name: manifest.name.clone(),
673            version: manifest.version.clone(),
674            integrity,
675            scope,
676            source_type: "url".to_string(),
677            dependencies: manifest.dependencies.clone(),
678        });
679
680        self.installed
681            .insert(manifest.name.clone(), manifest.clone());
682        self.save_lockfile()
683            .context("failed to persist package lockfile")?;
684        Ok(manifest)
685    }
686
687    /// Install from npm using `npm pack` (legacy sync method)
688    pub fn install_npm(&mut self, name: &str) -> Result<PackageManifest> {
689        self.install_npm_pack(name, SourceScope::User)
690    }
691
692    // ── Uninstall ─────────────────────────────────────────────────────
693
694    /// Uninstall a package by name
695    pub fn uninstall(&mut self, name: &str) -> Result<()> {
696        if !self.installed.contains_key(name) {
697            bail!("Package '{}' is not installed", name);
698        }
699
700        let dest = self.pkg_install_dir(name);
701        if dest.exists() {
702            fs::remove_dir_all(&dest).with_context(|| {
703                format!("Failed to remove package directory {}", dest.display())
704            })?;
705        }
706
707        // Also try to clean up git/npm scoped dirs
708        // (best effort)
709        let _ = self.lockfile.remove(name);
710        self.save_lockfile()
711            .context("failed to persist package lockfile")?;
712
713        self.installed.remove(name);
714        Ok(())
715    }
716
717    /// Uninstall a package from a specific source
718    pub fn uninstall_from_source(&mut self, source: &str, scope: SourceScope) -> Result<()> {
719        let parsed = ParsedSource::parse(source);
720        self.emit_progress(ProgressEvent {
721            event_type: ProgressEventType::Start,
722            action: ProgressAction::Remove,
723            source: source.to_string(),
724            message: Some(format!("Removing {}...", source)),
725        });
726        let result = self.do_uninstall_from_source(&parsed, scope);
727        match &result {
728            Ok(_) => self.emit_progress(ProgressEvent {
729                event_type: ProgressEventType::Complete,
730                action: ProgressAction::Remove,
731                source: source.to_string(),
732                message: None,
733            }),
734            Err(e) => self.emit_progress(ProgressEvent {
735                event_type: ProgressEventType::Error,
736                action: ProgressAction::Remove,
737                source: source.to_string(),
738                message: Some(e.to_string()),
739            }),
740        }
741        result
742    }
743
744    fn do_uninstall_from_source(
745        &mut self,
746        parsed: &ParsedSource,
747        scope: SourceScope,
748    ) -> Result<()> {
749        match parsed {
750            ParsedSource::Npm { name, .. } => {
751                let dest = self.npm_install_path(name, scope);
752                if dest.exists() {
753                    fs::remove_dir_all(&dest)?;
754                }
755                self.installed.remove(name);
756                self.lockfile.remove(name);
757                self.save_lockfile()
758                    .context("failed to persist package lockfile")?;
759                Ok(())
760            }
761            ParsedSource::Git { host, path, .. } => {
762                let dest = self.git_install_path(host, path, scope);
763                if dest.exists() {
764                    fs::remove_dir_all(&dest)?;
765                    prune_empty_parents(&dest, &self.packages_dir);
766                }
767                self.installed.retain(|_, m| {
768                    let parsed_m = ParsedSource::parse(m.name.as_str());
769                    parsed_m.identity() != parsed.identity()
770                });
771                self.lockfile.packages.retain(|_, entry| {
772                    let parsed_e = ParsedSource::parse(&entry.source);
773                    parsed_e.identity() != parsed.identity()
774                });
775                self.save_lockfile()
776                    .context("failed to persist package lockfile")?;
777                Ok(())
778            }
779            ParsedSource::Local { .. } => Ok(()),
780            ParsedSource::Url { .. } => {
781                let identity = parsed.identity();
782                self.lockfile
783                    .packages
784                    .retain(|_, e| ParsedSource::parse(&e.source).identity() != identity);
785                self.save_lockfile()
786                    .context("failed to persist package lockfile")?;
787                Ok(())
788            }
789        }
790    }
791
792    // ── Update ────────────────────────────────────────────────────────
793
794    /// Update a package (re-install from the same source).
795    /// For npm packages, re-runs `npm pack` to get the latest version.
796    /// For local packages, re-copies from the source path (if available).
797    /// For git packages, does a git pull.
798    pub fn update(&mut self, name: &str) -> Result<PackageManifest> {
799        let lock_entry = self.lockfile.get(name).cloned();
800
801        if let Some(entry) = lock_entry {
802            let parsed = ParsedSource::parse(&entry.source);
803            return match &parsed {
804                ParsedSource::Npm { spec, .. } => {
805                    self.emit_progress(ProgressEvent {
806                        event_type: ProgressEventType::Start,
807                        action: ProgressAction::Update,
808                        source: entry.source.clone(),
809                        message: Some(format!("Updating {}...", name)),
810                    });
811                    let result = self.install_npm_pack(spec, entry.scope);
812                    match &result {
813                        Ok(_) => self.emit_progress(ProgressEvent {
814                            event_type: ProgressEventType::Complete,
815                            action: ProgressAction::Update,
816                            source: entry.source.clone(),
817                            message: None,
818                        }),
819                        Err(e) => self.emit_progress(ProgressEvent {
820                            event_type: ProgressEventType::Error,
821                            action: ProgressAction::Update,
822                            source: entry.source.clone(),
823                            message: Some(e.to_string()),
824                        }),
825                    }
826                    result
827                }
828                ParsedSource::Git { repo, ref_, .. } => {
829                    let target_dir = match &parsed {
830                        ParsedSource::Git { host, path, .. } => {
831                            self.git_install_path(host, path, entry.scope)
832                        }
833                        _ => unreachable!(),
834                    };
835                    if target_dir.exists() {
836                        let updated = git_update(&target_dir, ref_.as_deref())?;
837                        if updated && target_dir.join(NPM_MANIFEST_NAME).exists() {
838                            let _ = std::process::Command::new("npm")
839                                .args(["install", "--omit=dev"])
840                                .current_dir(&target_dir)
841                                .output();
842                        }
843                        self.load_manifest_from_dir(&target_dir, &entry.source, entry.scope)
844                    } else {
845                        self.install_git_sync(&entry.source, repo, ref_.as_deref(), entry.scope)
846                    }
847                }
848                ParsedSource::Local { path } => self.install_local(path),
849                ParsedSource::Url { url } => {
850                    run_on_fresh_runtime(self.install_url(url, entry.scope))
851                }
852            };
853        }
854
855        // Fallback: try npm re-install
856        if self.installed.contains_key(name) {
857            self.install_npm_pack(name, SourceScope::User)
858        } else {
859            bail!("Package '{}' is not installed", name);
860        }
861    }
862
863    /// Update all installed packages
864    pub fn update_all(&mut self) -> Vec<(String, Result<PackageManifest>)> {
865        let names: Vec<String> = self.installed.keys().cloned().collect();
866        let mut results = Vec::new();
867        for name in names {
868            let result = self.update(&name);
869            results.push((name, result));
870        }
871        results
872    }
873
874    /// Check for available updates across all packages
875    pub async fn check_for_updates(&self) -> Vec<PackageUpdateInfo> {
876        let mut updates = Vec::new();
877
878        for lock_entry in self.lockfile.packages.values() {
879            let parsed = ParsedSource::parse(&lock_entry.source);
880
881            match &parsed {
882                ParsedSource::Npm { name: pkg_name, .. } => {
883                    // Check npm for newer version
884                    match NpmPackageInfo::fetch(pkg_name).await {
885                        Ok(info) => {
886                            if let Some(latest) = info.latest_version()
887                                && latest != lock_entry.version
888                            {
889                                updates.push(PackageUpdateInfo {
890                                    source: lock_entry.source.clone(),
891                                    display_name: pkg_name.clone(),
892                                    source_type: "npm".to_string(),
893                                    scope: lock_entry.scope,
894                                });
895                            }
896                        }
897                        Err(_) => continue,
898                    }
899                }
900                ParsedSource::Git { host, path, .. } => {
901                    let install_path = self.git_install_path(host, path, lock_entry.scope);
902                    if install_path.exists() {
903                        match git_has_update(&install_path) {
904                            Ok(true) => {
905                                updates.push(PackageUpdateInfo {
906                                    source: lock_entry.source.clone(),
907                                    display_name: format!("{}/{}", host, path),
908                                    source_type: "git".to_string(),
909                                    scope: lock_entry.scope,
910                                });
911                            }
912                            _ => continue,
913                        }
914                    }
915                }
916                _ => continue,
917            }
918        }
919
920        updates
921    }
922
923    // ── List / query ──────────────────────────────────────────────────
924
925    /// List all installed packages
926    pub fn list(&self) -> Vec<&PackageManifest> {
927        self.installed.values().collect()
928    }
929
930    /// List configured packages with metadata
931    pub fn list_configured(&self) -> Vec<ConfiguredPackage> {
932        let mut result = Vec::new();
933        for name in self.installed.keys() {
934            let installed_path = self.get_install_dir(name);
935            let lock_entry = self.lockfile.get(name);
936            result.push(ConfiguredPackage {
937                source: lock_entry
938                    .map(|e| e.source.clone())
939                    .unwrap_or_else(|| name.clone()),
940                scope: lock_entry.map(|e| e.scope).unwrap_or(SourceScope::User),
941                filtered: false,
942                installed_path,
943            });
944        }
945        result
946    }
947
948    /// Check whether a package is installed
949    pub fn is_installed(&self, name: &str) -> bool {
950        self.installed.contains_key(name)
951    }
952
953    /// Get the install directory for a package (if it exists on disk)
954    pub fn get_install_dir(&self, name: &str) -> Option<PathBuf> {
955        let dir = self.pkg_install_dir(name);
956        if dir.exists() { Some(dir) } else { None }
957    }
958
959    /// Get the installed path for a source at a given scope
960    pub fn get_installed_path_for_source(
961        &self,
962        source: &str,
963        scope: SourceScope,
964    ) -> Option<PathBuf> {
965        let parsed = ParsedSource::parse(source);
966        match &parsed {
967            ParsedSource::Npm { name, .. } => {
968                let path = self.npm_install_path(name, scope);
969                if path.exists() { Some(path) } else { None }
970            }
971            ParsedSource::Git { host, path, .. } => {
972                let path = self.git_install_path(host, path, scope);
973                if path.exists() { Some(path) } else { None }
974            }
975            ParsedSource::Local { path } => {
976                let p = PathBuf::from(path);
977                if p.exists() { Some(p) } else { None }
978            }
979            ParsedSource::Url { .. } => None,
980        }
981    }
982
983    // ── Resource discovery ────────────────────────────────────────────
984
985    /// Discover all resources from an installed package.
986    pub fn discover_resources(&self, name: &str) -> Result<Vec<DiscoveredResource>> {
987        let manifest = self
988            .installed
989            .get(name)
990            .with_context(|| format!("Package '{}' not found", name))?;
991
992        let install_dir = self.pkg_install_dir(name);
993        if !install_dir.exists() {
994            bail!("Install directory for '{}' does not exist", name);
995        }
996
997        let mut resources = Vec::new();
998
999        let has_explicit = !manifest.extensions.is_empty()
1000            || !manifest.skills.is_empty()
1001            || !manifest.prompts.is_empty()
1002            || !manifest.themes.is_empty();
1003
1004        if has_explicit {
1005            for ext in &manifest.extensions {
1006                let path = install_dir.join(ext);
1007                if path.exists() {
1008                    resources.push(DiscoveredResource {
1009                        kind: ResourceKind::Extension,
1010                        path,
1011                        relative_path: ext.clone(),
1012                    });
1013                }
1014            }
1015            for skill in &manifest.skills {
1016                let path = install_dir.join(skill);
1017                if path.exists() {
1018                    resources.push(DiscoveredResource {
1019                        kind: ResourceKind::Skill,
1020                        path,
1021                        relative_path: skill.clone(),
1022                    });
1023                }
1024            }
1025            for prompt in &manifest.prompts {
1026                let path = install_dir.join(prompt);
1027                if path.exists() {
1028                    resources.push(DiscoveredResource {
1029                        kind: ResourceKind::Prompt,
1030                        path,
1031                        relative_path: prompt.clone(),
1032                    });
1033                }
1034            }
1035            for theme in &manifest.themes {
1036                let path = install_dir.join(theme);
1037                if path.exists() {
1038                    resources.push(DiscoveredResource {
1039                        kind: ResourceKind::Theme,
1040                        path,
1041                        relative_path: theme.clone(),
1042                    });
1043                }
1044            }
1045        } else {
1046            resources.extend(discover_extensions(&install_dir));
1047            resources.extend(discover_skills(&install_dir));
1048            resources.extend(discover_prompts(&install_dir));
1049            resources.extend(discover_themes(&install_dir));
1050        }
1051
1052        Ok(resources)
1053    }
1054
1055    /// Get resource counts for a package
1056    pub fn resource_counts(&self, name: &str) -> Result<ResourceCounts> {
1057        let resources = self.discover_resources(name)?;
1058        let mut counts = ResourceCounts::default();
1059        for r in &resources {
1060            match r.kind {
1061                ResourceKind::Extension => counts.extensions += 1,
1062                ResourceKind::Skill => counts.skills += 1,
1063                ResourceKind::Prompt => counts.prompts += 1,
1064                ResourceKind::Theme => counts.themes += 1,
1065            }
1066        }
1067        Ok(counts)
1068    }
1069
1070    /// Resolve all resources from all installed packages, producing ResolvedPaths
1071    pub fn resolve(&self) -> ResolvedPaths {
1072        let mut extensions = Vec::new();
1073        let mut skills = Vec::new();
1074        let mut prompts = Vec::new();
1075        let mut themes = Vec::new();
1076
1077        for name in self.installed.keys() {
1078            let install_dir = self.pkg_install_dir(name);
1079            if !install_dir.exists() {
1080                continue;
1081            }
1082
1083            let metadata = PathMetadata {
1084                source: name.clone(),
1085                scope: SourceScope::User,
1086                origin: ResourceOrigin::Package,
1087                base_dir: Some(install_dir.clone()),
1088            };
1089
1090            // Use discover_resources logic
1091            if let Ok(resources) = self.discover_resources(name) {
1092                for r in resources {
1093                    match r.kind {
1094                        ResourceKind::Extension => extensions.push(ResolvedResource {
1095                            path: r.path,
1096                            enabled: true,
1097                            metadata: metadata.clone(),
1098                        }),
1099                        ResourceKind::Skill => skills.push(ResolvedResource {
1100                            path: r.path,
1101                            enabled: true,
1102                            metadata: metadata.clone(),
1103                        }),
1104                        ResourceKind::Prompt => prompts.push(ResolvedResource {
1105                            path: r.path,
1106                            enabled: true,
1107                            metadata: metadata.clone(),
1108                        }),
1109                        ResourceKind::Theme => themes.push(ResolvedResource {
1110                            path: r.path,
1111                            enabled: true,
1112                            metadata: metadata.clone(),
1113                        }),
1114                    }
1115                }
1116            }
1117        }
1118
1119        ResolvedPaths {
1120            extensions,
1121            skills,
1122            prompts,
1123            themes,
1124        }
1125    }
1126
1127    // ── Dependency resolution ─────────────────────────────────────────
1128
1129    /// Resolve dependencies for all installed packages.
1130    /// Returns a list of (package, missing_dependencies) tuples.
1131    pub fn resolve_dependencies(&self) -> Vec<(String, Vec<String>)> {
1132        let mut result = Vec::new();
1133        let installed_names: HashSet<&str> = self.installed.keys().map(|s| s.as_str()).collect();
1134
1135        for (name, manifest) in &self.installed {
1136            let missing: Vec<String> = manifest
1137                .dependencies
1138                .keys()
1139                .filter(|dep| !installed_names.contains(dep.as_str()))
1140                .cloned()
1141                .collect();
1142
1143            if !missing.is_empty() {
1144                result.push((name.clone(), missing));
1145            }
1146        }
1147
1148        result
1149    }
1150
1151    /// Validate a package structure
1152    pub fn validate_package(dir: &Path) -> Result<Vec<String>> {
1153        let mut warnings = Vec::new();
1154
1155        // Check for manifest
1156        if !dir.join(MANIFEST_NAME).exists() && !dir.join(NPM_MANIFEST_NAME).exists() {
1157            warnings.push(format!(
1158                "No {} or {} found",
1159                MANIFEST_NAME, NPM_MANIFEST_NAME
1160            ));
1161        }
1162
1163        // Try to parse manifest
1164        if dir.join(MANIFEST_NAME).exists() {
1165            match Self::read_manifest(&dir.join(MANIFEST_NAME)) {
1166                Ok(m) => {
1167                    if m.name.is_empty() {
1168                        warnings.push("Package name is empty".to_string());
1169                    }
1170                    if m.version.is_empty() {
1171                        warnings.push("Package version is empty".to_string());
1172                    }
1173                    if semver::Version::parse(&m.version).is_err() {
1174                        warnings.push(format!("Version '{}' is not valid semver", m.version));
1175                    }
1176                    let has_resources = !m.extensions.is_empty()
1177                        || !m.skills.is_empty()
1178                        || !m.prompts.is_empty()
1179                        || !m.themes.is_empty();
1180                    if !has_resources {
1181                        // Check if auto-discovery would find anything
1182                        let discovered = discover_extensions(dir)
1183                            .into_iter()
1184                            .chain(discover_skills(dir))
1185                            .chain(discover_prompts(dir))
1186                            .chain(discover_themes(dir))
1187                            .count();
1188                        if discovered == 0 {
1189                            warnings.push(
1190                                "Package has no explicit resources and auto-discovery found nothing"
1191                                    .to_string(),
1192                            );
1193                        }
1194                    }
1195
1196                    // Check that explicit paths exist
1197                    for ext in &m.extensions {
1198                        if !dir.join(ext).exists() {
1199                            warnings.push(format!("Extension path '{}' does not exist", ext));
1200                        }
1201                    }
1202                    for skill in &m.skills {
1203                        if !dir.join(skill).exists() {
1204                            warnings.push(format!("Skill path '{}' does not exist", skill));
1205                        }
1206                    }
1207                    for prompt in &m.prompts {
1208                        if !dir.join(prompt).exists() {
1209                            warnings.push(format!("Prompt path '{}' does not exist", prompt));
1210                        }
1211                    }
1212                    for theme in &m.themes {
1213                        if !dir.join(theme).exists() {
1214                            warnings.push(format!("Theme path '{}' does not exist", theme));
1215                        }
1216                    }
1217                }
1218                Err(e) => {
1219                    warnings.push(format!("Failed to parse {}: {}", MANIFEST_NAME, e));
1220                }
1221            }
1222        }
1223
1224        // Check for .gitignore or .ignore
1225        if !dir.join(".gitignore").exists() && !dir.join(".ignore").exists() {
1226            warnings.push("No .gitignore or .ignore file found".to_string());
1227        }
1228
1229        Ok(warnings)
1230    }
1231
1232    // ── Version queries ───────────────────────────────────────────────
1233
1234    /// Get installed version of a package
1235    pub fn get_installed_version(&self, name: &str) -> Option<&str> {
1236        self.installed.get(name).map(|m| m.version.as_str())
1237    }
1238
1239    /// Check if an installed version satisfies a semver requirement
1240    pub fn version_satisfies(&self, name: &str, requirement: &str) -> bool {
1241        if let Some(version) = self.get_installed_version(name)
1242            && let Ok(v) = semver::Version::parse(version)
1243            && let Ok(req) = semver::VersionReq::parse(requirement)
1244        {
1245            return req.matches(&v);
1246        }
1247        false
1248    }
1249
1250    /// Get the lockfile
1251    pub fn lockfile(&self) -> &Lockfile {
1252        &self.lockfile
1253    }
1254}
1255
1256// ── Fresh runtime helper ────────────────────────────────────────────────
1257
1258/// Run an async future on a fresh tokio runtime created on a dedicated OS thread.
1259///
1260/// This avoids the "Cannot start a runtime from within a runtime" panic that
1261/// `Runtime::new()?.block_on(future)` causes when called from inside an
1262/// existing tokio context (e.g., from an agent tool callback or TUI handler).
1263fn run_on_fresh_runtime<F, T>(future: F) -> Result<T>
1264where
1265    F: Future<Output = Result<T>> + Send,
1266    T: Send,
1267{
1268    std::thread::scope(|s| {
1269        s.spawn(|| {
1270            let rt = tokio::runtime::Builder::new_current_thread()
1271                .enable_all()
1272                .build()
1273                .context("failed to build temp runtime")?;
1274            rt.block_on(future)
1275        })
1276        .join()
1277        .map_err(|_| anyhow::anyhow!("runtime thread panicked"))?
1278    })
1279}
1280
1281#[cfg(test)]
1282mod tests {
1283    use super::super::lockfile::{
1284        compute_dir_hash as _compute_dir_hash,
1285        verify_lockfile_integrity as _verify_lockfile_integrity,
1286    };
1287    use super::*;
1288    use std::path::PathBuf;
1289
1290    fn setup_temp_packages_dir() -> (tempfile::TempDir, PathBuf) {
1291        let tmp = tempfile::tempdir().unwrap();
1292        let packages_dir = tmp.path().join("packages");
1293        fs::create_dir_all(&packages_dir).unwrap();
1294        (tmp, packages_dir)
1295    }
1296
1297    fn create_test_package(base: &Path, name: &str, version: &str) -> PathBuf {
1298        let pkg_dir = base.join("source-pkg");
1299        fs::create_dir_all(&pkg_dir).unwrap();
1300
1301        let manifest = PackageManifest {
1302            name: name.to_string(),
1303            version: version.to_string(),
1304            extensions: vec!["ext1.so".to_string()],
1305            skills: vec!["skill-a".to_string()],
1306            prompts: vec![],
1307            themes: vec![],
1308            description: None,
1309            dependencies: BTreeMap::new(),
1310        };
1311
1312        let toml_content = toml::to_string_pretty(&manifest).unwrap();
1313        fs::write(pkg_dir.join(MANIFEST_NAME), toml_content).unwrap();
1314        fs::write(pkg_dir.join("ext1.so"), "fake extension").unwrap();
1315        fs::create_dir_all(pkg_dir.join("skill-a")).unwrap();
1316        fs::write(pkg_dir.join("skill-a").join("SKILL.md"), "# Skill A").unwrap();
1317
1318        pkg_dir
1319    }
1320
1321    fn create_test_package_with_auto_discovery(base: &Path, name: &str, version: &str) -> PathBuf {
1322        let pkg_dir = base.join("source-pkg-auto");
1323        fs::create_dir_all(&pkg_dir).unwrap();
1324
1325        let manifest = PackageManifest {
1326            name: name.to_string(),
1327            version: version.to_string(),
1328            extensions: vec![],
1329            skills: vec![],
1330            prompts: vec![],
1331            themes: vec![],
1332            description: None,
1333            dependencies: BTreeMap::new(),
1334        };
1335        let toml_content = toml::to_string_pretty(&manifest).unwrap();
1336        fs::write(pkg_dir.join(MANIFEST_NAME), toml_content).unwrap();
1337
1338        fs::write(pkg_dir.join("myext.so"), "extension").unwrap();
1339        fs::create_dir_all(pkg_dir.join("my-skill")).unwrap();
1340        fs::write(pkg_dir.join("my-skill").join("SKILL.md"), "# My Skill").unwrap();
1341        fs::create_dir_all(pkg_dir.join("prompts")).unwrap();
1342        fs::write(pkg_dir.join("prompts").join("review.md"), "# Review").unwrap();
1343        fs::create_dir_all(pkg_dir.join("themes")).unwrap();
1344        fs::write(pkg_dir.join("themes").join("dark.json"), "{}").unwrap();
1345
1346        pkg_dir
1347    }
1348
1349    #[test]
1350    fn test_install_and_list() {
1351        let (tmp, packages_dir) = setup_temp_packages_dir();
1352
1353        let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
1354        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1355
1356        let manifest = mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1357        assert_eq!(manifest.name, "test-pkg");
1358        assert_eq!(manifest.version, "1.0.0");
1359
1360        let installed = mgr.list();
1361        assert_eq!(installed.len(), 1);
1362        assert_eq!(installed[0].name, "test-pkg");
1363    }
1364
1365    #[test]
1366    fn test_uninstall() {
1367        let (tmp, packages_dir) = setup_temp_packages_dir();
1368
1369        let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
1370        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1371
1372        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1373        assert!(mgr.is_installed("test-pkg"));
1374
1375        mgr.uninstall("test-pkg").unwrap();
1376        assert!(!mgr.is_installed("test-pkg"));
1377        assert!(mgr.list().is_empty());
1378    }
1379
1380    #[test]
1381    fn test_uninstall_not_installed() {
1382        let (_tmp, packages_dir) = setup_temp_packages_dir();
1383        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1384
1385        let result = mgr.uninstall("nonexistent");
1386        assert!(result.is_err());
1387    }
1388
1389    #[test]
1390    fn test_install_scoped_package() {
1391        let (tmp, packages_dir) = setup_temp_packages_dir();
1392
1393        let pkg_dir = create_test_package(tmp.path(), "@foo/oxi-tools", "2.0.0");
1394        let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
1395
1396        let manifest = mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1397        assert_eq!(manifest.name, "@foo/oxi-tools");
1398
1399        let expected_dir = packages_dir.join("foo-oxi-tools");
1400        assert!(expected_dir.exists());
1401    }
1402
1403    #[test]
1404    fn test_reinstall_overwrites() {
1405        let (tmp, packages_dir) = setup_temp_packages_dir();
1406
1407        let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
1408        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1409
1410        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1411
1412        let pkg_dir_v2 = tmp.path().join("source-pkg-v2");
1413        fs::create_dir_all(&pkg_dir_v2).unwrap();
1414        let manifest_v2 = PackageManifest {
1415            name: "test-pkg".to_string(),
1416            version: "2.0.0".to_string(),
1417            extensions: vec![],
1418            skills: vec![],
1419            prompts: vec![],
1420            themes: vec![],
1421            description: None,
1422            dependencies: BTreeMap::new(),
1423        };
1424        fs::write(
1425            pkg_dir_v2.join(MANIFEST_NAME),
1426            toml::to_string_pretty(&manifest_v2).unwrap(),
1427        )
1428        .unwrap();
1429
1430        mgr.install(pkg_dir_v2.to_str().unwrap()).unwrap();
1431
1432        let installed = mgr.list();
1433        assert_eq!(installed.len(), 1);
1434        assert_eq!(installed[0].version, "2.0.0");
1435    }
1436
1437    #[test]
1438    fn test_empty_packages_dir() {
1439        let (_tmp, packages_dir) = setup_temp_packages_dir();
1440        let mgr = PackageManager::with_dir(packages_dir).unwrap();
1441        assert!(mgr.list().is_empty());
1442    }
1443
1444    #[test]
1445    fn test_packages_dir_not_exists() {
1446        let tmp = tempfile::tempdir().unwrap();
1447        let nonexistent = tmp.path().join("does-not-exist");
1448        let mgr = PackageManager::with_dir(nonexistent).unwrap();
1449        assert!(mgr.list().is_empty());
1450    }
1451
1452    #[test]
1453    fn test_discover_resources_explicit() {
1454        let (tmp, packages_dir) = setup_temp_packages_dir();
1455
1456        let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
1457        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1458        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1459
1460        let resources = mgr.discover_resources("test-pkg").unwrap();
1461        assert_eq!(resources.len(), 2);
1462
1463        let extensions: Vec<_> = resources
1464            .iter()
1465            .filter(|r| r.kind == ResourceKind::Extension)
1466            .collect();
1467        let skills: Vec<_> = resources
1468            .iter()
1469            .filter(|r| r.kind == ResourceKind::Skill)
1470            .collect();
1471        assert_eq!(extensions.len(), 1);
1472        assert_eq!(skills.len(), 1);
1473    }
1474
1475    #[test]
1476    fn test_discover_resources_auto() {
1477        let (tmp, packages_dir) = setup_temp_packages_dir();
1478
1479        let pkg_dir = create_test_package_with_auto_discovery(tmp.path(), "auto-pkg", "1.0.0");
1480        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1481        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1482
1483        let resources = mgr.discover_resources("auto-pkg").unwrap();
1484
1485        let ext_count = resources
1486            .iter()
1487            .filter(|r| r.kind == ResourceKind::Extension)
1488            .count();
1489        let skill_count = resources
1490            .iter()
1491            .filter(|r| r.kind == ResourceKind::Skill)
1492            .count();
1493        let prompt_count = resources
1494            .iter()
1495            .filter(|r| r.kind == ResourceKind::Prompt)
1496            .count();
1497        let theme_count = resources
1498            .iter()
1499            .filter(|r| r.kind == ResourceKind::Theme)
1500            .count();
1501
1502        assert!(
1503            ext_count >= 1,
1504            "Expected at least 1 extension, got {}",
1505            ext_count
1506        );
1507        assert!(
1508            skill_count >= 1,
1509            "Expected at least 1 skill, got {}",
1510            skill_count
1511        );
1512        assert!(
1513            prompt_count >= 1,
1514            "Expected at least 1 prompt, got {}",
1515            prompt_count
1516        );
1517        assert!(
1518            theme_count >= 1,
1519            "Expected at least 1 theme, got {}",
1520            theme_count
1521        );
1522    }
1523
1524    #[test]
1525    fn test_resource_counts() {
1526        let (tmp, packages_dir) = setup_temp_packages_dir();
1527
1528        let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
1529        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1530        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1531
1532        let counts = mgr.resource_counts("test-pkg").unwrap();
1533        assert_eq!(counts.extensions, 1);
1534        assert_eq!(counts.skills, 1);
1535        assert_eq!(counts.prompts, 0);
1536        assert_eq!(counts.themes, 0);
1537    }
1538
1539    #[test]
1540    fn test_resource_counts_display() {
1541        let counts = ResourceCounts {
1542            extensions: 2,
1543            skills: 1,
1544            prompts: 0,
1545            themes: 3,
1546        };
1547        assert_eq!(counts.to_string(), "2 ext, 1 skill, 3 theme");
1548
1549        let empty = ResourceCounts::default();
1550        assert_eq!(empty.to_string(), "-");
1551    }
1552
1553    #[test]
1554    fn test_resource_kind_display() {
1555        assert_eq!(ResourceKind::Extension.to_string(), "extension");
1556        assert_eq!(ResourceKind::Skill.to_string(), "skill");
1557        assert_eq!(ResourceKind::Prompt.to_string(), "prompt");
1558        assert_eq!(ResourceKind::Theme.to_string(), "theme");
1559    }
1560
1561    #[test]
1562    fn test_get_install_dir() {
1563        let (tmp, packages_dir) = setup_temp_packages_dir();
1564
1565        let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
1566        let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
1567        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1568
1569        let dir = mgr.get_install_dir("test-pkg").unwrap();
1570        assert!(dir.exists());
1571        assert!(dir.join(MANIFEST_NAME).exists());
1572
1573        assert!(mgr.get_install_dir("nonexistent").is_none());
1574    }
1575
1576    #[test]
1577    fn test_discover_resources_not_installed() {
1578        let (_tmp, packages_dir) = setup_temp_packages_dir();
1579        let mgr = PackageManager::with_dir(packages_dir).unwrap();
1580
1581        let result = mgr.discover_resources("nonexistent");
1582        assert!(result.is_err());
1583    }
1584
1585    #[test]
1586    fn test_update_not_installed() {
1587        let (_tmp, packages_dir) = setup_temp_packages_dir();
1588        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1589
1590        let result = mgr.update("nonexistent");
1591        assert!(result.is_err());
1592    }
1593
1594    // ── Source parsing tests ──────────────────────────────────────────
1595
1596    #[test]
1597    fn test_parse_npm_source() {
1598        let parsed = ParsedSource::parse("npm:express@4.18.0");
1599        match parsed {
1600            ParsedSource::Npm { spec, name, pinned } => {
1601                assert_eq!(spec, "express@4.18.0");
1602                assert_eq!(name, "express");
1603                assert!(pinned);
1604            }
1605            _ => panic!("Expected Npm source"),
1606        }
1607
1608        let parsed = ParsedSource::parse("npm:lodash");
1609        match parsed {
1610            ParsedSource::Npm { name, pinned, .. } => {
1611                assert_eq!(name, "lodash");
1612                assert!(!pinned);
1613            }
1614            _ => panic!("Expected Npm source"),
1615        }
1616    }
1617
1618    #[test]
1619    fn test_parse_git_source() {
1620        let parsed = ParsedSource::parse("https://github.com/org/repo.git");
1621        match parsed {
1622            ParsedSource::Git {
1623                host, path, ref_, ..
1624            } => {
1625                assert_eq!(host, "github.com");
1626                assert_eq!(path, "org/repo");
1627                assert!(ref_.is_none());
1628            }
1629            _ => panic!("Expected Git source"),
1630        }
1631
1632        let parsed = ParsedSource::parse("https://github.com/org/repo.git@v1.0.0");
1633        match parsed {
1634            ParsedSource::Git { path, ref_, .. } => {
1635                assert_eq!(path, "org/repo");
1636                assert_eq!(ref_.as_deref(), Some("v1.0.0"));
1637            }
1638            _ => panic!("Expected Git source"),
1639        }
1640    }
1641
1642    #[test]
1643    fn test_parse_github_shorthand() {
1644        let parsed = ParsedSource::parse("github:org/repo@main");
1645        match parsed {
1646            ParsedSource::Git {
1647                host, path, ref_, ..
1648            } => {
1649                assert_eq!(host, "github.com");
1650                assert_eq!(path, "org/repo");
1651                assert_eq!(ref_.as_deref(), Some("main"));
1652            }
1653            _ => panic!("Expected Git source"),
1654        }
1655    }
1656
1657    #[test]
1658    fn test_parse_local_source() {
1659        let parsed = ParsedSource::parse("/path/to/package");
1660        match parsed {
1661            ParsedSource::Local { path } => {
1662                assert_eq!(path, "/path/to/package");
1663            }
1664            _ => panic!("Expected Local source"),
1665        }
1666
1667        let parsed = ParsedSource::parse("./relative/path");
1668        match parsed {
1669            ParsedSource::Local { path } => {
1670                assert_eq!(path, "./relative/path");
1671            }
1672            _ => panic!("Expected Local source"),
1673        }
1674    }
1675
1676    #[test]
1677    fn test_parse_url_source() {
1678        let parsed = ParsedSource::parse("https://example.com/pkg.tar.gz");
1679        match parsed {
1680            ParsedSource::Url { url } => {
1681                assert_eq!(url, "https://example.com/pkg.tar.gz");
1682            }
1683            _ => panic!("Expected Url source"),
1684        }
1685    }
1686
1687    #[test]
1688    fn test_source_identity() {
1689        let npm = ParsedSource::parse("npm:express@4.18.0");
1690        assert_eq!(npm.identity(), "npm:express");
1691
1692        let git = ParsedSource::parse("https://github.com/org/repo.git");
1693        assert_eq!(git.identity(), "git:github.com/org/repo");
1694
1695        let local = ParsedSource::parse("/path/to/pkg");
1696        assert_eq!(local.identity(), "local:/path/to/pkg");
1697    }
1698
1699    #[test]
1700    fn test_parse_npm_spec() {
1701        let (name, pinned) = parse_npm_spec("express@4.18.0");
1702        assert_eq!(name, "express");
1703        assert!(pinned);
1704
1705        let (name, pinned) = parse_npm_spec("express");
1706        assert_eq!(name, "express");
1707        assert!(!pinned);
1708
1709        let (name, pinned) = parse_npm_spec("@scope/pkg@1.0.0");
1710        assert_eq!(name, "@scope/pkg");
1711        assert!(pinned);
1712    }
1713
1714    // ── Lockfile tests ────────────────────────────────────────────────
1715
1716    #[test]
1717    fn test_lockfile_roundtrip() {
1718        let (tmp, _) = setup_temp_packages_dir();
1719        let lock_path = tmp.path().join(LOCKFILE_NAME);
1720
1721        let mut lock = Lockfile::new();
1722        lock.insert(LockEntry {
1723            source: "npm:express@4.18.0".to_string(),
1724            name: "express".to_string(),
1725            version: "4.18.0".to_string(),
1726            integrity: Some("sha256-abc123".to_string()),
1727            scope: SourceScope::User,
1728            source_type: "npm".to_string(),
1729            dependencies: BTreeMap::new(),
1730        });
1731
1732        lock.write(&lock_path).unwrap();
1733
1734        let loaded = Lockfile::read(&lock_path).unwrap().unwrap();
1735        assert_eq!(loaded.packages.len(), 1);
1736        assert_eq!(loaded.packages["express"].version, "4.18.0");
1737        assert_eq!(
1738            loaded.packages["express"].integrity.as_deref(),
1739            Some("sha256-abc123")
1740        );
1741    }
1742
1743    #[test]
1744    fn test_lockfile_install_roundtrip() {
1745        let (tmp, packages_dir) = setup_temp_packages_dir();
1746        let pkg_dir = create_test_package(tmp.path(), "locked-pkg", "1.0.0");
1747
1748        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1749        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1750
1751        // Lockfile should have been written
1752        let lock_path = mgr.packages_dir().join(LOCKFILE_NAME);
1753        assert!(lock_path.exists());
1754
1755        let lock = Lockfile::read(&lock_path).unwrap().unwrap();
1756        assert!(lock.contains("locked-pkg"));
1757        let entry = lock.get("locked-pkg").unwrap();
1758        assert_eq!(entry.version, "1.0.0");
1759    }
1760
1761    // ── Validation tests ──────────────────────────────────────────────
1762
1763    #[test]
1764    fn test_validate_valid_package() {
1765        let (tmp, _) = setup_temp_packages_dir();
1766        let pkg_dir = create_test_package(tmp.path(), "valid-pkg", "1.0.0");
1767        let warnings = PackageManager::validate_package(&pkg_dir).unwrap();
1768        // Should have minimal warnings (maybe just about .gitignore)
1769        assert!(
1770            warnings.len() <= 1,
1771            "Expected <= 1 warning, got {:?}",
1772            warnings
1773        );
1774    }
1775
1776    #[test]
1777    fn test_validate_empty_dir() {
1778        let tmp = tempfile::tempdir().unwrap();
1779        let empty_dir = tmp.path().join("empty-pkg");
1780        fs::create_dir_all(&empty_dir).unwrap();
1781        let warnings = PackageManager::validate_package(&empty_dir).unwrap();
1782        assert!(!warnings.is_empty());
1783    }
1784
1785    // ── Dependency tests ──────────────────────────────────────────────
1786
1787    #[test]
1788    fn test_resolve_dependencies() {
1789        let (tmp, packages_dir) = setup_temp_packages_dir();
1790
1791        // Create a package with dependencies
1792        let pkg_dir = tmp.path().join("dep-pkg");
1793        fs::create_dir_all(&pkg_dir).unwrap();
1794        let mut deps = BTreeMap::new();
1795        deps.insert("lodash".to_string(), "^4.0.0".to_string());
1796        deps.insert("nonexistent-pkg".to_string(), "^1.0.0".to_string());
1797
1798        let manifest = PackageManifest {
1799            name: "dep-pkg".to_string(),
1800            version: "1.0.0".to_string(),
1801            extensions: vec![],
1802            skills: vec![],
1803            prompts: vec![],
1804            themes: vec![],
1805            description: None,
1806            dependencies: deps,
1807        };
1808        fs::write(
1809            pkg_dir.join(MANIFEST_NAME),
1810            toml::to_string_pretty(&manifest).unwrap(),
1811        )
1812        .unwrap();
1813
1814        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1815        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1816
1817        let missing = mgr.resolve_dependencies();
1818        assert_eq!(missing.len(), 1);
1819        assert_eq!(missing[0].0, "dep-pkg");
1820        assert!(
1821            missing[0].1.contains(&"lodash".to_string())
1822                || missing[0].1.contains(&"nonexistent-pkg".to_string())
1823        );
1824    }
1825
1826    // ── Version tests ─────────────────────────────────────────────────
1827
1828    #[test]
1829    fn test_version_satisfies() {
1830        let (tmp, packages_dir) = setup_temp_packages_dir();
1831        let pkg_dir = create_test_package(tmp.path(), "ver-pkg", "1.2.3");
1832        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1833        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1834
1835        assert!(mgr.version_satisfies("ver-pkg", "^1.0.0"));
1836        assert!(mgr.version_satisfies("ver-pkg", ">=1.0.0"));
1837        assert!(!mgr.version_satisfies("ver-pkg", "^2.0.0"));
1838        assert!(!mgr.version_satisfies("ver-pkg", "<1.0.0"));
1839    }
1840
1841    #[test]
1842    fn test_get_installed_version() {
1843        let (tmp, packages_dir) = setup_temp_packages_dir();
1844        let pkg_dir = create_test_package(tmp.path(), "ver-pkg", "3.1.4");
1845        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1846        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1847
1848        assert_eq!(mgr.get_installed_version("ver-pkg"), Some("3.1.4"));
1849        assert_eq!(mgr.get_installed_version("nonexistent"), None);
1850    }
1851
1852    // ── Resolve tests ─────────────────────────────────────────────────
1853
1854    #[test]
1855    fn test_resolve() {
1856        let (tmp, packages_dir) = setup_temp_packages_dir();
1857        let pkg_dir = create_test_package(tmp.path(), "resolve-pkg", "1.0.0");
1858        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1859        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1860
1861        let resolved = mgr.resolve();
1862        assert!(!resolved.extensions.is_empty() || !resolved.skills.is_empty());
1863    }
1864
1865    // ── Progress callback tests ───────────────────────────────────────
1866
1867    #[test]
1868    fn test_progress_callback() {
1869        use std::sync::{Arc, Mutex};
1870
1871        let events: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
1872        let events_clone = events.clone();
1873
1874        let (tmp, packages_dir) = setup_temp_packages_dir();
1875        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1876
1877        mgr.set_progress_callback(Box::new(move |event| {
1878            let mut e = events_clone.lock().unwrap();
1879            e.push(format!("{:?}:{:?}", event.event_type, event.action));
1880        }));
1881
1882        let pkg_dir = create_test_package(tmp.path(), "progress-pkg", "1.0.0");
1883        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1884
1885        // install_local doesn't use with_progress, so no events expected from install()
1886        // Just verify the progress event mechanism exists and doesn't panic
1887        let _event_count = events.lock().unwrap().len();
1888    }
1889
1890    #[test]
1891    fn test_list_configured() {
1892        let (tmp, packages_dir) = setup_temp_packages_dir();
1893        let pkg_dir = create_test_package(tmp.path(), "cfg-pkg", "1.0.0");
1894        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1895        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1896
1897        let configured = mgr.list_configured();
1898        assert_eq!(configured.len(), 1);
1899        assert!(configured[0].source.contains("source-pkg"));
1900        // source comes from lockfile, might be the local path
1901    }
1902
1903    // ── F-1 regression: lockfile integrity verify on load ──────────
1904
1905    /// `verify_lockfile_integrity` returns Ok(()) when the directory
1906    /// contents hash to the lockfile-recorded `sha256-<hex>` value.
1907    #[test]
1908    fn verify_lockfile_integrity_accepts_matching_dir() {
1909        let tmp = tempfile::tempdir().unwrap();
1910        let pkg_dir = tmp.path().join("pkg");
1911        fs::create_dir_all(&pkg_dir).unwrap();
1912        fs::write(pkg_dir.join("a.txt"), b"hello").unwrap();
1913        fs::write(pkg_dir.join("b.txt"), b"world").unwrap();
1914
1915        let expected = _compute_dir_hash(&pkg_dir).expect("compute_dir_hash must succeed");
1916
1917        assert!(_verify_lockfile_integrity(&pkg_dir, &expected).is_ok());
1918    }
1919
1920    /// A directory that has been mutated after install must fail the
1921    /// integrity check. This is the F-1 supply-chain scenario the audit
1922    /// flagged: previously `LockEntry.integrity` was computed at install
1923    /// and never re-checked, so a tampered package would silently load.
1924    #[test]
1925    fn verify_lockfile_integrity_rejects_tampered_dir() {
1926        let tmp = tempfile::tempdir().unwrap();
1927        let pkg_dir = tmp.path().join("pkg");
1928        fs::create_dir_all(&pkg_dir).unwrap();
1929        fs::write(pkg_dir.join("a.txt"), b"hello").unwrap();
1930
1931        let expected = _compute_dir_hash(&pkg_dir).expect("compute_dir_hash must succeed");
1932
1933        // Tamper: replace file contents.
1934        fs::write(pkg_dir.join("a.txt"), b"tampered").unwrap();
1935
1936        let err = _verify_lockfile_integrity(&pkg_dir, &expected)
1937            .expect_err("tampered dir must not verify");
1938        assert!(err.contains("sha256 mismatch"), "unexpected error: {err}");
1939    }
1940
1941    /// Missing `sha256-` prefix is a clear lockfile-format error.
1942    #[test]
1943    fn verify_lockfile_integrity_rejects_bad_prefix() {
1944        let tmp = tempfile::tempdir().unwrap();
1945        let pkg_dir = tmp.path().join("pkg");
1946        fs::create_dir_all(&pkg_dir).unwrap();
1947
1948        let err = _verify_lockfile_integrity(&pkg_dir, "abc123")
1949            .expect_err("missing sha256- prefix must be rejected");
1950        assert!(err.contains("not in `sha256-<hex>` form"));
1951    }
1952
1953    /// `load_installed` must drop a package whose lockfile-recorded
1954    /// integrity no longer matches the on-disk directory.
1955    #[test]
1956    fn load_installed_skips_tampered_package() {
1957        let (tmp, packages_dir) = setup_temp_packages_dir();
1958        let pkg_dir = create_test_package(tmp.path(), "tamper-pkg", "1.0.0");
1959
1960        // Install normally — this writes integrity to the lockfile.
1961        {
1962            let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
1963            mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1964        }
1965
1966        // Tamper with the installed package after install.
1967        let installed_name = "tamper-pkg";
1968        let installed_safe = installed_name.replace('@', "").replace('/', "-");
1969        let on_disk = packages_dir.join(installed_safe);
1970        fs::write(on_disk.join(MANIFEST_NAME), "tampered = true\n").unwrap();
1971
1972        // Re-open the manager — `load_installed` should drop the package.
1973        let mgr2 = PackageManager::with_dir(packages_dir).unwrap();
1974        let names: Vec<&str> = mgr2.list().iter().map(|m| m.name.as_str()).collect();
1975        assert!(
1976            !names.contains(&"tamper-pkg"),
1977            "tampered package must be excluded from load_installed; loaded names: {names:?}"
1978        );
1979    }
1980}