1use 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
50fn 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
73static NPM_SPEC_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
75 regex::Regex::new(r"^(@?[^@]+(?:/[^@]+)?)(?:@(.+))?$").expect("valid static regex")
76});
77
78const LOCKFILE_NAME: &str = "oxi-lock.json";
81const MANIFEST_NAME: &str = "oxi-package.toml";
82const NPM_MANIFEST_NAME: &str = "package.json";
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum ResourceKind {
90 Extension,
92 Skill,
94 Prompt,
96 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#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct PackageManifest {
116 pub name: String,
118 pub version: String,
120 #[serde(default)]
122 pub extensions: Vec<String>,
123 #[serde(default)]
125 pub skills: Vec<String>,
126 #[serde(default)]
128 pub prompts: Vec<String>,
129 #[serde(default)]
131 pub themes: Vec<String>,
132 #[serde(default)]
134 pub description: Option<String>,
135 #[serde(default)]
137 pub dependencies: BTreeMap<String, String>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct DiscoveredResource {
143 pub kind: ResourceKind,
145 pub path: PathBuf,
147 pub relative_path: String,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct PathMetadata {
154 pub source: String,
156 pub scope: SourceScope,
158 pub origin: ResourceOrigin,
160 pub base_dir: Option<PathBuf>,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
166#[serde(rename_all = "snake_case")]
167pub enum ResourceOrigin {
168 Package,
170 TopLevel,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case")]
177pub enum SourceScope {
178 User,
180 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#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct ResolvedResource {
196 pub path: PathBuf,
198 pub enabled: bool,
200 pub metadata: PathMetadata,
202}
203
204#[derive(Debug, Clone, Default, Serialize, Deserialize)]
206pub struct ResolvedPaths {
207 pub extensions: Vec<ResolvedResource>,
209 pub skills: Vec<ResolvedResource>,
211 pub prompts: Vec<ResolvedResource>,
213 pub themes: Vec<ResolvedResource>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct ProgressEvent {
220 pub event_type: ProgressEventType,
222 pub action: ProgressAction,
224 pub source: String,
226 pub message: Option<String>,
228}
229
230#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
232#[serde(rename_all = "snake_case")]
233pub enum ProgressEventType {
234 Start,
236 Progress,
238 Complete,
240 Error,
242}
243
244#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
246#[serde(rename_all = "snake_case")]
247pub enum ProgressAction {
248 Install,
250 Remove,
252 Update,
254 Clone,
256 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
272pub type ProgressCallback = Box<dyn Fn(ProgressEvent) + Send + Sync>;
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
279#[serde(tag = "type", rename_all = "snake_case")]
280pub enum ParsedSource {
281 Npm {
283 spec: String,
285 name: String,
287 pinned: bool,
289 },
290 Git {
292 repo: String,
294 host: String,
296 path: String,
298 ref_: Option<String>,
300 },
301 Local {
303 path: String,
305 },
306 Url {
308 url: String,
310 },
311}
312
313impl ParsedSource {
314 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 if source.ends_with(".git")
347 || source.contains("github.com")
348 || source.contains("gitlab.com")
349 {
350 return parse_git_source(source);
351 }
352 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 return parse_git_source(source);
364 }
365
366 ParsedSource::Local {
368 path: source.to_string(),
369 }
370 }
371
372 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 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
393fn parse_npm_spec(spec: &str) -> (String, bool) {
395 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
404fn split_git_path_ref(input: &str) -> (String, Option<String>) {
406 if let Some(at_pos) = input.rfind('@') {
407 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
418fn parse_git_source(source: &str) -> ParsedSource {
420 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
488pub struct NpmPackageInfo {
489 pub name: String,
491 pub versions: BTreeMap<String, serde_json::Value>,
493 #[serde(rename = "dist-tags")]
495 pub dist_tags: BTreeMap<String, String>,
496}
497
498impl NpmPackageInfo {
499 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 pub fn latest_version(&self) -> Option<&str> {
525 self.dist_tags.get("latest").map(|s| s.as_str())
526 }
527
528 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 if self.versions.contains_key(constraint) {
536 return Some(constraint.to_string());
537 }
538
539 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
562pub 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
570fn 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
599fn 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
618pub 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
638pub 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 let local_head = git_command(&["rev-parse", "HEAD"], Some(repo_dir))?;
649
650 let fetch_ref = if let Some(r) = ref_ {
652 r.to_string()
653 } else {
654 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 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); }
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) }
688
689pub fn git_has_update(repo_dir: &Path) -> Result<bool> {
691 let local_head = git_command(&["rev-parse", "HEAD"], Some(repo_dir))?;
692
693 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
724pub struct LockEntry {
725 pub source: String,
727 pub name: String,
729 pub version: String,
731 pub integrity: Option<String>,
733 pub scope: SourceScope,
735 pub source_type: String,
737 #[serde(default)]
739 pub dependencies: BTreeMap<String, String>,
740}
741
742#[derive(Debug, Clone, Serialize, Deserialize)]
744pub struct Lockfile {
745 pub version: u32,
747 pub packages: BTreeMap<String, LockEntry>,
749}
750
751impl Lockfile {
752 pub fn new() -> Self {
754 Self {
755 version: 1,
756 packages: BTreeMap::new(),
757 }
758 }
759
760 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 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 pub fn insert(&mut self, entry: LockEntry) {
782 self.packages.insert(entry.name.clone(), entry);
783 }
784
785 pub fn remove(&mut self, name: &str) -> Option<LockEntry> {
787 self.packages.remove(name)
788 }
789
790 pub fn contains(&self, name: &str) -> bool {
792 self.packages.contains_key(name)
793 }
794
795 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
811pub struct ResourceCounts {
812 pub extensions: usize,
814 pub skills: usize,
816 pub prompts: usize,
818 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#[derive(Debug, Clone, Serialize, Deserialize)]
850pub struct PackageUpdateInfo {
851 pub source: String,
853 pub display_name: String,
855 pub source_type: String, pub scope: SourceScope,
859}
860
861#[derive(Debug, Clone, Serialize, Deserialize)]
863pub struct ConfiguredPackage {
864 pub source: String,
866 pub scope: SourceScope,
868 pub filtered: bool,
870 pub installed_path: Option<PathBuf>,
872}
873
874pub struct PackageManager {
876 packages_dir: PathBuf,
877 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 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 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 pub fn set_project_dir(&mut self, dir: PathBuf) {
919 self.project_dir = dir;
920 }
921
922 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 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 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 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 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 fn save_lockfile(&self) -> Result<()> {
1015 let lock_path = self.packages_dir.join(LOCKFILE_NAME);
1016 self.lockfile.write(&lock_path)
1017 }
1018
1019 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 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 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 pub fn packages_dir(&self) -> &Path {
1047 &self.packages_dir
1048 }
1049
1050 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 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 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 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 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 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 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 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 let _version = if pinned {
1203 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 self.install_npm_pack(&spec, scope)
1218 }
1219
1220 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 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 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 let pkg_source = extract_dir.join("package");
1266 let source_for_copy = if pkg_source.exists() {
1267 &pkg_source
1268 } else {
1269 extract_dir.as_path()
1271 };
1272
1273 self.ensure_packages_dir()?;
1274
1275 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 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 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 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 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 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 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 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 pub fn install_npm(&mut self, name: &str) -> Result<PackageManifest> {
1534 self.install_npm_pack(name, SourceScope::User)
1535 }
1536
1537 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 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 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 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 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 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 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 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 pub fn list(&self) -> Vec<&PackageManifest> {
1772 self.installed.values().collect()
1773 }
1774
1775 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 pub fn is_installed(&self, name: &str) -> bool {
1795 self.installed.contains_key(name)
1796 }
1797
1798 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 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 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 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 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 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 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 pub fn validate_package(dir: &Path) -> Result<Vec<String>> {
1998 let mut warnings = Vec::new();
1999
2000 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 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 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 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 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 pub fn get_installed_version(&self, name: &str) -> Option<&str> {
2081 self.installed.get(name).map(|m| m.version.as_str())
2082 }
2083
2084 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 pub fn lockfile(&self) -> &Lockfile {
2097 &self.lockfile
2098 }
2099}
2100
2101fn 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 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
2160fn 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
2201fn 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
2215fn 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
2229fn 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
2274fn 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
2297fn 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
2313fn 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
2346fn 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
2370fn 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
2380fn 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 #[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 #[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 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 #[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 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 #[test]
2903 fn test_resolve_dependencies() {
2904 let (tmp, packages_dir) = setup_temp_packages_dir();
2905
2906 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 #[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 #[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 #[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 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 }
3017
3018 #[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 #[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 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 #[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 #[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 {
3077 let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
3078 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
3079 }
3080
3081 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 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}