Skip to main content

oxi/storage/
packages.rs

1//! Package system for oxi CLI
2//!
3//! Packages bundle extensions, skills, prompts, and themes for sharing.
4//! Supports local directories, npm packages, git repositories, GitHub
5//! shorthand, and URL-based archives.
6//!
7//! ## Package sources
8//!
9//! - **Local path**: a directory with `oxi-package.toml` or auto-discoverable resources
10//! - **npm**: `npm:<package>[@<version>]` — resolved from the npm registry
11//! - **git**: `https://github.com/org/repo.git[@ref]`, `git://…`, `git+ssh://…`
12//! - **GitHub shorthand**: `github:org/repo[@ref]`
13//! - **URL**: direct `.tar.gz` / `.zip` archive
14//!
15//! ## Package manifest
16//!
17//! A package is a directory containing an `oxi-package.toml` file:
18//!
19//! ```toml
20//! name = "@foo/oxi-tools"
21//! version = "1.0.0"
22//! extensions = ["ext/index.ts"]
23//! skills = ["skills/code-review/SKILL.md"]
24//! prompts = ["prompts/review.md"]
25//! themes = ["themes/dark-pro.json"]
26//! ```
27//!
28//! ## Resource discovery
29//!
30//! When a package lacks explicit resource lists, resources are discovered
31//! automatically by scanning the package directory:
32//! - **Extensions**: `.so`, `.dylib`, `.dll` files, or `index.ts`/`index.js` entries
33//! - **Skills**: Directories containing `SKILL.md`
34//! - **Prompts**: `.md` files in `prompts/` subdirectory
35//! - **Themes**: `.json` files in `themes/` subdirectory
36//!
37//! ## Lockfile
38//!
39//! An `oxi-lock.json` file records exact versions/refs for reproducibility.
40
41use crate::util::http_client::shared_http_client;
42use anyhow::{Context, Result, bail};
43use serde::{Deserialize, Serialize};
44use sha2::{Digest, Sha256};
45use std::collections::{BTreeMap, HashMap, HashSet};
46use std::fs;
47use std::path::{Path, PathBuf};
48use std::sync::LazyLock;
49
50/// Run an async future on a fresh tokio runtime created on a dedicated OS thread.
51///
52/// This avoids the "Cannot start a runtime from within a runtime" panic that
53/// `Runtime::new()?.block_on(future)` causes when called from inside an
54/// existing tokio context (e.g., from an agent tool callback or TUI handler).
55fn run_on_fresh_runtime<F, T>(future: F) -> Result<T>
56where
57    F: Future<Output = Result<T>> + Send,
58    T: Send,
59{
60    std::thread::scope(|s| {
61        s.spawn(|| {
62            let rt = tokio::runtime::Builder::new_current_thread()
63                .enable_all()
64                .build()
65                .context("failed to build temp runtime")?;
66            rt.block_on(future)
67        })
68        .join()
69        .map_err(|_| anyhow::anyhow!("runtime thread panicked"))?
70    })
71}
72
73/// Cached regex for parsing npm package specs
74static NPM_SPEC_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
75    regex::Regex::new(r"^(@?[^@]+(?:/[^@]+)?)(?:@(.+))?$").expect("valid static regex")
76});
77
78// ── Constants ─────────────────────────────────────────────────────────
79
80const LOCKFILE_NAME: &str = "oxi-lock.json";
81const MANIFEST_NAME: &str = "oxi-package.toml";
82const NPM_MANIFEST_NAME: &str = "package.json";
83
84// ── Types ─────────────────────────────────────────────────────────────
85
86/// Types of resources a package can contribute
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum ResourceKind {
90    /// extension variant.
91    Extension,
92    /// skill variant.
93    Skill,
94    /// prompt variant.
95    Prompt,
96    /// theme variant.
97    Theme,
98}
99
100impl std::fmt::Display for ResourceKind {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        match self {
103            ResourceKind::Extension => write!(f, "extension"),
104            ResourceKind::Skill => write!(f, "skill"),
105            ResourceKind::Prompt => write!(f, "prompt"),
106            ResourceKind::Theme => write!(f, "theme"),
107        }
108    }
109}
110
111// All resource kinds for iteration
112
113/// Package manifest describing bundled resources
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct PackageManifest {
116    /// Package name (e.g. "@foo/oxi-tools")
117    pub name: String,
118    /// Semantic version (e.g. "1.0.0")
119    pub version: String,
120    /// Extension paths relative to the package root
121    #[serde(default)]
122    pub extensions: Vec<String>,
123    /// Skill names/paths
124    #[serde(default)]
125    pub skills: Vec<String>,
126    /// Prompt template paths
127    #[serde(default)]
128    pub prompts: Vec<String>,
129    /// Theme paths
130    #[serde(default)]
131    pub themes: Vec<String>,
132    /// Optional description
133    #[serde(default)]
134    pub description: Option<String>,
135    /// Package dependencies (name -> version constraint)
136    #[serde(default)]
137    pub dependencies: BTreeMap<String, String>,
138}
139
140/// A discovered resource within a package
141#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct DiscoveredResource {
143    /// Resource type
144    pub kind: ResourceKind,
145    /// Absolute path to the resource
146    pub path: PathBuf,
147    /// Relative path within the package
148    pub relative_path: String,
149}
150
151/// Metadata about a resolved resource path
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct PathMetadata {
154    /// Source specifier
155    pub source: String,
156    /// Scope (user / project)
157    pub scope: SourceScope,
158    /// Whether this is a package resource or top-level
159    pub origin: ResourceOrigin,
160    /// Base directory for resolving relative paths
161    pub base_dir: Option<PathBuf>,
162}
163
164/// Origin of a resource
165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum ResourceOrigin {
168    /// package variant.
169    Package,
170    /// top level variant.
171    TopLevel,
172}
173
174/// Scope for package sources
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case")]
177pub enum SourceScope {
178    /// user variant.
179    User,
180    /// project variant.
181    Project,
182}
183
184impl std::fmt::Display for SourceScope {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        match self {
187            SourceScope::User => write!(f, "user"),
188            SourceScope::Project => write!(f, "project"),
189        }
190    }
191}
192
193/// A resolved resource with metadata
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct ResolvedResource {
196    /// Absolute path to the resource
197    pub path: PathBuf,
198    /// Whether this resource is enabled
199    pub enabled: bool,
200    /// Metadata about the resource
201    pub metadata: PathMetadata,
202}
203
204/// Resolved paths for all resource types
205#[derive(Debug, Clone, Default, Serialize, Deserialize)]
206pub struct ResolvedPaths {
207    /// pub.
208    pub extensions: Vec<ResolvedResource>,
209    /// pub.
210    pub skills: Vec<ResolvedResource>,
211    /// pub.
212    pub prompts: Vec<ResolvedResource>,
213    /// pub.
214    pub themes: Vec<ResolvedResource>,
215}
216
217/// Progress events for package operations
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct ProgressEvent {
220    /// pub.
221    pub event_type: ProgressEventType,
222    /// pub.
223    pub action: ProgressAction,
224    /// pub.
225    pub source: String,
226    /// pub.
227    pub message: Option<String>,
228}
229
230/// Progress event type
231#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
232#[serde(rename_all = "snake_case")]
233pub enum ProgressEventType {
234    /// start variant.
235    Start,
236    /// progress variant.
237    Progress,
238    /// complete variant.
239    Complete,
240    /// error variant.
241    Error,
242}
243
244/// Action being performed
245#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
246#[serde(rename_all = "snake_case")]
247pub enum ProgressAction {
248    /// install variant.
249    Install,
250    /// remove variant.
251    Remove,
252    /// update variant.
253    Update,
254    /// clone variant.
255    Clone,
256    /// pull variant.
257    Pull,
258}
259
260impl std::fmt::Display for ProgressAction {
261    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
262        match self {
263            ProgressAction::Install => write!(f, "install"),
264            ProgressAction::Remove => write!(f, "remove"),
265            ProgressAction::Update => write!(f, "update"),
266            ProgressAction::Clone => write!(f, "clone"),
267            ProgressAction::Pull => write!(f, "pull"),
268        }
269    }
270}
271
272/// Callback for progress events
273pub type ProgressCallback = Box<dyn Fn(ProgressEvent) + Send + Sync>;
274
275// ── Source parsing ────────────────────────────────────────────────────
276
277/// Parsed package source
278#[derive(Debug, Clone, Serialize, Deserialize)]
279#[serde(tag = "type", rename_all = "snake_case")]
280pub enum ParsedSource {
281    /// Variant.
282    Npm {
283        /// Full spec (e.g. "express@4.18.0")
284        spec: String,
285        /// Package name without version
286        name: String,
287        /// Whether a version was pinned
288        pinned: bool,
289    },
290    /// Variant.
291    Git {
292        /// Full repository URL
293        repo: String,
294        /// Host (e.g. "github.com")
295        host: String,
296        /// Path on host (e.g. "org/repo")
297        path: String,
298        /// Optional ref (branch / tag / commit)
299        ref_: Option<String>,
300    },
301    /// Variant.
302    Local {
303        /// Local path
304        path: String,
305    },
306    /// Variant.
307    Url {
308        /// URL to archive
309        url: String,
310    },
311}
312
313impl ParsedSource {
314    /// Parse a source string into a ParsedSource
315    pub fn parse(source: &str) -> Self {
316        if let Some(rest) = source.strip_prefix("npm:") {
317            let spec = rest.trim();
318            let (name, pinned) = parse_npm_spec(spec);
319            return ParsedSource::Npm {
320                spec: spec.to_string(),
321                name,
322                pinned,
323            };
324        }
325
326        if let Some(rest) = source.strip_prefix("github:") {
327            let parts: Vec<&str> = rest.splitn(2, '/').collect();
328            if parts.len() == 2 {
329                let (path, ref_) = split_git_path_ref(rest);
330                return ParsedSource::Git {
331                    repo: format!("https://github.com/{}.git", path),
332                    host: "github.com".to_string(),
333                    path,
334                    ref_,
335                };
336            }
337        }
338
339        if source.starts_with("git+") || source.starts_with("git://") || source.starts_with("git@")
340        {
341            return parse_git_source(source);
342        }
343
344        if source.starts_with("https://") || source.starts_with("http://") {
345            // Distinguish git URLs from plain archive URLs
346            if source.ends_with(".git")
347                || source.contains("github.com")
348                || source.contains("gitlab.com")
349            {
350                return parse_git_source(source);
351            }
352            // Archive URL (.tar.gz, .zip, .tgz)
353            if source.ends_with(".tar.gz")
354                || source.ends_with(".tgz")
355                || source.ends_with(".zip")
356                || source.ends_with(".tar.bz2")
357            {
358                return ParsedSource::Url {
359                    url: source.to_string(),
360                };
361            }
362            // Default to git for http(s) URLs that look like repos
363            return parse_git_source(source);
364        }
365
366        // Local path
367        ParsedSource::Local {
368            path: source.to_string(),
369        }
370    }
371
372    /// Get a unique identity key for this source (ignoring version/ref)
373    pub fn identity(&self) -> String {
374        match self {
375            ParsedSource::Npm { name, .. } => format!("npm:{}", name),
376            ParsedSource::Git { host, path, .. } => format!("git:{}/{}", host, path),
377            ParsedSource::Local { path } => format!("local:{}", path),
378            ParsedSource::Url { url } => format!("url:{}", url),
379        }
380    }
381
382    /// Get a display-friendly name
383    pub fn display_name(&self) -> String {
384        match self {
385            ParsedSource::Npm { name, .. } => name.clone(),
386            ParsedSource::Git { host, path, .. } => format!("{}/{}", host, path),
387            ParsedSource::Local { path } => path.clone(),
388            ParsedSource::Url { url } => url.clone(),
389        }
390    }
391}
392
393/// Parse an npm spec into (name, pinned)
394fn parse_npm_spec(spec: &str) -> (String, bool) {
395    // Handle scoped packages like @scope/name@version
396    if let Some(caps) = NPM_SPEC_RE.captures(spec) {
397        let name = caps.get(1).map(|m| m.as_str()).unwrap_or(spec);
398        let has_version = caps.get(2).is_some();
399        return (name.to_string(), has_version);
400    }
401    (spec.to_string(), false)
402}
403
404/// Split a git path like "org/repo@ref" into ("org/repo", Some("ref"))
405fn split_git_path_ref(input: &str) -> (String, Option<String>) {
406    if let Some(at_pos) = input.rfind('@') {
407        // Make sure it's not part of an email (don't split if there's no / before @)
408        if input[..at_pos].contains('/') {
409            return (
410                input[..at_pos].to_string(),
411                Some(input[at_pos + 1..].to_string()),
412            );
413        }
414    }
415    (input.to_string(), None)
416}
417
418/// Parse a git source string
419fn parse_git_source(source: &str) -> ParsedSource {
420    // Handle git@host:path format (SSH)
421    if let Some(rest) = source.strip_prefix("git@") {
422        let colon_pos = rest.find(':').unwrap_or(rest.len());
423        let host = &rest[..colon_pos];
424        let path_part = rest.get(colon_pos + 1..).unwrap_or("");
425        let (path, ref_) = if let Some(hash_pos) = path_part.rfind('#') {
426            (
427                path_part[..hash_pos].to_string(),
428                Some(path_part[hash_pos + 1..].to_string()),
429            )
430        } else {
431            split_git_path_ref(path_part)
432        };
433        let repo = format!("git@{}:{}", host, path_part);
434        let host = host.to_string();
435        return ParsedSource::Git {
436            repo,
437            host,
438            path: path.trim_end_matches(".git").to_string(),
439            ref_,
440        };
441    }
442
443    // Handle git+ssh://, git+https://, git:// prefixes
444    let url_str = source
445        .strip_prefix("git+")
446        .unwrap_or(source)
447        .strip_prefix("git://")
448        .map(|s| format!("https://{}", s))
449        .unwrap_or_else(|| source.strip_prefix("git+").unwrap_or(source).to_string());
450
451    // Parse URL to extract host and path
452    let url = match url::Url::parse(&url_str) {
453        Ok(u) => u,
454        Err(_) => {
455            return ParsedSource::Local {
456                path: source.to_string(),
457            };
458        }
459    };
460
461    let host = url.host_str().unwrap_or("unknown").to_string();
462    let full_path = url.path().trim_start_matches('/').to_string();
463
464    // Check for #ref fragment
465    let fragment = url.fragment().map(|f| f.to_string());
466
467    let (path, ref_) = if let Some(frag) = fragment {
468        (full_path.trim_end_matches(".git").to_string(), Some(frag))
469    } else {
470        let (p, r) = split_git_path_ref(&full_path);
471        (p.trim_end_matches(".git").to_string(), r)
472    };
473
474    let repo = url_str.clone();
475
476    ParsedSource::Git {
477        repo,
478        host,
479        path,
480        ref_,
481    }
482}
483
484// ── NPM Registry ──────────────────────────────────────────────────────
485
486/// Information fetched from the npm registry
487#[derive(Debug, Clone, Serialize, Deserialize)]
488pub struct NpmPackageInfo {
489    /// pub.
490    pub name: String,
491    /// pub.
492    pub versions: BTreeMap<String, serde_json::Value>,
493    /// pub.
494    #[serde(rename = "dist-tags")]
495    pub dist_tags: BTreeMap<String, String>,
496}
497
498impl NpmPackageInfo {
499    /// Fetch package info from the npm registry
500    pub async fn fetch(name: &str) -> Result<Self> {
501        let url = format!("https://registry.npmjs.org/{}", name);
502        let client = shared_http_client();
503
504        let resp = client
505            .get(&url)
506            .header("Accept", "application/json")
507            .send()
508            .await
509            .with_context(|| format!("Failed to fetch npm info for '{}'", name))?;
510
511        if !resp.status().is_success() {
512            bail!("npm registry returned {} for '{}'", resp.status(), name);
513        }
514
515        let info: NpmPackageInfo = resp
516            .json()
517            .await
518            .with_context(|| format!("Failed to parse npm registry response for '{}'", name))?;
519
520        Ok(info)
521    }
522
523    /// Get the latest version from dist-tags
524    pub fn latest_version(&self) -> Option<&str> {
525        self.dist_tags.get("latest").map(|s| s.as_str())
526    }
527
528    /// Find the best matching version for a constraint
529    pub fn resolve_version(&self, constraint: &str) -> Option<String> {
530        if constraint == "latest" || constraint.is_empty() {
531            return self.latest_version().map(|s| s.to_string());
532        }
533
534        // Try exact match first
535        if self.versions.contains_key(constraint) {
536            return Some(constraint.to_string());
537        }
538
539        // Try semver range matching
540        if let Ok(req) = semver::VersionReq::parse(constraint) {
541            let mut best: Option<semver::Version> = None;
542            for ver_str in self.versions.keys() {
543                if let Ok(ver) = semver::Version::parse(ver_str)
544                    && req.matches(&ver)
545                {
546                    match &best {
547                        Some(b) if ver > *b => best = Some(ver),
548                        None => best = Some(ver),
549                        _ => {}
550                    }
551                }
552            }
553            if let Some(v) = best {
554                return Some(v.to_string());
555            }
556        }
557
558        None
559    }
560}
561
562/// Get the latest version of an npm package
563pub async fn get_latest_npm_version(name: &str) -> Result<String> {
564    let info = NpmPackageInfo::fetch(name).await?;
565    info.latest_version()
566        .map(|s| s.to_string())
567        .context(format!("No latest version found for '{}'", name))
568}
569
570// ── Git Operations ────────────────────────────────────────────────────
571
572/// Run a git command and capture stdout
573fn git_command(args: &[&str], cwd: Option<&Path>) -> Result<String> {
574    let mut cmd = std::process::Command::new("git");
575    cmd.args(args)
576        .env("GIT_TERMINAL_PROMPT", "0")
577        .stdout(std::process::Stdio::piped())
578        .stderr(std::process::Stdio::piped());
579
580    if let Some(dir) = cwd {
581        cmd.current_dir(dir);
582    }
583
584    let output = cmd.output().context("Failed to execute git")?;
585
586    if !output.status.success() {
587        let stderr = String::from_utf8_lossy(&output.stderr);
588        bail!(
589            "git {} failed ({}): {}",
590            args.join(" "),
591            output.status,
592            stderr.trim()
593        );
594    }
595
596    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
597}
598
599/// Run a git command (no capture)
600fn git_command_silent(args: &[&str], cwd: Option<&Path>) -> Result<()> {
601    let mut cmd = std::process::Command::new("git");
602    cmd.args(args)
603        .env("GIT_TERMINAL_PROMPT", "0")
604        .stdout(std::process::Stdio::null())
605        .stderr(std::process::Stdio::null());
606
607    if let Some(dir) = cwd {
608        cmd.current_dir(dir);
609    }
610
611    let status = cmd.status().context("Failed to execute git")?;
612    if !status.success() {
613        bail!("git {} failed ({})", args.join(" "), status);
614    }
615    Ok(())
616}
617
618/// Clone a git repository
619pub fn git_clone(repo_url: &str, target_dir: &Path, ref_: Option<&str>) -> Result<()> {
620    if target_dir.exists() {
621        bail!("Target directory already exists: {}", target_dir.display());
622    }
623    fs::create_dir_all(target_dir)
624        .with_context(|| format!("Failed to create {}", target_dir.display()))?;
625
626    let target_str = target_dir.to_string_lossy().to_string();
627    let args = vec!["clone", repo_url, &target_str];
628
629    git_command_silent(&args, None)?;
630
631    if let Some(r) = ref_ {
632        git_command_silent(&["checkout", r], Some(target_dir))?;
633    }
634
635    Ok(())
636}
637
638/// Pull/update a git repository in place
639pub fn git_update(repo_dir: &Path, ref_: Option<&str>) -> Result<bool> {
640    if !repo_dir.exists() {
641        bail!(
642            "Repository directory does not exist: {}",
643            repo_dir.display()
644        );
645    }
646
647    // Get current HEAD
648    let local_head = git_command(&["rev-parse", "HEAD"], Some(repo_dir))?;
649
650    // Determine what to fetch
651    let fetch_ref = if let Some(r) = ref_ {
652        r.to_string()
653    } else {
654        // Try to get upstream ref
655        match git_command(
656            &["rev-parse", "--abbrev-ref", "@{upstream}"],
657            Some(repo_dir),
658        ) {
659            Ok(upstream) => {
660                if let Some(branch) = upstream.strip_prefix("origin/") {
661                    format!("+refs/heads/{branch}:refs/remotes/origin/{branch}")
662                } else {
663                    "+HEAD:refs/remotes/origin/HEAD".to_string()
664                }
665            }
666            Err(_) => "+HEAD:refs/remotes/origin/HEAD".to_string(),
667        }
668    };
669
670    git_command_silent(
671        &["fetch", "--prune", "--no-tags", "origin", &fetch_ref],
672        Some(repo_dir),
673    )?;
674
675    // Determine what to reset to
676    let target_ref = ref_.unwrap_or("origin/HEAD");
677    let remote_head = git_command(&["rev-parse", target_ref], Some(repo_dir))?;
678
679    if local_head == remote_head {
680        return Ok(false); // No update needed
681    }
682
683    git_command_silent(&["reset", "--hard", target_ref], Some(repo_dir))?;
684    git_command_silent(&["clean", "-fdx"], Some(repo_dir))?;
685
686    Ok(true) // Updated
687}
688
689/// Check if a git repo has remote updates available
690pub fn git_has_update(repo_dir: &Path) -> Result<bool> {
691    let local_head = git_command(&["rev-parse", "HEAD"], Some(repo_dir))?;
692
693    // Try to get upstream
694    let upstream_ref = match git_command(
695        &["rev-parse", "--abbrev-ref", "@{upstream}"],
696        Some(repo_dir),
697    ) {
698        Ok(u) if u.starts_with("origin/") => {
699            let branch = &u["origin/".len()..];
700            format!("refs/heads/{branch}")
701        }
702        _ => "HEAD".to_string(),
703    };
704
705    // Fetch quietly and check remote
706    let _ = git_command_silent(&["fetch", "--prune", "--no-tags", "origin"], Some(repo_dir));
707
708    let remote_head = git_command(&["ls-remote", "origin", &upstream_ref], None)?;
709
710    // Parse first hash from ls-remote output
711    let remote_hash = remote_head
712        .lines()
713        .next()
714        .and_then(|line| line.split_whitespace().next())
715        .unwrap_or("");
716
717    Ok(local_head != remote_hash)
718}
719
720// ── Lockfile ──────────────────────────────────────────────────────────
721
722/// Lockfile entry for an installed package
723#[derive(Debug, Clone, Serialize, Deserialize)]
724pub struct LockEntry {
725    /// Source specifier
726    pub source: String,
727    /// Package name
728    pub name: String,
729    /// Resolved version or ref
730    pub version: String,
731    /// Integrity hash (sha256)
732    pub integrity: Option<String>,
733    /// Scope
734    pub scope: SourceScope,
735    /// Type of source
736    pub source_type: String,
737    /// Dependencies
738    #[serde(default)]
739    pub dependencies: BTreeMap<String, String>,
740}
741
742/// The lockfile structure
743#[derive(Debug, Clone, Serialize, Deserialize)]
744pub struct Lockfile {
745    /// Lockfile version
746    pub version: u32,
747    /// Locked packages
748    pub packages: BTreeMap<String, LockEntry>,
749}
750
751impl Lockfile {
752    /// Create a new empty lockfile
753    pub fn new() -> Self {
754        Self {
755            version: 1,
756            packages: BTreeMap::new(),
757        }
758    }
759
760    /// Read lockfile from disk
761    pub fn read(path: &Path) -> Result<Option<Self>> {
762        if !path.exists() {
763            return Ok(None);
764        }
765        let content = fs::read_to_string(path)
766            .with_context(|| format!("Failed to read lockfile {}", path.display()))?;
767        let lock: Lockfile = serde_json::from_str(&content)
768            .with_context(|| format!("Failed to parse lockfile {}", path.display()))?;
769        Ok(Some(lock))
770    }
771
772    /// Write lockfile to disk
773    pub fn write(&self, path: &Path) -> Result<()> {
774        let content = serde_json::to_string_pretty(self).context("Failed to serialize lockfile")?;
775        fs::write(path, content)
776            .with_context(|| format!("Failed to write lockfile {}", path.display()))?;
777        Ok(())
778    }
779
780    /// Add or update an entry
781    pub fn insert(&mut self, entry: LockEntry) {
782        self.packages.insert(entry.name.clone(), entry);
783    }
784
785    /// Remove an entry
786    pub fn remove(&mut self, name: &str) -> Option<LockEntry> {
787        self.packages.remove(name)
788    }
789
790    /// Check if a package is locked
791    pub fn contains(&self, name: &str) -> bool {
792        self.packages.contains_key(name)
793    }
794
795    /// Get an entry
796    pub fn get(&self, name: &str) -> Option<&LockEntry> {
797        self.packages.get(name)
798    }
799}
800
801impl Default for Lockfile {
802    fn default() -> Self {
803        Self::new()
804    }
805}
806
807// ── Counts ────────────────────────────────────────────────────────────
808
809/// Counts of each resource type in a package
810#[derive(Debug, Clone, Default, Serialize, Deserialize)]
811pub struct ResourceCounts {
812    /// pub.
813    pub extensions: usize,
814    /// pub.
815    pub skills: usize,
816    /// pub.
817    pub prompts: usize,
818    /// pub.
819    pub themes: usize,
820}
821
822impl std::fmt::Display for ResourceCounts {
823    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
824        let mut parts = Vec::new();
825        if self.extensions > 0 {
826            parts.push(format!("{} ext", self.extensions));
827        }
828        if self.skills > 0 {
829            parts.push(format!("{} skill", self.skills));
830        }
831        if self.prompts > 0 {
832            parts.push(format!("{} prompt", self.prompts));
833        }
834        if self.themes > 0 {
835            parts.push(format!("{} theme", self.themes));
836        }
837        if parts.is_empty() {
838            write!(f, "-")?;
839        } else {
840            write!(f, "{}", parts.join(", "))?;
841        }
842        Ok(())
843    }
844}
845
846// ── Package Manager ───────────────────────────────────────────────────
847
848/// Information about an available package update
849#[derive(Debug, Clone, Serialize, Deserialize)]
850pub struct PackageUpdateInfo {
851    /// pub.
852    pub source: String,
853    /// pub.
854    pub display_name: String,
855    /// pub.
856    pub source_type: String, // "npm" or "git"
857    /// pub.
858    pub scope: SourceScope,
859}
860
861/// A configured package
862#[derive(Debug, Clone, Serialize, Deserialize)]
863pub struct ConfiguredPackage {
864    /// pub.
865    pub source: String,
866    /// pub.
867    pub scope: SourceScope,
868    /// pub.
869    pub filtered: bool,
870    /// pub.
871    pub installed_path: Option<PathBuf>,
872}
873
874/// Manages installation, removal, and listing of packages
875pub struct PackageManager {
876    packages_dir: PathBuf,
877    /// Base directory for project-scoped packages
878    project_dir: PathBuf,
879    installed: HashMap<String, PackageManifest>,
880    lockfile: Lockfile,
881    progress_callback: Option<Box<dyn Fn(ProgressEvent) + Send + Sync>>,
882}
883
884impl PackageManager {
885    /// Create a new PackageManager using the default packages directory
886    pub fn new() -> Result<Self> {
887        let base = dirs::home_dir().context("Cannot determine home directory")?;
888        let packages_dir = base.join(".oxi").join("packages");
889        let project_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
890        let mut mgr = Self {
891            packages_dir,
892            project_dir,
893            installed: HashMap::new(),
894            lockfile: Lockfile::new(),
895            progress_callback: None,
896        };
897        mgr.load_installed()?;
898        mgr.load_lockfile()?;
899        Ok(mgr)
900    }
901
902    /// Create a PackageManager with a custom packages directory (for testing)
903    pub fn with_dir(packages_dir: PathBuf) -> Result<Self> {
904        let project_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
905        let mut mgr = Self {
906            packages_dir,
907            project_dir,
908            installed: HashMap::new(),
909            lockfile: Lockfile::new(),
910            progress_callback: None,
911        };
912        mgr.load_installed()?;
913        mgr.load_lockfile()?;
914        Ok(mgr)
915    }
916
917    /// Set the project directory for project-scoped packages
918    pub fn set_project_dir(&mut self, dir: PathBuf) {
919        self.project_dir = dir;
920    }
921
922    /// Set a progress callback
923    pub fn set_progress_callback(&mut self, callback: Box<dyn Fn(ProgressEvent) + Send + Sync>) {
924        self.progress_callback = Some(callback);
925    }
926
927    fn emit_progress(&self, event: ProgressEvent) {
928        if let Some(ref cb) = self.progress_callback {
929            cb(event);
930        }
931    }
932
933    // ── Loading ───────────────────────────────────────────────────────
934
935    /// Load all installed package manifests from disk.
936    ///
937    /// For every installed package whose `LockEntry` records an integrity
938    /// hash, the on-disk SHA-256 is recomputed and compared. Mismatches
939    /// (caused by tampering, partial disk failure, or any non-atomic write
940    /// that survived a crash) remove the package from the in-memory `installed`
941    /// map AND invalidate the matching lockfile entry, so subsequent
942    /// `update_all` re-fetches the package instead of trusting the cached copy.
943    /// A mismatch is logged at `warn` level — the CLI keeps booting (other
944    /// packages remain usable) but the affected package is treated as
945    /// un-installed.
946    fn load_installed(&mut self) -> Result<()> {
947        if !self.packages_dir.exists() {
948            return Ok(());
949        }
950        for entry in fs::read_dir(&self.packages_dir)? {
951            let entry = entry?;
952            let manifest_path = entry.path().join(MANIFEST_NAME);
953            if manifest_path.exists() {
954                match Self::read_manifest(&manifest_path) {
955                    Ok(manifest) => {
956                        let name = manifest.name.clone();
957                        let install_dir = entry.path();
958
959                        // F-1 (audit 2026-06-21): verify lockfile integrity
960                        // before trusting the installed package. Without this,
961                        // `compute_dir_hash` is only computed at install and
962                        // never re-checked on subsequent loads — a local
963                        // attacker (or a partial-write crash) could swap files
964                        // under `~/.oxi/packages/<name>/` and the next session
965                        // would silently load the tampered manifest.
966                        if let Some(expected) = self
967                            .lockfile
968                            .packages
969                            .get(&name)
970                            .and_then(|e| e.integrity.as_ref())
971                        {
972                            match verify_lockfile_integrity(&install_dir, expected) {
973                                Ok(()) => {}
974                                Err(reason) => {
975                                    tracing::warn!(
976                                        package = %name,
977                                        expected = %expected,
978                                        reason = %reason,
979                                        "package integrity mismatch on load — treating as un-installed; re-install with `oxi pkg install`"
980                                    );
981                                    // Drop the lockfile entry so `update_all`
982                                    // re-resolves from the source.
983                                    self.lockfile.packages.remove(&name);
984                                    continue;
985                                }
986                            }
987                        }
988
989                        self.installed.insert(name, manifest);
990                    }
991                    Err(e) => {
992                        tracing::warn!(
993                            "Failed to load manifest {}: {}",
994                            manifest_path.display(),
995                            e
996                        );
997                    }
998                }
999            }
1000        }
1001        Ok(())
1002    }
1003
1004    /// Load lockfile from disk
1005    fn load_lockfile(&mut self) -> Result<()> {
1006        let lock_path = self.packages_dir.join(LOCKFILE_NAME);
1007        if let Some(lock) = Lockfile::read(&lock_path)? {
1008            self.lockfile = lock;
1009        }
1010        Ok(())
1011    }
1012
1013    /// Save lockfile to disk
1014    fn save_lockfile(&self) -> Result<()> {
1015        let lock_path = self.packages_dir.join(LOCKFILE_NAME);
1016        self.lockfile.write(&lock_path)
1017    }
1018
1019    // ── Manifest ──────────────────────────────────────────────────────
1020
1021    /// Read and parse a package manifest from disk
1022    fn read_manifest(path: &Path) -> Result<PackageManifest> {
1023        let content = fs::read_to_string(path)
1024            .with_context(|| format!("Failed to read manifest {}", path.display()))?;
1025        let manifest: PackageManifest = toml::from_str(&content)
1026            .with_context(|| format!("Failed to parse manifest {}", path.display()))?;
1027        Ok(manifest)
1028    }
1029
1030    /// Try to read a `package.json` manifest (for npm packages)
1031    fn read_package_json(dir: &Path) -> Option<serde_json::Value> {
1032        let path = dir.join(NPM_MANIFEST_NAME);
1033        let content = fs::read_to_string(path).ok()?;
1034        serde_json::from_str(&content).ok()
1035    }
1036
1037    // ── Path helpers ──────────────────────────────────────────────────
1038
1039    /// Get the installation directory for a package
1040    fn pkg_install_dir(&self, name: &str) -> PathBuf {
1041        let safe_name = name.replace('@', "").replace('/', "-");
1042        self.packages_dir.join(safe_name)
1043    }
1044
1045    /// Get the packages directory path
1046    pub fn packages_dir(&self) -> &Path {
1047        &self.packages_dir
1048    }
1049
1050    /// Get install dir for a git source
1051    fn git_install_path(&self, host: &str, path: &str, scope: SourceScope) -> PathBuf {
1052        match scope {
1053            SourceScope::Project => self
1054                .project_dir
1055                .join(".oxi")
1056                .join("git")
1057                .join(host)
1058                .join(path),
1059            SourceScope::User => self.packages_dir.join("git").join(host).join(path),
1060        }
1061    }
1062
1063    /// Get install dir for an npm source
1064    fn npm_install_path(&self, name: &str, scope: SourceScope) -> PathBuf {
1065        let safe_name = name.replace('@', "").replace('/', "-");
1066        match scope {
1067            SourceScope::Project => self.project_dir.join(".oxi").join("npm").join(safe_name),
1068            SourceScope::User => self.packages_dir.join("npm").join(safe_name),
1069        }
1070    }
1071
1072    // ── Install ───────────────────────────────────────────────────────
1073
1074    /// Ensure packages directory exists
1075    fn ensure_packages_dir(&self) -> Result<()> {
1076        fs::create_dir_all(&self.packages_dir).with_context(|| {
1077            format!(
1078                "Failed to create packages directory {}",
1079                self.packages_dir.display()
1080            )
1081        })
1082    }
1083
1084    /// Install a package from a local directory path
1085    pub fn install(&mut self, source: &str) -> Result<PackageManifest> {
1086        let parsed = ParsedSource::parse(source);
1087        match parsed {
1088            ParsedSource::Local { path } => self.install_local(&path),
1089            _ => bail!("Use install_from_source() for non-local packages"),
1090        }
1091    }
1092
1093    /// Install a package from a local directory path
1094    fn install_local(&mut self, path: &str) -> Result<PackageManifest> {
1095        let source_path = Path::new(path);
1096        let manifest_path = source_path.join(MANIFEST_NAME);
1097
1098        let manifest = if manifest_path.exists() {
1099            Self::read_manifest(&manifest_path)
1100                .with_context(|| format!("No valid {} found in {}", MANIFEST_NAME, path))?
1101        } else {
1102            // Synthesise a minimal manifest
1103            let name = source_path
1104                .file_name()
1105                .map(|n| n.to_string_lossy().to_string())
1106                .unwrap_or_else(|| "unknown".to_string());
1107            PackageManifest {
1108                name,
1109                version: "0.0.0".to_string(),
1110                extensions: Vec::new(),
1111                skills: Vec::new(),
1112                prompts: Vec::new(),
1113                themes: Vec::new(),
1114                description: None,
1115                dependencies: BTreeMap::new(),
1116            }
1117        };
1118
1119        let dest = self.pkg_install_dir(&manifest.name);
1120        self.ensure_packages_dir()?;
1121
1122        if dest.exists() {
1123            fs::remove_dir_all(&dest).with_context(|| {
1124                format!("Failed to remove existing package at {}", dest.display())
1125            })?;
1126        }
1127
1128        copy_dir_recursive(source_path, &dest).with_context(|| {
1129            format!("Failed to copy package from {} to {}", path, dest.display())
1130        })?;
1131
1132        let integrity = compute_dir_hash(&dest);
1133
1134        self.lockfile.insert(LockEntry {
1135            source: path.to_string(),
1136            name: manifest.name.clone(),
1137            version: manifest.version.clone(),
1138            integrity,
1139            scope: SourceScope::User,
1140            source_type: "local".to_string(),
1141            dependencies: manifest.dependencies.clone(),
1142        });
1143
1144        self.installed
1145            .insert(manifest.name.clone(), manifest.clone());
1146        self.save_lockfile()
1147            .context("failed to persist package lockfile")?;
1148        Ok(manifest)
1149    }
1150
1151    /// Install from any source
1152    pub fn install_from_source(
1153        &mut self,
1154        source: &str,
1155        scope: SourceScope,
1156    ) -> Result<PackageManifest> {
1157        let parsed = ParsedSource::parse(source);
1158        self.emit_progress(ProgressEvent {
1159            event_type: ProgressEventType::Start,
1160            action: ProgressAction::Install,
1161            source: source.to_string(),
1162            message: Some(format!("Installing {}...", source)),
1163        });
1164        let result = match &parsed {
1165            ParsedSource::Npm { .. } => run_on_fresh_runtime(self.install_npm_async(source, scope)),
1166            ParsedSource::Git { repo, ref_, .. } => {
1167                self.install_git_sync(source, repo, ref_.as_deref(), scope)
1168            }
1169            ParsedSource::Local { path } => self.install_local(path),
1170            ParsedSource::Url { url } => run_on_fresh_runtime(self.install_url(url, scope)),
1171        };
1172        match &result {
1173            Ok(_) => self.emit_progress(ProgressEvent {
1174                event_type: ProgressEventType::Complete,
1175                action: ProgressAction::Install,
1176                source: source.to_string(),
1177                message: None,
1178            }),
1179            Err(e) => self.emit_progress(ProgressEvent {
1180                event_type: ProgressEventType::Error,
1181                action: ProgressAction::Install,
1182                source: source.to_string(),
1183                message: Some(e.to_string()),
1184            }),
1185        }
1186        result
1187    }
1188
1189    /// Async install from npm using registry
1190    async fn install_npm_async(
1191        &mut self,
1192        source: &str,
1193        scope: SourceScope,
1194    ) -> Result<PackageManifest> {
1195        let parsed = ParsedSource::parse(source);
1196        let (spec, name, pinned) = match &parsed {
1197            ParsedSource::Npm { spec, name, pinned } => (spec.clone(), name.clone(), *pinned),
1198            _ => bail!("Expected npm source"),
1199        };
1200
1201        // Resolve version
1202        let _version = if pinned {
1203            // Extract version from spec
1204            let (_, ver) = parse_npm_spec(&spec);
1205            if ver {
1206                spec.rsplit('@').next().unwrap_or("latest").to_string()
1207            } else {
1208                "latest".to_string()
1209            }
1210        } else {
1211            get_latest_npm_version(&name)
1212                .await
1213                .unwrap_or_else(|_| "latest".to_string())
1214        };
1215
1216        // Use npm pack approach
1217        self.install_npm_pack(&spec, scope)
1218    }
1219
1220    /// Install npm package using `npm pack`
1221    fn install_npm_pack(&mut self, spec: &str, scope: SourceScope) -> Result<PackageManifest> {
1222        let tmp_dir =
1223            tempfile::tempdir().context("Failed to create temp directory for npm install")?;
1224
1225        let output = std::process::Command::new("npm")
1226            .args(["pack", spec, "--pack-destination"])
1227            .arg(tmp_dir.path())
1228            .current_dir(tmp_dir.path())
1229            .output()
1230            .context("Failed to run npm pack")?;
1231
1232        if !output.status.success() {
1233            let stderr = String::from_utf8_lossy(&output.stderr);
1234            bail!("npm pack failed for '{}': {}", spec, stderr);
1235        }
1236
1237        // Find the tarball
1238        let tarball = fs::read_dir(tmp_dir.path())?
1239            .filter_map(|e| e.ok())
1240            .find(|e| {
1241                e.path()
1242                    .extension()
1243                    .map(|ext| ext == "tgz")
1244                    .unwrap_or(false)
1245            })
1246            .map(|e| e.path())
1247            .context("No .tgz file found after npm pack")?;
1248
1249        // Extract tarball
1250        let extract_dir = tmp_dir.path().join("extracted");
1251        fs::create_dir_all(&extract_dir)?;
1252
1253        let tar_status = std::process::Command::new("tar")
1254            .args(["-xzf", &tarball.to_string_lossy(), "-C"])
1255            .arg(&extract_dir)
1256            .output()
1257            .context("Failed to run tar")?;
1258
1259        if !tar_status.status.success() {
1260            let stderr = String::from_utf8_lossy(&tar_status.stderr);
1261            bail!("tar extraction failed: {}", stderr);
1262        }
1263
1264        // npm pack extracts into a "package" subdirectory
1265        let pkg_source = extract_dir.join("package");
1266        let source_for_copy = if pkg_source.exists() {
1267            &pkg_source
1268        } else {
1269            // Might be just the extracted dir
1270            extract_dir.as_path()
1271        };
1272
1273        self.ensure_packages_dir()?;
1274
1275        // Determine package name from manifest or spec
1276        let manifest = if source_for_copy.join(MANIFEST_NAME).exists() {
1277            Self::read_manifest(&source_for_copy.join(MANIFEST_NAME))?
1278        } else if source_for_copy.join(NPM_MANIFEST_NAME).exists() {
1279            let pj = Self::read_package_json(source_for_copy);
1280            let (pkg_name, pkg_version) = pj
1281                .as_ref()
1282                .map(|v| {
1283                    (
1284                        v.get("name")
1285                            .and_then(|n| n.as_str())
1286                            .unwrap_or(spec)
1287                            .to_string(),
1288                        v.get("version")
1289                            .and_then(|v| v.as_str())
1290                            .unwrap_or("0.0.0")
1291                            .to_string(),
1292                    )
1293                })
1294                .unwrap_or((spec.to_string(), "0.0.0".to_string()));
1295
1296            PackageManifest {
1297                name: pkg_name,
1298                version: pkg_version,
1299                extensions: Vec::new(),
1300                skills: Vec::new(),
1301                prompts: Vec::new(),
1302                themes: Vec::new(),
1303                description: None,
1304                dependencies: BTreeMap::new(),
1305            }
1306        } else {
1307            PackageManifest {
1308                name: spec.to_string(),
1309                version: "0.0.0".to_string(),
1310                extensions: Vec::new(),
1311                skills: Vec::new(),
1312                prompts: Vec::new(),
1313                themes: Vec::new(),
1314                description: None,
1315                dependencies: BTreeMap::new(),
1316            }
1317        };
1318
1319        let dest = self.pkg_install_dir(&manifest.name);
1320        if dest.exists() {
1321            fs::remove_dir_all(&dest).with_context(|| {
1322                format!("Failed to remove existing package at {}", dest.display())
1323            })?;
1324        }
1325
1326        copy_dir_recursive(source_for_copy, &dest)
1327            .with_context(|| format!("Failed to copy npm package for '{}'", spec))?;
1328
1329        let integrity = compute_dir_hash(&dest);
1330
1331        self.lockfile.insert(LockEntry {
1332            source: format!("npm:{}", spec),
1333            name: manifest.name.clone(),
1334            version: manifest.version.clone(),
1335            integrity,
1336            scope,
1337            source_type: "npm".to_string(),
1338            dependencies: manifest.dependencies.clone(),
1339        });
1340
1341        self.installed
1342            .insert(manifest.name.clone(), manifest.clone());
1343        self.save_lockfile()
1344            .context("failed to persist package lockfile")?;
1345        Ok(manifest)
1346    }
1347
1348    /// Install from git
1349    fn install_git_sync(
1350        &mut self,
1351        source: &str,
1352        repo: &str,
1353        ref_: Option<&str>,
1354        scope: SourceScope,
1355    ) -> Result<PackageManifest> {
1356        let parsed = ParsedSource::parse(source);
1357        let (host, path) = match &parsed {
1358            ParsedSource::Git { host, path, .. } => (host.clone(), path.clone()),
1359            _ => bail!("Expected git source"),
1360        };
1361
1362        let target_dir = self.git_install_path(&host, &path, scope);
1363
1364        if target_dir.exists() {
1365            // Already installed
1366            return self.load_manifest_from_dir(&target_dir, source, scope);
1367        }
1368
1369        let Some(parent) = target_dir.parent() else {
1370            bail!(
1371                "Invalid install path: no parent directory for {}",
1372                target_dir.display()
1373            );
1374        };
1375        fs::create_dir_all(parent)
1376            .with_context(|| format!("Failed to create parent dir for {}", target_dir.display()))?;
1377
1378        git_clone(repo, &target_dir, ref_)?;
1379
1380        // Install npm dependencies if package.json exists
1381        if target_dir.join(NPM_MANIFEST_NAME).exists() {
1382            let _ = std::process::Command::new("npm")
1383                .args(["install", "--omit=dev"])
1384                .current_dir(&target_dir)
1385                .output();
1386        }
1387
1388        self.load_manifest_from_dir(&target_dir, source, scope)
1389    }
1390
1391    /// Load manifest from a directory and register it
1392    fn load_manifest_from_dir(
1393        &mut self,
1394        dir: &Path,
1395        source: &str,
1396        scope: SourceScope,
1397    ) -> Result<PackageManifest> {
1398        let manifest = if dir.join(MANIFEST_NAME).exists() {
1399            Self::read_manifest(&dir.join(MANIFEST_NAME))?
1400        } else {
1401            let name = dir
1402                .file_name()
1403                .map(|n| n.to_string_lossy().to_string())
1404                .unwrap_or_else(|| "unknown".to_string());
1405            PackageManifest {
1406                name,
1407                version: "0.0.0".to_string(),
1408                extensions: Vec::new(),
1409                skills: Vec::new(),
1410                prompts: Vec::new(),
1411                themes: Vec::new(),
1412                description: None,
1413                dependencies: BTreeMap::new(),
1414            }
1415        };
1416
1417        let integrity = compute_dir_hash(dir);
1418
1419        self.lockfile.insert(LockEntry {
1420            source: source.to_string(),
1421            name: manifest.name.clone(),
1422            version: manifest.version.clone(),
1423            integrity,
1424            scope,
1425            source_type: "git".to_string(),
1426            dependencies: manifest.dependencies.clone(),
1427        });
1428
1429        self.installed
1430            .insert(manifest.name.clone(), manifest.clone());
1431        self.save_lockfile()
1432            .context("failed to persist package lockfile")?;
1433        Ok(manifest)
1434    }
1435
1436    /// Install from a URL (archive)
1437    async fn install_url(&mut self, url: &str, scope: SourceScope) -> Result<PackageManifest> {
1438        let client = shared_http_client();
1439
1440        let resp = client.get(url).send().await?;
1441        if !resp.status().is_success() {
1442            bail!("Failed to download {}: {}", url, resp.status());
1443        }
1444
1445        let bytes = resp.bytes().await?;
1446
1447        let tmp_dir = tempfile::tempdir()?;
1448        let archive_name = url.split('/').next_back().unwrap_or("archive");
1449        let archive_path = tmp_dir.path().join(archive_name);
1450        fs::write(&archive_path, &bytes)?;
1451
1452        let extract_dir = tmp_dir.path().join("extracted");
1453        fs::create_dir_all(&extract_dir)?;
1454
1455        if archive_name.ends_with(".tar.gz") || archive_name.ends_with(".tgz") {
1456            let status = std::process::Command::new("tar")
1457                .args(["-xzf", &archive_path.to_string_lossy(), "-C"])
1458                .arg(&extract_dir)
1459                .output()?;
1460            if !status.status.success() {
1461                bail!("Failed to extract archive");
1462            }
1463        } else if archive_name.ends_with(".zip") {
1464            // Use unzip if available
1465            let status = std::process::Command::new("unzip")
1466                .arg("-o")
1467                .arg(&archive_path)
1468                .arg("-d")
1469                .arg(&extract_dir)
1470                .output()?;
1471            if !status.status.success() {
1472                bail!("Failed to extract zip archive");
1473            }
1474        } else {
1475            bail!("Unsupported archive format: {}", archive_name);
1476        }
1477
1478        // Find the extracted package directory
1479        let pkg_dir = find_single_subdir(&extract_dir).unwrap_or_else(|| extract_dir.to_path_buf());
1480
1481        self.ensure_packages_dir()?;
1482
1483        let manifest = if pkg_dir.join(MANIFEST_NAME).exists() {
1484            Self::read_manifest(&pkg_dir.join(MANIFEST_NAME))?
1485        } else {
1486            let name = url
1487                .split('/')
1488                .next_back()
1489                .unwrap_or("url-package")
1490                .trim_end_matches(".tar.gz")
1491                .trim_end_matches(".tgz")
1492                .trim_end_matches(".zip")
1493                .to_string();
1494            PackageManifest {
1495                name,
1496                version: "0.0.0".to_string(),
1497                extensions: Vec::new(),
1498                skills: Vec::new(),
1499                prompts: Vec::new(),
1500                themes: Vec::new(),
1501                description: None,
1502                dependencies: BTreeMap::new(),
1503            }
1504        };
1505
1506        let dest = self.pkg_install_dir(&manifest.name);
1507        if dest.exists() {
1508            fs::remove_dir_all(&dest)?;
1509        }
1510
1511        copy_dir_recursive(&pkg_dir, &dest)?;
1512
1513        let integrity = compute_dir_hash(&dest);
1514
1515        self.lockfile.insert(LockEntry {
1516            source: url.to_string(),
1517            name: manifest.name.clone(),
1518            version: manifest.version.clone(),
1519            integrity,
1520            scope,
1521            source_type: "url".to_string(),
1522            dependencies: manifest.dependencies.clone(),
1523        });
1524
1525        self.installed
1526            .insert(manifest.name.clone(), manifest.clone());
1527        self.save_lockfile()
1528            .context("failed to persist package lockfile")?;
1529        Ok(manifest)
1530    }
1531
1532    /// Install from npm using `npm pack` (legacy sync method)
1533    pub fn install_npm(&mut self, name: &str) -> Result<PackageManifest> {
1534        self.install_npm_pack(name, SourceScope::User)
1535    }
1536
1537    // ── Uninstall ─────────────────────────────────────────────────────
1538
1539    /// Uninstall a package by name
1540    pub fn uninstall(&mut self, name: &str) -> Result<()> {
1541        if !self.installed.contains_key(name) {
1542            bail!("Package '{}' is not installed", name);
1543        }
1544
1545        let dest = self.pkg_install_dir(name);
1546        if dest.exists() {
1547            fs::remove_dir_all(&dest).with_context(|| {
1548                format!("Failed to remove package directory {}", dest.display())
1549            })?;
1550        }
1551
1552        // Also try to clean up git/npm scoped dirs
1553        // (best effort)
1554        let _ = self.lockfile.remove(name);
1555        self.save_lockfile()
1556            .context("failed to persist package lockfile")?;
1557
1558        self.installed.remove(name);
1559        Ok(())
1560    }
1561
1562    /// Uninstall a package from a specific source
1563    pub fn uninstall_from_source(&mut self, source: &str, scope: SourceScope) -> Result<()> {
1564        let parsed = ParsedSource::parse(source);
1565        self.emit_progress(ProgressEvent {
1566            event_type: ProgressEventType::Start,
1567            action: ProgressAction::Remove,
1568            source: source.to_string(),
1569            message: Some(format!("Removing {}...", source)),
1570        });
1571        let result = self.do_uninstall_from_source(&parsed, scope);
1572        match &result {
1573            Ok(_) => self.emit_progress(ProgressEvent {
1574                event_type: ProgressEventType::Complete,
1575                action: ProgressAction::Remove,
1576                source: source.to_string(),
1577                message: None,
1578            }),
1579            Err(e) => self.emit_progress(ProgressEvent {
1580                event_type: ProgressEventType::Error,
1581                action: ProgressAction::Remove,
1582                source: source.to_string(),
1583                message: Some(e.to_string()),
1584            }),
1585        }
1586        result
1587    }
1588
1589    fn do_uninstall_from_source(
1590        &mut self,
1591        parsed: &ParsedSource,
1592        scope: SourceScope,
1593    ) -> Result<()> {
1594        match parsed {
1595            ParsedSource::Npm { name, .. } => {
1596                let dest = self.npm_install_path(name, scope);
1597                if dest.exists() {
1598                    fs::remove_dir_all(&dest)?;
1599                }
1600                self.installed.remove(name);
1601                self.lockfile.remove(name);
1602                self.save_lockfile()
1603                    .context("failed to persist package lockfile")?;
1604                Ok(())
1605            }
1606            ParsedSource::Git { host, path, .. } => {
1607                let dest = self.git_install_path(host, path, scope);
1608                if dest.exists() {
1609                    fs::remove_dir_all(&dest)?;
1610                    prune_empty_parents(&dest, &self.packages_dir);
1611                }
1612                self.installed.retain(|_, m| {
1613                    let parsed_m = ParsedSource::parse(m.name.as_str());
1614                    parsed_m.identity() != parsed.identity()
1615                });
1616                self.lockfile.packages.retain(|_, entry| {
1617                    let parsed_e = ParsedSource::parse(&entry.source);
1618                    parsed_e.identity() != parsed.identity()
1619                });
1620                self.save_lockfile()
1621                    .context("failed to persist package lockfile")?;
1622                Ok(())
1623            }
1624            ParsedSource::Local { .. } => Ok(()),
1625            ParsedSource::Url { .. } => {
1626                let identity = parsed.identity();
1627                self.lockfile
1628                    .packages
1629                    .retain(|_, e| ParsedSource::parse(&e.source).identity() != identity);
1630                self.save_lockfile()
1631                    .context("failed to persist package lockfile")?;
1632                Ok(())
1633            }
1634        }
1635    }
1636
1637    // ── Update ────────────────────────────────────────────────────────
1638
1639    /// Update a package (re-install from the same source).
1640    /// For npm packages, re-runs `npm pack` to get the latest version.
1641    /// For local packages, re-copies from the source path (if available).
1642    /// For git packages, does a git pull.
1643    pub fn update(&mut self, name: &str) -> Result<PackageManifest> {
1644        let lock_entry = self.lockfile.get(name).cloned();
1645
1646        if let Some(entry) = lock_entry {
1647            let parsed = ParsedSource::parse(&entry.source);
1648            return match &parsed {
1649                ParsedSource::Npm { spec, .. } => {
1650                    self.emit_progress(ProgressEvent {
1651                        event_type: ProgressEventType::Start,
1652                        action: ProgressAction::Update,
1653                        source: entry.source.clone(),
1654                        message: Some(format!("Updating {}...", name)),
1655                    });
1656                    let result = self.install_npm_pack(spec, entry.scope);
1657                    match &result {
1658                        Ok(_) => self.emit_progress(ProgressEvent {
1659                            event_type: ProgressEventType::Complete,
1660                            action: ProgressAction::Update,
1661                            source: entry.source.clone(),
1662                            message: None,
1663                        }),
1664                        Err(e) => self.emit_progress(ProgressEvent {
1665                            event_type: ProgressEventType::Error,
1666                            action: ProgressAction::Update,
1667                            source: entry.source.clone(),
1668                            message: Some(e.to_string()),
1669                        }),
1670                    }
1671                    result
1672                }
1673                ParsedSource::Git { repo, ref_, .. } => {
1674                    let target_dir = match &parsed {
1675                        ParsedSource::Git { host, path, .. } => {
1676                            self.git_install_path(host, path, entry.scope)
1677                        }
1678                        _ => unreachable!(),
1679                    };
1680                    if target_dir.exists() {
1681                        let updated = git_update(&target_dir, ref_.as_deref())?;
1682                        if updated && target_dir.join(NPM_MANIFEST_NAME).exists() {
1683                            let _ = std::process::Command::new("npm")
1684                                .args(["install", "--omit=dev"])
1685                                .current_dir(&target_dir)
1686                                .output();
1687                        }
1688                        self.load_manifest_from_dir(&target_dir, &entry.source, entry.scope)
1689                    } else {
1690                        self.install_git_sync(&entry.source, repo, ref_.as_deref(), entry.scope)
1691                    }
1692                }
1693                ParsedSource::Local { path } => self.install_local(path),
1694                ParsedSource::Url { url } => {
1695                    run_on_fresh_runtime(self.install_url(url, entry.scope))
1696                }
1697            };
1698        }
1699
1700        // Fallback: try npm re-install
1701        if self.installed.contains_key(name) {
1702            self.install_npm_pack(name, SourceScope::User)
1703        } else {
1704            bail!("Package '{}' is not installed", name);
1705        }
1706    }
1707
1708    /// Update all installed packages
1709    pub fn update_all(&mut self) -> Vec<(String, Result<PackageManifest>)> {
1710        let names: Vec<String> = self.installed.keys().cloned().collect();
1711        let mut results = Vec::new();
1712        for name in names {
1713            let result = self.update(&name);
1714            results.push((name, result));
1715        }
1716        results
1717    }
1718
1719    /// Check for available updates across all packages
1720    pub async fn check_for_updates(&self) -> Vec<PackageUpdateInfo> {
1721        let mut updates = Vec::new();
1722
1723        for lock_entry in self.lockfile.packages.values() {
1724            let parsed = ParsedSource::parse(&lock_entry.source);
1725
1726            match &parsed {
1727                ParsedSource::Npm { name: pkg_name, .. } => {
1728                    // Check npm for newer version
1729                    match NpmPackageInfo::fetch(pkg_name).await {
1730                        Ok(info) => {
1731                            if let Some(latest) = info.latest_version()
1732                                && latest != lock_entry.version
1733                            {
1734                                updates.push(PackageUpdateInfo {
1735                                    source: lock_entry.source.clone(),
1736                                    display_name: pkg_name.clone(),
1737                                    source_type: "npm".to_string(),
1738                                    scope: lock_entry.scope,
1739                                });
1740                            }
1741                        }
1742                        Err(_) => continue,
1743                    }
1744                }
1745                ParsedSource::Git { host, path, .. } => {
1746                    let install_path = self.git_install_path(host, path, lock_entry.scope);
1747                    if install_path.exists() {
1748                        match git_has_update(&install_path) {
1749                            Ok(true) => {
1750                                updates.push(PackageUpdateInfo {
1751                                    source: lock_entry.source.clone(),
1752                                    display_name: format!("{}/{}", host, path),
1753                                    source_type: "git".to_string(),
1754                                    scope: lock_entry.scope,
1755                                });
1756                            }
1757                            _ => continue,
1758                        }
1759                    }
1760                }
1761                _ => continue,
1762            }
1763        }
1764
1765        updates
1766    }
1767
1768    // ── List / query ──────────────────────────────────────────────────
1769
1770    /// List all installed packages
1771    pub fn list(&self) -> Vec<&PackageManifest> {
1772        self.installed.values().collect()
1773    }
1774
1775    /// List configured packages with metadata
1776    pub fn list_configured(&self) -> Vec<ConfiguredPackage> {
1777        let mut result = Vec::new();
1778        for name in self.installed.keys() {
1779            let installed_path = self.get_install_dir(name);
1780            let lock_entry = self.lockfile.get(name);
1781            result.push(ConfiguredPackage {
1782                source: lock_entry
1783                    .map(|e| e.source.clone())
1784                    .unwrap_or_else(|| name.clone()),
1785                scope: lock_entry.map(|e| e.scope).unwrap_or(SourceScope::User),
1786                filtered: false,
1787                installed_path,
1788            });
1789        }
1790        result
1791    }
1792
1793    /// Check whether a package is installed
1794    pub fn is_installed(&self, name: &str) -> bool {
1795        self.installed.contains_key(name)
1796    }
1797
1798    /// Get the install directory for a package (if it exists on disk)
1799    pub fn get_install_dir(&self, name: &str) -> Option<PathBuf> {
1800        let dir = self.pkg_install_dir(name);
1801        if dir.exists() { Some(dir) } else { None }
1802    }
1803
1804    /// Get the installed path for a source at a given scope
1805    pub fn get_installed_path_for_source(
1806        &self,
1807        source: &str,
1808        scope: SourceScope,
1809    ) -> Option<PathBuf> {
1810        let parsed = ParsedSource::parse(source);
1811        match &parsed {
1812            ParsedSource::Npm { name, .. } => {
1813                let path = self.npm_install_path(name, scope);
1814                if path.exists() { Some(path) } else { None }
1815            }
1816            ParsedSource::Git { host, path, .. } => {
1817                let path = self.git_install_path(host, path, scope);
1818                if path.exists() { Some(path) } else { None }
1819            }
1820            ParsedSource::Local { path } => {
1821                let p = PathBuf::from(path);
1822                if p.exists() { Some(p) } else { None }
1823            }
1824            ParsedSource::Url { .. } => None,
1825        }
1826    }
1827
1828    // ── Resource discovery ────────────────────────────────────────────
1829
1830    /// Discover all resources from an installed package.
1831    pub fn discover_resources(&self, name: &str) -> Result<Vec<DiscoveredResource>> {
1832        let manifest = self
1833            .installed
1834            .get(name)
1835            .with_context(|| format!("Package '{}' not found", name))?;
1836
1837        let install_dir = self.pkg_install_dir(name);
1838        if !install_dir.exists() {
1839            bail!("Install directory for '{}' does not exist", name);
1840        }
1841
1842        let mut resources = Vec::new();
1843
1844        let has_explicit = !manifest.extensions.is_empty()
1845            || !manifest.skills.is_empty()
1846            || !manifest.prompts.is_empty()
1847            || !manifest.themes.is_empty();
1848
1849        if has_explicit {
1850            for ext in &manifest.extensions {
1851                let path = install_dir.join(ext);
1852                if path.exists() {
1853                    resources.push(DiscoveredResource {
1854                        kind: ResourceKind::Extension,
1855                        path,
1856                        relative_path: ext.clone(),
1857                    });
1858                }
1859            }
1860            for skill in &manifest.skills {
1861                let path = install_dir.join(skill);
1862                if path.exists() {
1863                    resources.push(DiscoveredResource {
1864                        kind: ResourceKind::Skill,
1865                        path,
1866                        relative_path: skill.clone(),
1867                    });
1868                }
1869            }
1870            for prompt in &manifest.prompts {
1871                let path = install_dir.join(prompt);
1872                if path.exists() {
1873                    resources.push(DiscoveredResource {
1874                        kind: ResourceKind::Prompt,
1875                        path,
1876                        relative_path: prompt.clone(),
1877                    });
1878                }
1879            }
1880            for theme in &manifest.themes {
1881                let path = install_dir.join(theme);
1882                if path.exists() {
1883                    resources.push(DiscoveredResource {
1884                        kind: ResourceKind::Theme,
1885                        path,
1886                        relative_path: theme.clone(),
1887                    });
1888                }
1889            }
1890        } else {
1891            resources.extend(discover_extensions(&install_dir));
1892            resources.extend(discover_skills(&install_dir));
1893            resources.extend(discover_prompts(&install_dir));
1894            resources.extend(discover_themes(&install_dir));
1895        }
1896
1897        Ok(resources)
1898    }
1899
1900    /// Get resource counts for a package
1901    pub fn resource_counts(&self, name: &str) -> Result<ResourceCounts> {
1902        let resources = self.discover_resources(name)?;
1903        let mut counts = ResourceCounts::default();
1904        for r in &resources {
1905            match r.kind {
1906                ResourceKind::Extension => counts.extensions += 1,
1907                ResourceKind::Skill => counts.skills += 1,
1908                ResourceKind::Prompt => counts.prompts += 1,
1909                ResourceKind::Theme => counts.themes += 1,
1910            }
1911        }
1912        Ok(counts)
1913    }
1914
1915    /// Resolve all resources from all installed packages, producing ResolvedPaths
1916    pub fn resolve(&self) -> ResolvedPaths {
1917        let mut extensions = Vec::new();
1918        let mut skills = Vec::new();
1919        let mut prompts = Vec::new();
1920        let mut themes = Vec::new();
1921
1922        for name in self.installed.keys() {
1923            let install_dir = self.pkg_install_dir(name);
1924            if !install_dir.exists() {
1925                continue;
1926            }
1927
1928            let metadata = PathMetadata {
1929                source: name.clone(),
1930                scope: SourceScope::User,
1931                origin: ResourceOrigin::Package,
1932                base_dir: Some(install_dir.clone()),
1933            };
1934
1935            // Use discover_resources logic
1936            if let Ok(resources) = self.discover_resources(name) {
1937                for r in resources {
1938                    match r.kind {
1939                        ResourceKind::Extension => extensions.push(ResolvedResource {
1940                            path: r.path,
1941                            enabled: true,
1942                            metadata: metadata.clone(),
1943                        }),
1944                        ResourceKind::Skill => skills.push(ResolvedResource {
1945                            path: r.path,
1946                            enabled: true,
1947                            metadata: metadata.clone(),
1948                        }),
1949                        ResourceKind::Prompt => prompts.push(ResolvedResource {
1950                            path: r.path,
1951                            enabled: true,
1952                            metadata: metadata.clone(),
1953                        }),
1954                        ResourceKind::Theme => themes.push(ResolvedResource {
1955                            path: r.path,
1956                            enabled: true,
1957                            metadata: metadata.clone(),
1958                        }),
1959                    }
1960                }
1961            }
1962        }
1963
1964        ResolvedPaths {
1965            extensions,
1966            skills,
1967            prompts,
1968            themes,
1969        }
1970    }
1971
1972    // ── Dependency resolution ─────────────────────────────────────────
1973
1974    /// Resolve dependencies for all installed packages.
1975    /// Returns a list of (package, missing_dependencies) tuples.
1976    pub fn resolve_dependencies(&self) -> Vec<(String, Vec<String>)> {
1977        let mut result = Vec::new();
1978        let installed_names: HashSet<&str> = self.installed.keys().map(|s| s.as_str()).collect();
1979
1980        for (name, manifest) in &self.installed {
1981            let missing: Vec<String> = manifest
1982                .dependencies
1983                .keys()
1984                .filter(|dep| !installed_names.contains(dep.as_str()))
1985                .cloned()
1986                .collect();
1987
1988            if !missing.is_empty() {
1989                result.push((name.clone(), missing));
1990            }
1991        }
1992
1993        result
1994    }
1995
1996    /// Validate a package structure
1997    pub fn validate_package(dir: &Path) -> Result<Vec<String>> {
1998        let mut warnings = Vec::new();
1999
2000        // Check for manifest
2001        if !dir.join(MANIFEST_NAME).exists() && !dir.join(NPM_MANIFEST_NAME).exists() {
2002            warnings.push(format!(
2003                "No {} or {} found",
2004                MANIFEST_NAME, NPM_MANIFEST_NAME
2005            ));
2006        }
2007
2008        // Try to parse manifest
2009        if dir.join(MANIFEST_NAME).exists() {
2010            match Self::read_manifest(&dir.join(MANIFEST_NAME)) {
2011                Ok(m) => {
2012                    if m.name.is_empty() {
2013                        warnings.push("Package name is empty".to_string());
2014                    }
2015                    if m.version.is_empty() {
2016                        warnings.push("Package version is empty".to_string());
2017                    }
2018                    if semver::Version::parse(&m.version).is_err() {
2019                        warnings.push(format!("Version '{}' is not valid semver", m.version));
2020                    }
2021                    let has_resources = !m.extensions.is_empty()
2022                        || !m.skills.is_empty()
2023                        || !m.prompts.is_empty()
2024                        || !m.themes.is_empty();
2025                    if !has_resources {
2026                        // Check if auto-discovery would find anything
2027                        let discovered = discover_extensions(dir)
2028                            .into_iter()
2029                            .chain(discover_skills(dir))
2030                            .chain(discover_prompts(dir))
2031                            .chain(discover_themes(dir))
2032                            .count();
2033                        if discovered == 0 {
2034                            warnings.push(
2035                                "Package has no explicit resources and auto-discovery found nothing"
2036                                    .to_string(),
2037                            );
2038                        }
2039                    }
2040
2041                    // Check that explicit paths exist
2042                    for ext in &m.extensions {
2043                        if !dir.join(ext).exists() {
2044                            warnings.push(format!("Extension path '{}' does not exist", ext));
2045                        }
2046                    }
2047                    for skill in &m.skills {
2048                        if !dir.join(skill).exists() {
2049                            warnings.push(format!("Skill path '{}' does not exist", skill));
2050                        }
2051                    }
2052                    for prompt in &m.prompts {
2053                        if !dir.join(prompt).exists() {
2054                            warnings.push(format!("Prompt path '{}' does not exist", prompt));
2055                        }
2056                    }
2057                    for theme in &m.themes {
2058                        if !dir.join(theme).exists() {
2059                            warnings.push(format!("Theme path '{}' does not exist", theme));
2060                        }
2061                    }
2062                }
2063                Err(e) => {
2064                    warnings.push(format!("Failed to parse {}: {}", MANIFEST_NAME, e));
2065                }
2066            }
2067        }
2068
2069        // Check for .gitignore or .ignore
2070        if !dir.join(".gitignore").exists() && !dir.join(".ignore").exists() {
2071            warnings.push("No .gitignore or .ignore file found".to_string());
2072        }
2073
2074        Ok(warnings)
2075    }
2076
2077    // ── Version queries ───────────────────────────────────────────────
2078
2079    /// Get installed version of a package
2080    pub fn get_installed_version(&self, name: &str) -> Option<&str> {
2081        self.installed.get(name).map(|m| m.version.as_str())
2082    }
2083
2084    /// Check if an installed version satisfies a semver requirement
2085    pub fn version_satisfies(&self, name: &str, requirement: &str) -> bool {
2086        if let Some(version) = self.get_installed_version(name)
2087            && let Ok(v) = semver::Version::parse(version)
2088            && let Ok(req) = semver::VersionReq::parse(requirement)
2089        {
2090            return req.matches(&v);
2091        }
2092        false
2093    }
2094
2095    /// Get the lockfile
2096    pub fn lockfile(&self) -> &Lockfile {
2097        &self.lockfile
2098    }
2099}
2100
2101// ── Auto-discovery helpers ────────────────────────────────────────────
2102
2103/// Discover extension files in a directory.
2104fn discover_extensions(dir: &Path) -> Vec<DiscoveredResource> {
2105    let mut results = Vec::new();
2106    discover_extensions_recursive(dir, dir, &mut results);
2107    results
2108}
2109
2110fn discover_extensions_recursive(
2111    base: &Path,
2112    current: &Path,
2113    results: &mut Vec<DiscoveredResource>,
2114) {
2115    if !current.exists() {
2116        return;
2117    }
2118
2119    let entries = match fs::read_dir(current) {
2120        Ok(e) => e,
2121        Err(_) => return,
2122    };
2123
2124    for entry in entries.flatten() {
2125        let path = entry.path();
2126        let name = entry.file_name();
2127        let name_str = name.to_string_lossy();
2128
2129        if name_str.starts_with('.') || name_str == "node_modules" {
2130            continue;
2131        }
2132
2133        if path.is_dir() {
2134            // Check for index.ts / index.js in subdirectory
2135            for index in &["index.ts", "index.js"] {
2136                let index_path = path.join(index);
2137                if index_path.exists() {
2138                    let rel = path.strip_prefix(base).unwrap_or(&path);
2139                    results.push(DiscoveredResource {
2140                        kind: ResourceKind::Extension,
2141                        path: index_path,
2142                        relative_path: rel.join(index).to_string_lossy().to_string(),
2143                    });
2144                }
2145            }
2146        } else {
2147            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
2148            if matches!(ext, "so" | "dylib" | "dll" | "ts" | "js") {
2149                let rel = path.strip_prefix(base).unwrap_or(&path);
2150                results.push(DiscoveredResource {
2151                    kind: ResourceKind::Extension,
2152                    path: path.clone(),
2153                    relative_path: rel.to_string_lossy().to_string(),
2154                });
2155            }
2156        }
2157    }
2158}
2159
2160/// Discover skill directories containing SKILL.md
2161fn discover_skills(dir: &Path) -> Vec<DiscoveredResource> {
2162    let mut results = Vec::new();
2163    discover_skills_recursive(dir, dir, &mut results);
2164    results
2165}
2166
2167fn discover_skills_recursive(base: &Path, current: &Path, results: &mut Vec<DiscoveredResource>) {
2168    if !current.exists() {
2169        return;
2170    }
2171
2172    let entries = match fs::read_dir(current) {
2173        Ok(e) => e,
2174        Err(_) => return,
2175    };
2176
2177    for entry in entries.flatten() {
2178        let path = entry.path();
2179        let name = entry.file_name();
2180        let name_str = name.to_string_lossy();
2181
2182        if name_str.starts_with('.') || name_str == "node_modules" {
2183            continue;
2184        }
2185
2186        if path.is_dir() {
2187            let skill_file = path.join("SKILL.md");
2188            if skill_file.exists() {
2189                let rel = path.strip_prefix(base).unwrap_or(&path);
2190                results.push(DiscoveredResource {
2191                    kind: ResourceKind::Skill,
2192                    path: skill_file,
2193                    relative_path: rel.join("SKILL.md").to_string_lossy().to_string(),
2194                });
2195            }
2196            discover_skills_recursive(base, &path, results);
2197        }
2198    }
2199}
2200
2201/// Discover prompt template files (.md in prompts/ subdirectory)
2202fn discover_prompts(dir: &Path) -> Vec<DiscoveredResource> {
2203    let prompts_dir = dir.join("prompts");
2204    discover_files_by_ext(
2205        if prompts_dir.exists() {
2206            &prompts_dir
2207        } else {
2208            dir
2209        },
2210        "md",
2211        ResourceKind::Prompt,
2212    )
2213}
2214
2215/// Discover theme files (.json in themes/ subdirectory)
2216fn discover_themes(dir: &Path) -> Vec<DiscoveredResource> {
2217    let themes_dir = dir.join("themes");
2218    discover_files_by_ext(
2219        if themes_dir.exists() {
2220            &themes_dir
2221        } else {
2222            dir
2223        },
2224        "json",
2225        ResourceKind::Theme,
2226    )
2227}
2228
2229/// Recursively find files with a given extension
2230fn discover_files_by_ext(dir: &Path, ext: &str, kind: ResourceKind) -> Vec<DiscoveredResource> {
2231    let mut results = Vec::new();
2232    discover_files_recursive(dir, dir, ext, kind, &mut results);
2233    results
2234}
2235
2236fn discover_files_recursive(
2237    base: &Path,
2238    current: &Path,
2239    ext: &str,
2240    kind: ResourceKind,
2241    results: &mut Vec<DiscoveredResource>,
2242) {
2243    if !current.exists() {
2244        return;
2245    }
2246
2247    let entries = match fs::read_dir(current) {
2248        Ok(e) => e,
2249        Err(_) => return,
2250    };
2251
2252    for entry in entries.flatten() {
2253        let path = entry.path();
2254        let name = entry.file_name();
2255        let name_str = name.to_string_lossy();
2256
2257        if name_str.starts_with('.') || name_str == "node_modules" {
2258            continue;
2259        }
2260
2261        if path.is_dir() {
2262            discover_files_recursive(base, &path, ext, kind, results);
2263        } else if path.extension().and_then(|e| e.to_str()) == Some(ext) {
2264            let rel = path.strip_prefix(base).unwrap_or(&path);
2265            results.push(DiscoveredResource {
2266                kind,
2267                path: path.clone(),
2268                relative_path: rel.to_string_lossy().to_string(),
2269            });
2270        }
2271    }
2272}
2273
2274// ── Utility functions ─────────────────────────────────────────────────
2275
2276/// Recursively copy a directory
2277fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
2278    if !dst.exists() {
2279        fs::create_dir_all(dst)?;
2280    }
2281
2282    for entry in fs::read_dir(src)? {
2283        let entry = entry?;
2284        let src_path = entry.path();
2285        let dst_path = dst.join(entry.file_name());
2286
2287        if src_path.is_dir() {
2288            copy_dir_recursive(&src_path, &dst_path)?;
2289        } else {
2290            fs::copy(&src_path, &dst_path)?;
2291        }
2292    }
2293
2294    Ok(())
2295}
2296
2297/// Compute a SHA-256 hash of a directory's contents for integrity checking
2298fn compute_dir_hash(dir: &Path) -> Option<String> {
2299    let mut hasher = Sha256::new();
2300    let mut files = collect_file_paths(dir);
2301    files.sort();
2302
2303    for file_path in &files {
2304        if let Ok(content) = fs::read(file_path) {
2305            hasher.update(&content);
2306        }
2307    }
2308
2309    let result = hasher.finalize();
2310    Some(format!("sha256-{:x}", result))
2311}
2312
2313/// Verify that an installed package directory matches its lockfile integrity hash.
2314///
2315/// `expected` must be in the `"sha256-<hex>"` format produced by
2316/// [`compute_dir_hash`]. Returns `Ok(())` on match, `Err(reason)` on
2317/// mismatch, missing directory, or hash format error. Missing files are
2318/// skipped silently (same as `compute_dir_hash`) so a partially-installed
2319/// package still hashes consistently with its lockfile.
2320///
2321/// This is the *consumer-side* companion to `compute_dir_hash`: the writer
2322/// (install) computes and stores; the reader (load) recomputes and compares.
2323/// Before this function existed (audit finding F-1), `integrity` was a
2324/// write-only field and a local attacker could swap files under
2325/// `~/.oxi/packages/<name>/` without detection.
2326fn verify_lockfile_integrity(install_dir: &Path, expected: &str) -> Result<(), String> {
2327    let expected_hex = expected.strip_prefix("sha256-").ok_or_else(|| {
2328        format!("lockfile integrity value not in `sha256-<hex>` form: {expected}")
2329    })?;
2330
2331    let actual = compute_dir_hash(install_dir)
2332        .ok_or_else(|| format!("could not hash install dir {}", install_dir.display()))?;
2333    let actual_hex = actual
2334        .strip_prefix("sha256-")
2335        .ok_or_else(|| format!("recomputed hash not in expected form: {actual}"))?;
2336
2337    if actual_hex.eq_ignore_ascii_case(expected_hex) {
2338        Ok(())
2339    } else {
2340        Err(format!(
2341            "sha256 mismatch: expected sha256-{expected_hex}, got {actual_hex}"
2342        ))
2343    }
2344}
2345
2346/// Collect all file paths in a directory recursively
2347fn collect_file_paths(dir: &Path) -> Vec<PathBuf> {
2348    let mut paths = Vec::new();
2349    if !dir.exists() {
2350        return paths;
2351    }
2352
2353    let entries = match fs::read_dir(dir) {
2354        Ok(e) => e,
2355        Err(_) => return paths,
2356    };
2357
2358    for entry in entries.flatten() {
2359        let path = entry.path();
2360        if path.is_dir() {
2361            paths.extend(collect_file_paths(&path));
2362        } else {
2363            paths.push(path);
2364        }
2365    }
2366
2367    paths
2368}
2369
2370/// Find the single subdirectory inside an extracted archive
2371fn find_single_subdir(dir: &Path) -> Option<PathBuf> {
2372    let entries: Vec<_> = fs::read_dir(dir).ok()?.filter_map(|e| e.ok()).collect();
2373    if entries.len() == 1 && entries[0].path().is_dir() {
2374        Some(entries[0].path())
2375    } else {
2376        None
2377    }
2378}
2379
2380/// Remove empty parent directories up to a root
2381fn prune_empty_parents(target: &Path, root: &Path) {
2382    let mut current = target.parent();
2383    while let Some(dir) = current {
2384        if dir == root || !dir.starts_with(root) {
2385            break;
2386        }
2387        if dir.exists() {
2388            let is_empty = fs::read_dir(dir)
2389                .map(|mut rd| rd.next().is_none())
2390                .unwrap_or(false);
2391            if is_empty {
2392                let _ = fs::remove_dir(dir);
2393            } else {
2394                break;
2395            }
2396        }
2397        current = dir.parent();
2398    }
2399}
2400
2401#[cfg(test)]
2402mod tests {
2403    use super::*;
2404
2405    fn setup_temp_packages_dir() -> (tempfile::TempDir, PathBuf) {
2406        let tmp = tempfile::tempdir().unwrap();
2407        let packages_dir = tmp.path().join("packages");
2408        fs::create_dir_all(&packages_dir).unwrap();
2409        (tmp, packages_dir)
2410    }
2411
2412    fn create_test_package(base: &Path, name: &str, version: &str) -> PathBuf {
2413        let pkg_dir = base.join("source-pkg");
2414        fs::create_dir_all(&pkg_dir).unwrap();
2415
2416        let manifest = PackageManifest {
2417            name: name.to_string(),
2418            version: version.to_string(),
2419            extensions: vec!["ext1.so".to_string()],
2420            skills: vec!["skill-a".to_string()],
2421            prompts: vec![],
2422            themes: vec![],
2423            description: None,
2424            dependencies: BTreeMap::new(),
2425        };
2426
2427        let toml_content = toml::to_string_pretty(&manifest).unwrap();
2428        fs::write(pkg_dir.join(MANIFEST_NAME), toml_content).unwrap();
2429        fs::write(pkg_dir.join("ext1.so"), "fake extension").unwrap();
2430        fs::create_dir_all(pkg_dir.join("skill-a")).unwrap();
2431        fs::write(pkg_dir.join("skill-a").join("SKILL.md"), "# Skill A").unwrap();
2432
2433        pkg_dir
2434    }
2435
2436    fn create_test_package_with_auto_discovery(base: &Path, name: &str, version: &str) -> PathBuf {
2437        let pkg_dir = base.join("source-pkg-auto");
2438        fs::create_dir_all(&pkg_dir).unwrap();
2439
2440        let manifest = PackageManifest {
2441            name: name.to_string(),
2442            version: version.to_string(),
2443            extensions: vec![],
2444            skills: vec![],
2445            prompts: vec![],
2446            themes: vec![],
2447            description: None,
2448            dependencies: BTreeMap::new(),
2449        };
2450        let toml_content = toml::to_string_pretty(&manifest).unwrap();
2451        fs::write(pkg_dir.join(MANIFEST_NAME), toml_content).unwrap();
2452
2453        fs::write(pkg_dir.join("myext.so"), "extension").unwrap();
2454        fs::create_dir_all(pkg_dir.join("my-skill")).unwrap();
2455        fs::write(pkg_dir.join("my-skill").join("SKILL.md"), "# My Skill").unwrap();
2456        fs::create_dir_all(pkg_dir.join("prompts")).unwrap();
2457        fs::write(pkg_dir.join("prompts").join("review.md"), "# Review").unwrap();
2458        fs::create_dir_all(pkg_dir.join("themes")).unwrap();
2459        fs::write(pkg_dir.join("themes").join("dark.json"), "{}").unwrap();
2460
2461        pkg_dir
2462    }
2463
2464    #[test]
2465    fn test_install_and_list() {
2466        let (tmp, packages_dir) = setup_temp_packages_dir();
2467
2468        let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2469        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2470
2471        let manifest = mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2472        assert_eq!(manifest.name, "test-pkg");
2473        assert_eq!(manifest.version, "1.0.0");
2474
2475        let installed = mgr.list();
2476        assert_eq!(installed.len(), 1);
2477        assert_eq!(installed[0].name, "test-pkg");
2478    }
2479
2480    #[test]
2481    fn test_uninstall() {
2482        let (tmp, packages_dir) = setup_temp_packages_dir();
2483
2484        let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2485        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2486
2487        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2488        assert!(mgr.is_installed("test-pkg"));
2489
2490        mgr.uninstall("test-pkg").unwrap();
2491        assert!(!mgr.is_installed("test-pkg"));
2492        assert!(mgr.list().is_empty());
2493    }
2494
2495    #[test]
2496    fn test_uninstall_not_installed() {
2497        let (_tmp, packages_dir) = setup_temp_packages_dir();
2498        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2499
2500        let result = mgr.uninstall("nonexistent");
2501        assert!(result.is_err());
2502    }
2503
2504    #[test]
2505    fn test_install_scoped_package() {
2506        let (tmp, packages_dir) = setup_temp_packages_dir();
2507
2508        let pkg_dir = create_test_package(tmp.path(), "@foo/oxi-tools", "2.0.0");
2509        let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
2510
2511        let manifest = mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2512        assert_eq!(manifest.name, "@foo/oxi-tools");
2513
2514        let expected_dir = packages_dir.join("foo-oxi-tools");
2515        assert!(expected_dir.exists());
2516    }
2517
2518    #[test]
2519    fn test_reinstall_overwrites() {
2520        let (tmp, packages_dir) = setup_temp_packages_dir();
2521
2522        let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2523        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2524
2525        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2526
2527        let pkg_dir_v2 = tmp.path().join("source-pkg-v2");
2528        fs::create_dir_all(&pkg_dir_v2).unwrap();
2529        let manifest_v2 = PackageManifest {
2530            name: "test-pkg".to_string(),
2531            version: "2.0.0".to_string(),
2532            extensions: vec![],
2533            skills: vec![],
2534            prompts: vec![],
2535            themes: vec![],
2536            description: None,
2537            dependencies: BTreeMap::new(),
2538        };
2539        fs::write(
2540            pkg_dir_v2.join(MANIFEST_NAME),
2541            toml::to_string_pretty(&manifest_v2).unwrap(),
2542        )
2543        .unwrap();
2544
2545        mgr.install(pkg_dir_v2.to_str().unwrap()).unwrap();
2546
2547        let installed = mgr.list();
2548        assert_eq!(installed.len(), 1);
2549        assert_eq!(installed[0].version, "2.0.0");
2550    }
2551
2552    #[test]
2553    fn test_empty_packages_dir() {
2554        let (_tmp, packages_dir) = setup_temp_packages_dir();
2555        let mgr = PackageManager::with_dir(packages_dir).unwrap();
2556        assert!(mgr.list().is_empty());
2557    }
2558
2559    #[test]
2560    fn test_packages_dir_not_exists() {
2561        let tmp = tempfile::tempdir().unwrap();
2562        let nonexistent = tmp.path().join("does-not-exist");
2563        let mgr = PackageManager::with_dir(nonexistent).unwrap();
2564        assert!(mgr.list().is_empty());
2565    }
2566
2567    #[test]
2568    fn test_discover_resources_explicit() {
2569        let (tmp, packages_dir) = setup_temp_packages_dir();
2570
2571        let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2572        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2573        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2574
2575        let resources = mgr.discover_resources("test-pkg").unwrap();
2576        assert_eq!(resources.len(), 2);
2577
2578        let extensions: Vec<_> = resources
2579            .iter()
2580            .filter(|r| r.kind == ResourceKind::Extension)
2581            .collect();
2582        let skills: Vec<_> = resources
2583            .iter()
2584            .filter(|r| r.kind == ResourceKind::Skill)
2585            .collect();
2586        assert_eq!(extensions.len(), 1);
2587        assert_eq!(skills.len(), 1);
2588    }
2589
2590    #[test]
2591    fn test_discover_resources_auto() {
2592        let (tmp, packages_dir) = setup_temp_packages_dir();
2593
2594        let pkg_dir = create_test_package_with_auto_discovery(tmp.path(), "auto-pkg", "1.0.0");
2595        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2596        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2597
2598        let resources = mgr.discover_resources("auto-pkg").unwrap();
2599
2600        let ext_count = resources
2601            .iter()
2602            .filter(|r| r.kind == ResourceKind::Extension)
2603            .count();
2604        let skill_count = resources
2605            .iter()
2606            .filter(|r| r.kind == ResourceKind::Skill)
2607            .count();
2608        let prompt_count = resources
2609            .iter()
2610            .filter(|r| r.kind == ResourceKind::Prompt)
2611            .count();
2612        let theme_count = resources
2613            .iter()
2614            .filter(|r| r.kind == ResourceKind::Theme)
2615            .count();
2616
2617        assert!(
2618            ext_count >= 1,
2619            "Expected at least 1 extension, got {}",
2620            ext_count
2621        );
2622        assert!(
2623            skill_count >= 1,
2624            "Expected at least 1 skill, got {}",
2625            skill_count
2626        );
2627        assert!(
2628            prompt_count >= 1,
2629            "Expected at least 1 prompt, got {}",
2630            prompt_count
2631        );
2632        assert!(
2633            theme_count >= 1,
2634            "Expected at least 1 theme, got {}",
2635            theme_count
2636        );
2637    }
2638
2639    #[test]
2640    fn test_resource_counts() {
2641        let (tmp, packages_dir) = setup_temp_packages_dir();
2642
2643        let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2644        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2645        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2646
2647        let counts = mgr.resource_counts("test-pkg").unwrap();
2648        assert_eq!(counts.extensions, 1);
2649        assert_eq!(counts.skills, 1);
2650        assert_eq!(counts.prompts, 0);
2651        assert_eq!(counts.themes, 0);
2652    }
2653
2654    #[test]
2655    fn test_resource_counts_display() {
2656        let counts = ResourceCounts {
2657            extensions: 2,
2658            skills: 1,
2659            prompts: 0,
2660            themes: 3,
2661        };
2662        assert_eq!(counts.to_string(), "2 ext, 1 skill, 3 theme");
2663
2664        let empty = ResourceCounts::default();
2665        assert_eq!(empty.to_string(), "-");
2666    }
2667
2668    #[test]
2669    fn test_resource_kind_display() {
2670        assert_eq!(ResourceKind::Extension.to_string(), "extension");
2671        assert_eq!(ResourceKind::Skill.to_string(), "skill");
2672        assert_eq!(ResourceKind::Prompt.to_string(), "prompt");
2673        assert_eq!(ResourceKind::Theme.to_string(), "theme");
2674    }
2675
2676    #[test]
2677    fn test_get_install_dir() {
2678        let (tmp, packages_dir) = setup_temp_packages_dir();
2679
2680        let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2681        let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
2682        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2683
2684        let dir = mgr.get_install_dir("test-pkg").unwrap();
2685        assert!(dir.exists());
2686        assert!(dir.join(MANIFEST_NAME).exists());
2687
2688        assert!(mgr.get_install_dir("nonexistent").is_none());
2689    }
2690
2691    #[test]
2692    fn test_discover_resources_not_installed() {
2693        let (_tmp, packages_dir) = setup_temp_packages_dir();
2694        let mgr = PackageManager::with_dir(packages_dir).unwrap();
2695
2696        let result = mgr.discover_resources("nonexistent");
2697        assert!(result.is_err());
2698    }
2699
2700    #[test]
2701    fn test_update_not_installed() {
2702        let (_tmp, packages_dir) = setup_temp_packages_dir();
2703        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2704
2705        let result = mgr.update("nonexistent");
2706        assert!(result.is_err());
2707    }
2708
2709    // ── Source parsing tests ──────────────────────────────────────────
2710
2711    #[test]
2712    fn test_parse_npm_source() {
2713        let parsed = ParsedSource::parse("npm:express@4.18.0");
2714        match parsed {
2715            ParsedSource::Npm { spec, name, pinned } => {
2716                assert_eq!(spec, "express@4.18.0");
2717                assert_eq!(name, "express");
2718                assert!(pinned);
2719            }
2720            _ => panic!("Expected Npm source"),
2721        }
2722
2723        let parsed = ParsedSource::parse("npm:lodash");
2724        match parsed {
2725            ParsedSource::Npm { name, pinned, .. } => {
2726                assert_eq!(name, "lodash");
2727                assert!(!pinned);
2728            }
2729            _ => panic!("Expected Npm source"),
2730        }
2731    }
2732
2733    #[test]
2734    fn test_parse_git_source() {
2735        let parsed = ParsedSource::parse("https://github.com/org/repo.git");
2736        match parsed {
2737            ParsedSource::Git {
2738                host, path, ref_, ..
2739            } => {
2740                assert_eq!(host, "github.com");
2741                assert_eq!(path, "org/repo");
2742                assert!(ref_.is_none());
2743            }
2744            _ => panic!("Expected Git source"),
2745        }
2746
2747        let parsed = ParsedSource::parse("https://github.com/org/repo.git@v1.0.0");
2748        match parsed {
2749            ParsedSource::Git { path, ref_, .. } => {
2750                assert_eq!(path, "org/repo");
2751                assert_eq!(ref_.as_deref(), Some("v1.0.0"));
2752            }
2753            _ => panic!("Expected Git source"),
2754        }
2755    }
2756
2757    #[test]
2758    fn test_parse_github_shorthand() {
2759        let parsed = ParsedSource::parse("github:org/repo@main");
2760        match parsed {
2761            ParsedSource::Git {
2762                host, path, ref_, ..
2763            } => {
2764                assert_eq!(host, "github.com");
2765                assert_eq!(path, "org/repo");
2766                assert_eq!(ref_.as_deref(), Some("main"));
2767            }
2768            _ => panic!("Expected Git source"),
2769        }
2770    }
2771
2772    #[test]
2773    fn test_parse_local_source() {
2774        let parsed = ParsedSource::parse("/path/to/package");
2775        match parsed {
2776            ParsedSource::Local { path } => {
2777                assert_eq!(path, "/path/to/package");
2778            }
2779            _ => panic!("Expected Local source"),
2780        }
2781
2782        let parsed = ParsedSource::parse("./relative/path");
2783        match parsed {
2784            ParsedSource::Local { path } => {
2785                assert_eq!(path, "./relative/path");
2786            }
2787            _ => panic!("Expected Local source"),
2788        }
2789    }
2790
2791    #[test]
2792    fn test_parse_url_source() {
2793        let parsed = ParsedSource::parse("https://example.com/pkg.tar.gz");
2794        match parsed {
2795            ParsedSource::Url { url } => {
2796                assert_eq!(url, "https://example.com/pkg.tar.gz");
2797            }
2798            _ => panic!("Expected Url source"),
2799        }
2800    }
2801
2802    #[test]
2803    fn test_source_identity() {
2804        let npm = ParsedSource::parse("npm:express@4.18.0");
2805        assert_eq!(npm.identity(), "npm:express");
2806
2807        let git = ParsedSource::parse("https://github.com/org/repo.git");
2808        assert_eq!(git.identity(), "git:github.com/org/repo");
2809
2810        let local = ParsedSource::parse("/path/to/pkg");
2811        assert_eq!(local.identity(), "local:/path/to/pkg");
2812    }
2813
2814    #[test]
2815    fn test_parse_npm_spec() {
2816        let (name, pinned) = parse_npm_spec("express@4.18.0");
2817        assert_eq!(name, "express");
2818        assert!(pinned);
2819
2820        let (name, pinned) = parse_npm_spec("express");
2821        assert_eq!(name, "express");
2822        assert!(!pinned);
2823
2824        let (name, pinned) = parse_npm_spec("@scope/pkg@1.0.0");
2825        assert_eq!(name, "@scope/pkg");
2826        assert!(pinned);
2827    }
2828
2829    // ── Lockfile tests ────────────────────────────────────────────────
2830
2831    #[test]
2832    fn test_lockfile_roundtrip() {
2833        let (tmp, _) = setup_temp_packages_dir();
2834        let lock_path = tmp.path().join(LOCKFILE_NAME);
2835
2836        let mut lock = Lockfile::new();
2837        lock.insert(LockEntry {
2838            source: "npm:express@4.18.0".to_string(),
2839            name: "express".to_string(),
2840            version: "4.18.0".to_string(),
2841            integrity: Some("sha256-abc123".to_string()),
2842            scope: SourceScope::User,
2843            source_type: "npm".to_string(),
2844            dependencies: BTreeMap::new(),
2845        });
2846
2847        lock.write(&lock_path).unwrap();
2848
2849        let loaded = Lockfile::read(&lock_path).unwrap().unwrap();
2850        assert_eq!(loaded.packages.len(), 1);
2851        assert_eq!(loaded.packages["express"].version, "4.18.0");
2852        assert_eq!(
2853            loaded.packages["express"].integrity.as_deref(),
2854            Some("sha256-abc123")
2855        );
2856    }
2857
2858    #[test]
2859    fn test_lockfile_install_roundtrip() {
2860        let (tmp, packages_dir) = setup_temp_packages_dir();
2861        let pkg_dir = create_test_package(tmp.path(), "locked-pkg", "1.0.0");
2862
2863        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2864        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2865
2866        // Lockfile should have been written
2867        let lock_path = mgr.packages_dir().join(LOCKFILE_NAME);
2868        assert!(lock_path.exists());
2869
2870        let lock = Lockfile::read(&lock_path).unwrap().unwrap();
2871        assert!(lock.contains("locked-pkg"));
2872        let entry = lock.get("locked-pkg").unwrap();
2873        assert_eq!(entry.version, "1.0.0");
2874    }
2875
2876    // ── Validation tests ──────────────────────────────────────────────
2877
2878    #[test]
2879    fn test_validate_valid_package() {
2880        let (tmp, _) = setup_temp_packages_dir();
2881        let pkg_dir = create_test_package(tmp.path(), "valid-pkg", "1.0.0");
2882        let warnings = PackageManager::validate_package(&pkg_dir).unwrap();
2883        // Should have minimal warnings (maybe just about .gitignore)
2884        assert!(
2885            warnings.len() <= 1,
2886            "Expected <= 1 warning, got {:?}",
2887            warnings
2888        );
2889    }
2890
2891    #[test]
2892    fn test_validate_empty_dir() {
2893        let tmp = tempfile::tempdir().unwrap();
2894        let empty_dir = tmp.path().join("empty-pkg");
2895        fs::create_dir_all(&empty_dir).unwrap();
2896        let warnings = PackageManager::validate_package(&empty_dir).unwrap();
2897        assert!(!warnings.is_empty());
2898    }
2899
2900    // ── Dependency tests ──────────────────────────────────────────────
2901
2902    #[test]
2903    fn test_resolve_dependencies() {
2904        let (tmp, packages_dir) = setup_temp_packages_dir();
2905
2906        // Create a package with dependencies
2907        let pkg_dir = tmp.path().join("dep-pkg");
2908        fs::create_dir_all(&pkg_dir).unwrap();
2909        let mut deps = BTreeMap::new();
2910        deps.insert("lodash".to_string(), "^4.0.0".to_string());
2911        deps.insert("nonexistent-pkg".to_string(), "^1.0.0".to_string());
2912
2913        let manifest = PackageManifest {
2914            name: "dep-pkg".to_string(),
2915            version: "1.0.0".to_string(),
2916            extensions: vec![],
2917            skills: vec![],
2918            prompts: vec![],
2919            themes: vec![],
2920            description: None,
2921            dependencies: deps,
2922        };
2923        fs::write(
2924            pkg_dir.join(MANIFEST_NAME),
2925            toml::to_string_pretty(&manifest).unwrap(),
2926        )
2927        .unwrap();
2928
2929        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2930        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2931
2932        let missing = mgr.resolve_dependencies();
2933        assert_eq!(missing.len(), 1);
2934        assert_eq!(missing[0].0, "dep-pkg");
2935        assert!(
2936            missing[0].1.contains(&"lodash".to_string())
2937                || missing[0].1.contains(&"nonexistent-pkg".to_string())
2938        );
2939    }
2940
2941    // ── Version tests ─────────────────────────────────────────────────
2942
2943    #[test]
2944    fn test_version_satisfies() {
2945        let (tmp, packages_dir) = setup_temp_packages_dir();
2946        let pkg_dir = create_test_package(tmp.path(), "ver-pkg", "1.2.3");
2947        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2948        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2949
2950        assert!(mgr.version_satisfies("ver-pkg", "^1.0.0"));
2951        assert!(mgr.version_satisfies("ver-pkg", ">=1.0.0"));
2952        assert!(!mgr.version_satisfies("ver-pkg", "^2.0.0"));
2953        assert!(!mgr.version_satisfies("ver-pkg", "<1.0.0"));
2954    }
2955
2956    #[test]
2957    fn test_get_installed_version() {
2958        let (tmp, packages_dir) = setup_temp_packages_dir();
2959        let pkg_dir = create_test_package(tmp.path(), "ver-pkg", "3.1.4");
2960        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2961        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2962
2963        assert_eq!(mgr.get_installed_version("ver-pkg"), Some("3.1.4"));
2964        assert_eq!(mgr.get_installed_version("nonexistent"), None);
2965    }
2966
2967    // ── Resolve tests ─────────────────────────────────────────────────
2968
2969    #[test]
2970    fn test_resolve() {
2971        let (tmp, packages_dir) = setup_temp_packages_dir();
2972        let pkg_dir = create_test_package(tmp.path(), "resolve-pkg", "1.0.0");
2973        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2974        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2975
2976        let resolved = mgr.resolve();
2977        assert!(!resolved.extensions.is_empty() || !resolved.skills.is_empty());
2978    }
2979
2980    // ── Progress callback tests ───────────────────────────────────────
2981
2982    #[test]
2983    fn test_progress_callback() {
2984        use std::sync::{Arc, Mutex};
2985
2986        let events: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
2987        let events_clone = events.clone();
2988
2989        let (tmp, packages_dir) = setup_temp_packages_dir();
2990        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2991
2992        mgr.set_progress_callback(Box::new(move |event| {
2993            let mut e = events_clone.lock().unwrap();
2994            e.push(format!("{:?}:{:?}", event.event_type, event.action));
2995        }));
2996
2997        let pkg_dir = create_test_package(tmp.path(), "progress-pkg", "1.0.0");
2998        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2999
3000        // install_local doesn't use with_progress, so no events expected from install()
3001        // Just verify the progress event mechanism exists and doesn't panic
3002        let _event_count = events.lock().unwrap().len();
3003    }
3004
3005    #[test]
3006    fn test_list_configured() {
3007        let (tmp, packages_dir) = setup_temp_packages_dir();
3008        let pkg_dir = create_test_package(tmp.path(), "cfg-pkg", "1.0.0");
3009        let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
3010        mgr.install(pkg_dir.to_str().unwrap()).unwrap();
3011
3012        let configured = mgr.list_configured();
3013        assert_eq!(configured.len(), 1);
3014        assert!(configured[0].source.contains("source-pkg"));
3015        // source comes from lockfile, might be the local path
3016    }
3017
3018    // ── F-1 regression: lockfile integrity verify on load ──────────
3019
3020    /// `verify_lockfile_integrity` returns Ok(()) when the directory
3021    /// contents hash to the lockfile-recorded `sha256-<hex>` value.
3022    #[test]
3023    fn verify_lockfile_integrity_accepts_matching_dir() {
3024        let tmp = tempfile::tempdir().unwrap();
3025        let pkg_dir = tmp.path().join("pkg");
3026        fs::create_dir_all(&pkg_dir).unwrap();
3027        fs::write(pkg_dir.join("a.txt"), b"hello").unwrap();
3028        fs::write(pkg_dir.join("b.txt"), b"world").unwrap();
3029
3030        let expected = compute_dir_hash(&pkg_dir).expect("compute_dir_hash must succeed");
3031
3032        assert!(verify_lockfile_integrity(&pkg_dir, &expected).is_ok());
3033    }
3034
3035    /// A directory that has been mutated after install must fail the
3036    /// integrity check. This is the F-1 supply-chain scenario the audit
3037    /// flagged: previously `LockEntry.integrity` was computed at install
3038    /// and never re-checked, so a tampered package would silently load.
3039    #[test]
3040    fn verify_lockfile_integrity_rejects_tampered_dir() {
3041        let tmp = tempfile::tempdir().unwrap();
3042        let pkg_dir = tmp.path().join("pkg");
3043        fs::create_dir_all(&pkg_dir).unwrap();
3044        fs::write(pkg_dir.join("a.txt"), b"hello").unwrap();
3045
3046        let expected = compute_dir_hash(&pkg_dir).expect("compute_dir_hash must succeed");
3047
3048        // Tamper: replace file contents.
3049        fs::write(pkg_dir.join("a.txt"), b"tampered").unwrap();
3050
3051        let err = verify_lockfile_integrity(&pkg_dir, &expected)
3052            .expect_err("tampered dir must not verify");
3053        assert!(err.contains("sha256 mismatch"), "unexpected error: {err}");
3054    }
3055
3056    /// Missing `sha256-` prefix is a clear lockfile-format error.
3057    #[test]
3058    fn verify_lockfile_integrity_rejects_bad_prefix() {
3059        let tmp = tempfile::tempdir().unwrap();
3060        let pkg_dir = tmp.path().join("pkg");
3061        fs::create_dir_all(&pkg_dir).unwrap();
3062
3063        let err = verify_lockfile_integrity(&pkg_dir, "abc123")
3064            .expect_err("missing sha256- prefix must be rejected");
3065        assert!(err.contains("not in `sha256-<hex>` form"));
3066    }
3067
3068    /// `load_installed` must drop a package whose lockfile-recorded
3069    /// integrity no longer matches the on-disk directory.
3070    #[test]
3071    fn load_installed_skips_tampered_package() {
3072        let (tmp, packages_dir) = setup_temp_packages_dir();
3073        let pkg_dir = create_test_package(tmp.path(), "tamper-pkg", "1.0.0");
3074
3075        // Install normally — this writes integrity to the lockfile.
3076        {
3077            let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
3078            mgr.install(pkg_dir.to_str().unwrap()).unwrap();
3079        }
3080
3081        // Tamper with the installed package after install.
3082        let installed_name = "tamper-pkg";
3083        let installed_safe = installed_name.replace('@', "").replace('/', "-");
3084        let on_disk = packages_dir.join(installed_safe);
3085        fs::write(on_disk.join(MANIFEST_NAME), "tampered = true\n").unwrap();
3086
3087        // Re-open the manager — `load_installed` should drop the package.
3088        let mgr2 = PackageManager::with_dir(packages_dir).unwrap();
3089        let names: Vec<&str> = mgr2.list().iter().map(|m| m.name.as_str()).collect();
3090        assert!(
3091            !names.contains(&"tamper-pkg"),
3092            "tampered package must be excluded from load_installed; loaded names: {names:?}"
3093        );
3094    }
3095}