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