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