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 self.emit_progress(ProgressEvent {
314 event_type: ProgressEventType::Start,
315 action: ProgressAction::Install,
316 source: source.to_string(),
317 message: Some(format!("Installing {}...", source)),
318 });
319 let result = match &parsed {
320 ParsedSource::Npm { .. } => run_on_fresh_runtime(self.install_npm_async(source, scope)),
321 ParsedSource::Git { repo, ref_, .. } => {
322 self.install_git_sync(source, repo, ref_.as_deref(), scope)
323 }
324 ParsedSource::Local { path } => self.install_local(path),
325 ParsedSource::Url { url } => run_on_fresh_runtime(self.install_url(url, scope)),
326 };
327 match &result {
328 Ok(_) => self.emit_progress(ProgressEvent {
329 event_type: ProgressEventType::Complete,
330 action: ProgressAction::Install,
331 source: source.to_string(),
332 message: None,
333 }),
334 Err(e) => self.emit_progress(ProgressEvent {
335 event_type: ProgressEventType::Error,
336 action: ProgressAction::Install,
337 source: source.to_string(),
338 message: Some(e.to_string()),
339 }),
340 }
341 result
342 }
343
344 async fn install_npm_async(
346 &mut self,
347 source: &str,
348 scope: SourceScope,
349 ) -> Result<PackageManifest> {
350 let parsed = ParsedSource::parse(source);
351 let (spec, name, pinned) = match &parsed {
352 ParsedSource::Npm { spec, name, pinned } => (spec.clone(), name.clone(), *pinned),
353 _ => bail!("Expected npm source"),
354 };
355
356 let _version = if pinned {
358 let (_, ver) = parse_npm_spec(&spec);
360 if ver {
361 spec.rsplit('@').next().unwrap_or("latest").to_string()
362 } else {
363 "latest".to_string()
364 }
365 } else {
366 get_latest_npm_version(&name)
367 .await
368 .unwrap_or_else(|_| "latest".to_string())
369 };
370
371 self.install_npm_pack(&spec, scope)
373 }
374
375 fn install_npm_pack(&mut self, spec: &str, scope: SourceScope) -> Result<PackageManifest> {
377 let tmp_dir =
378 tempfile::tempdir().context("Failed to create temp directory for npm install")?;
379
380 let output = std::process::Command::new("npm")
381 .args(["pack", spec, "--pack-destination"])
382 .arg(tmp_dir.path())
383 .current_dir(tmp_dir.path())
384 .output()
385 .context("Failed to run npm pack")?;
386
387 if !output.status.success() {
388 let stderr = String::from_utf8_lossy(&output.stderr);
389 bail!("npm pack failed for '{}': {}", spec, stderr);
390 }
391
392 let tarball = fs::read_dir(tmp_dir.path())?
394 .filter_map(|e| e.ok())
395 .find(|e| {
396 e.path()
397 .extension()
398 .map(|ext| ext == "tgz")
399 .unwrap_or(false)
400 })
401 .map(|e| e.path())
402 .context("No .tgz file found after npm pack")?;
403
404 let extract_dir = tmp_dir.path().join("extracted");
406 fs::create_dir_all(&extract_dir)?;
407
408 let tar_status = std::process::Command::new("tar")
409 .args(["-xzf", &tarball.to_string_lossy(), "-C"])
410 .arg(&extract_dir)
411 .output()
412 .context("Failed to run tar")?;
413
414 if !tar_status.status.success() {
415 let stderr = String::from_utf8_lossy(&tar_status.stderr);
416 bail!("tar extraction failed: {}", stderr);
417 }
418
419 let pkg_source = extract_dir.join("package");
421 let source_for_copy = if pkg_source.exists() {
422 &pkg_source
423 } else {
424 extract_dir.as_path()
426 };
427
428 self.ensure_packages_dir()?;
429
430 let manifest = if source_for_copy.join(MANIFEST_NAME).exists() {
432 Self::read_manifest(&source_for_copy.join(MANIFEST_NAME))?
433 } else if source_for_copy.join(NPM_MANIFEST_NAME).exists() {
434 let pj = Self::read_package_json(source_for_copy);
435 let (pkg_name, pkg_version) = pj
436 .as_ref()
437 .map(|v| {
438 (
439 v.get("name")
440 .and_then(|n| n.as_str())
441 .unwrap_or(spec)
442 .to_string(),
443 v.get("version")
444 .and_then(|v| v.as_str())
445 .unwrap_or("0.0.0")
446 .to_string(),
447 )
448 })
449 .unwrap_or((spec.to_string(), "0.0.0".to_string()));
450
451 PackageManifest {
452 name: pkg_name,
453 version: pkg_version,
454 extensions: Vec::new(),
455 skills: Vec::new(),
456 prompts: Vec::new(),
457 themes: Vec::new(),
458 description: None,
459 dependencies: BTreeMap::new(),
460 }
461 } else {
462 PackageManifest {
463 name: spec.to_string(),
464 version: "0.0.0".to_string(),
465 extensions: Vec::new(),
466 skills: Vec::new(),
467 prompts: Vec::new(),
468 themes: Vec::new(),
469 description: None,
470 dependencies: BTreeMap::new(),
471 }
472 };
473
474 let dest = self.pkg_install_dir(&manifest.name);
475 if dest.exists() {
476 fs::remove_dir_all(&dest).with_context(|| {
477 format!("Failed to remove existing package at {}", dest.display())
478 })?;
479 }
480
481 copy_dir_recursive(source_for_copy, &dest)
482 .with_context(|| format!("Failed to copy npm package for '{}'", spec))?;
483
484 let integrity = compute_dir_hash(&dest);
485
486 self.lockfile.insert(LockEntry {
487 source: format!("npm:{}", spec),
488 name: manifest.name.clone(),
489 version: manifest.version.clone(),
490 integrity,
491 scope,
492 source_type: "npm".to_string(),
493 dependencies: manifest.dependencies.clone(),
494 });
495
496 self.installed
497 .insert(manifest.name.clone(), manifest.clone());
498 self.save_lockfile()
499 .context("failed to persist package lockfile")?;
500 Ok(manifest)
501 }
502
503 fn install_git_sync(
505 &mut self,
506 source: &str,
507 repo: &str,
508 ref_: Option<&str>,
509 scope: SourceScope,
510 ) -> Result<PackageManifest> {
511 let parsed = ParsedSource::parse(source);
512 let (host, path) = match &parsed {
513 ParsedSource::Git { host, path, .. } => (host.clone(), path.clone()),
514 _ => bail!("Expected git source"),
515 };
516
517 let target_dir = self.git_install_path(&host, &path, scope);
518
519 if target_dir.exists() {
520 return self.load_manifest_from_dir(&target_dir, source, scope);
522 }
523
524 let Some(parent) = target_dir.parent() else {
525 bail!(
526 "Invalid install path: no parent directory for {}",
527 target_dir.display()
528 );
529 };
530 fs::create_dir_all(parent)
531 .with_context(|| format!("Failed to create parent dir for {}", target_dir.display()))?;
532
533 git_clone(repo, &target_dir, ref_)?;
534
535 if target_dir.join(NPM_MANIFEST_NAME).exists() {
537 let _ = std::process::Command::new("npm")
538 .args(["install", "--omit=dev"])
539 .current_dir(&target_dir)
540 .output();
541 }
542
543 self.load_manifest_from_dir(&target_dir, source, scope)
544 }
545
546 fn load_manifest_from_dir(
548 &mut self,
549 dir: &Path,
550 source: &str,
551 scope: SourceScope,
552 ) -> Result<PackageManifest> {
553 let manifest = if dir.join(MANIFEST_NAME).exists() {
554 Self::read_manifest(&dir.join(MANIFEST_NAME))?
555 } else {
556 let name = dir
557 .file_name()
558 .map(|n| n.to_string_lossy().to_string())
559 .unwrap_or_else(|| "unknown".to_string());
560 PackageManifest {
561 name,
562 version: "0.0.0".to_string(),
563 extensions: Vec::new(),
564 skills: Vec::new(),
565 prompts: Vec::new(),
566 themes: Vec::new(),
567 description: None,
568 dependencies: BTreeMap::new(),
569 }
570 };
571
572 let integrity = compute_dir_hash(dir);
573
574 self.lockfile.insert(LockEntry {
575 source: source.to_string(),
576 name: manifest.name.clone(),
577 version: manifest.version.clone(),
578 integrity,
579 scope,
580 source_type: "git".to_string(),
581 dependencies: manifest.dependencies.clone(),
582 });
583
584 self.installed
585 .insert(manifest.name.clone(), manifest.clone());
586 self.save_lockfile()
587 .context("failed to persist package lockfile")?;
588 Ok(manifest)
589 }
590
591 async fn install_url(&mut self, url: &str, scope: SourceScope) -> Result<PackageManifest> {
593 let client = shared_http_client();
594
595 let resp = client.get(url).send().await?;
596 if !resp.status().is_success() {
597 bail!("Failed to download {}: {}", url, resp.status());
598 }
599
600 let bytes = resp.bytes().await?;
601
602 let tmp_dir = tempfile::tempdir()?;
603 let archive_name = url.split('/').next_back().unwrap_or("archive");
604 let archive_path = tmp_dir.path().join(archive_name);
605 fs::write(&archive_path, &bytes)?;
606
607 let extract_dir = tmp_dir.path().join("extracted");
608 fs::create_dir_all(&extract_dir)?;
609
610 if archive_name.ends_with(".tar.gz") || archive_name.ends_with(".tgz") {
611 let status = std::process::Command::new("tar")
612 .args(["-xzf", &archive_path.to_string_lossy(), "-C"])
613 .arg(&extract_dir)
614 .output()?;
615 if !status.status.success() {
616 bail!("Failed to extract archive");
617 }
618 } else if archive_name.ends_with(".zip") {
619 let status = std::process::Command::new("unzip")
621 .arg("-o")
622 .arg(&archive_path)
623 .arg("-d")
624 .arg(&extract_dir)
625 .output()?;
626 if !status.status.success() {
627 bail!("Failed to extract zip archive");
628 }
629 } else {
630 bail!("Unsupported archive format: {}", archive_name);
631 }
632
633 let pkg_dir = find_single_subdir(&extract_dir).unwrap_or_else(|| extract_dir.to_path_buf());
635
636 self.ensure_packages_dir()?;
637
638 let manifest = if pkg_dir.join(MANIFEST_NAME).exists() {
639 Self::read_manifest(&pkg_dir.join(MANIFEST_NAME))?
640 } else {
641 let name = url
642 .split('/')
643 .next_back()
644 .unwrap_or("url-package")
645 .trim_end_matches(".tar.gz")
646 .trim_end_matches(".tgz")
647 .trim_end_matches(".zip")
648 .to_string();
649 PackageManifest {
650 name,
651 version: "0.0.0".to_string(),
652 extensions: Vec::new(),
653 skills: Vec::new(),
654 prompts: Vec::new(),
655 themes: Vec::new(),
656 description: None,
657 dependencies: BTreeMap::new(),
658 }
659 };
660
661 let dest = self.pkg_install_dir(&manifest.name);
662 if dest.exists() {
663 fs::remove_dir_all(&dest)?;
664 }
665
666 copy_dir_recursive(&pkg_dir, &dest)?;
667
668 let integrity = compute_dir_hash(&dest);
669
670 self.lockfile.insert(LockEntry {
671 source: url.to_string(),
672 name: manifest.name.clone(),
673 version: manifest.version.clone(),
674 integrity,
675 scope,
676 source_type: "url".to_string(),
677 dependencies: manifest.dependencies.clone(),
678 });
679
680 self.installed
681 .insert(manifest.name.clone(), manifest.clone());
682 self.save_lockfile()
683 .context("failed to persist package lockfile")?;
684 Ok(manifest)
685 }
686
687 pub fn install_npm(&mut self, name: &str) -> Result<PackageManifest> {
689 self.install_npm_pack(name, SourceScope::User)
690 }
691
692 pub fn uninstall(&mut self, name: &str) -> Result<()> {
696 if !self.installed.contains_key(name) {
697 bail!("Package '{}' is not installed", name);
698 }
699
700 let dest = self.pkg_install_dir(name);
701 if dest.exists() {
702 fs::remove_dir_all(&dest).with_context(|| {
703 format!("Failed to remove package directory {}", dest.display())
704 })?;
705 }
706
707 let _ = self.lockfile.remove(name);
710 self.save_lockfile()
711 .context("failed to persist package lockfile")?;
712
713 self.installed.remove(name);
714 Ok(())
715 }
716
717 pub fn uninstall_from_source(&mut self, source: &str, scope: SourceScope) -> Result<()> {
719 let parsed = ParsedSource::parse(source);
720 self.emit_progress(ProgressEvent {
721 event_type: ProgressEventType::Start,
722 action: ProgressAction::Remove,
723 source: source.to_string(),
724 message: Some(format!("Removing {}...", source)),
725 });
726 let result = self.do_uninstall_from_source(&parsed, scope);
727 match &result {
728 Ok(_) => self.emit_progress(ProgressEvent {
729 event_type: ProgressEventType::Complete,
730 action: ProgressAction::Remove,
731 source: source.to_string(),
732 message: None,
733 }),
734 Err(e) => self.emit_progress(ProgressEvent {
735 event_type: ProgressEventType::Error,
736 action: ProgressAction::Remove,
737 source: source.to_string(),
738 message: Some(e.to_string()),
739 }),
740 }
741 result
742 }
743
744 fn do_uninstall_from_source(
745 &mut self,
746 parsed: &ParsedSource,
747 scope: SourceScope,
748 ) -> Result<()> {
749 match parsed {
750 ParsedSource::Npm { name, .. } => {
751 let dest = self.npm_install_path(name, scope);
752 if dest.exists() {
753 fs::remove_dir_all(&dest)?;
754 }
755 self.installed.remove(name);
756 self.lockfile.remove(name);
757 self.save_lockfile()
758 .context("failed to persist package lockfile")?;
759 Ok(())
760 }
761 ParsedSource::Git { host, path, .. } => {
762 let dest = self.git_install_path(host, path, scope);
763 if dest.exists() {
764 fs::remove_dir_all(&dest)?;
765 prune_empty_parents(&dest, &self.packages_dir);
766 }
767 self.installed.retain(|_, m| {
768 let parsed_m = ParsedSource::parse(m.name.as_str());
769 parsed_m.identity() != parsed.identity()
770 });
771 self.lockfile.packages.retain(|_, entry| {
772 let parsed_e = ParsedSource::parse(&entry.source);
773 parsed_e.identity() != parsed.identity()
774 });
775 self.save_lockfile()
776 .context("failed to persist package lockfile")?;
777 Ok(())
778 }
779 ParsedSource::Local { .. } => Ok(()),
780 ParsedSource::Url { .. } => {
781 let identity = parsed.identity();
782 self.lockfile
783 .packages
784 .retain(|_, e| ParsedSource::parse(&e.source).identity() != identity);
785 self.save_lockfile()
786 .context("failed to persist package lockfile")?;
787 Ok(())
788 }
789 }
790 }
791
792 pub fn update(&mut self, name: &str) -> Result<PackageManifest> {
799 let lock_entry = self.lockfile.get(name).cloned();
800
801 if let Some(entry) = lock_entry {
802 let parsed = ParsedSource::parse(&entry.source);
803 return match &parsed {
804 ParsedSource::Npm { spec, .. } => {
805 self.emit_progress(ProgressEvent {
806 event_type: ProgressEventType::Start,
807 action: ProgressAction::Update,
808 source: entry.source.clone(),
809 message: Some(format!("Updating {}...", name)),
810 });
811 let result = self.install_npm_pack(spec, entry.scope);
812 match &result {
813 Ok(_) => self.emit_progress(ProgressEvent {
814 event_type: ProgressEventType::Complete,
815 action: ProgressAction::Update,
816 source: entry.source.clone(),
817 message: None,
818 }),
819 Err(e) => self.emit_progress(ProgressEvent {
820 event_type: ProgressEventType::Error,
821 action: ProgressAction::Update,
822 source: entry.source.clone(),
823 message: Some(e.to_string()),
824 }),
825 }
826 result
827 }
828 ParsedSource::Git { repo, ref_, .. } => {
829 let target_dir = match &parsed {
830 ParsedSource::Git { host, path, .. } => {
831 self.git_install_path(host, path, entry.scope)
832 }
833 _ => unreachable!(),
834 };
835 if target_dir.exists() {
836 let updated = git_update(&target_dir, ref_.as_deref())?;
837 if updated && target_dir.join(NPM_MANIFEST_NAME).exists() {
838 let _ = std::process::Command::new("npm")
839 .args(["install", "--omit=dev"])
840 .current_dir(&target_dir)
841 .output();
842 }
843 self.load_manifest_from_dir(&target_dir, &entry.source, entry.scope)
844 } else {
845 self.install_git_sync(&entry.source, repo, ref_.as_deref(), entry.scope)
846 }
847 }
848 ParsedSource::Local { path } => self.install_local(path),
849 ParsedSource::Url { url } => {
850 run_on_fresh_runtime(self.install_url(url, entry.scope))
851 }
852 };
853 }
854
855 if self.installed.contains_key(name) {
857 self.install_npm_pack(name, SourceScope::User)
858 } else {
859 bail!("Package '{}' is not installed", name);
860 }
861 }
862
863 pub fn update_all(&mut self) -> Vec<(String, Result<PackageManifest>)> {
865 let names: Vec<String> = self.installed.keys().cloned().collect();
866 let mut results = Vec::new();
867 for name in names {
868 let result = self.update(&name);
869 results.push((name, result));
870 }
871 results
872 }
873
874 pub async fn check_for_updates(&self) -> Vec<PackageUpdateInfo> {
876 let mut updates = Vec::new();
877
878 for lock_entry in self.lockfile.packages.values() {
879 let parsed = ParsedSource::parse(&lock_entry.source);
880
881 match &parsed {
882 ParsedSource::Npm { name: pkg_name, .. } => {
883 match NpmPackageInfo::fetch(pkg_name).await {
885 Ok(info) => {
886 if let Some(latest) = info.latest_version()
887 && latest != lock_entry.version
888 {
889 updates.push(PackageUpdateInfo {
890 source: lock_entry.source.clone(),
891 display_name: pkg_name.clone(),
892 source_type: "npm".to_string(),
893 scope: lock_entry.scope,
894 });
895 }
896 }
897 Err(_) => continue,
898 }
899 }
900 ParsedSource::Git { host, path, .. } => {
901 let install_path = self.git_install_path(host, path, lock_entry.scope);
902 if install_path.exists() {
903 match git_has_update(&install_path) {
904 Ok(true) => {
905 updates.push(PackageUpdateInfo {
906 source: lock_entry.source.clone(),
907 display_name: format!("{}/{}", host, path),
908 source_type: "git".to_string(),
909 scope: lock_entry.scope,
910 });
911 }
912 _ => continue,
913 }
914 }
915 }
916 _ => continue,
917 }
918 }
919
920 updates
921 }
922
923 pub fn list(&self) -> Vec<&PackageManifest> {
927 self.installed.values().collect()
928 }
929
930 pub fn list_configured(&self) -> Vec<ConfiguredPackage> {
932 let mut result = Vec::new();
933 for name in self.installed.keys() {
934 let installed_path = self.get_install_dir(name);
935 let lock_entry = self.lockfile.get(name);
936 result.push(ConfiguredPackage {
937 source: lock_entry
938 .map(|e| e.source.clone())
939 .unwrap_or_else(|| name.clone()),
940 scope: lock_entry.map(|e| e.scope).unwrap_or(SourceScope::User),
941 filtered: false,
942 installed_path,
943 });
944 }
945 result
946 }
947
948 pub fn is_installed(&self, name: &str) -> bool {
950 self.installed.contains_key(name)
951 }
952
953 pub fn get_install_dir(&self, name: &str) -> Option<PathBuf> {
955 let dir = self.pkg_install_dir(name);
956 if dir.exists() { Some(dir) } else { None }
957 }
958
959 pub fn get_installed_path_for_source(
961 &self,
962 source: &str,
963 scope: SourceScope,
964 ) -> Option<PathBuf> {
965 let parsed = ParsedSource::parse(source);
966 match &parsed {
967 ParsedSource::Npm { name, .. } => {
968 let path = self.npm_install_path(name, scope);
969 if path.exists() { Some(path) } else { None }
970 }
971 ParsedSource::Git { host, path, .. } => {
972 let path = self.git_install_path(host, path, scope);
973 if path.exists() { Some(path) } else { None }
974 }
975 ParsedSource::Local { path } => {
976 let p = PathBuf::from(path);
977 if p.exists() { Some(p) } else { None }
978 }
979 ParsedSource::Url { .. } => None,
980 }
981 }
982
983 pub fn discover_resources(&self, name: &str) -> Result<Vec<DiscoveredResource>> {
987 let manifest = self
988 .installed
989 .get(name)
990 .with_context(|| format!("Package '{}' not found", name))?;
991
992 let install_dir = self.pkg_install_dir(name);
993 if !install_dir.exists() {
994 bail!("Install directory for '{}' does not exist", name);
995 }
996
997 let mut resources = Vec::new();
998
999 let has_explicit = !manifest.extensions.is_empty()
1000 || !manifest.skills.is_empty()
1001 || !manifest.prompts.is_empty()
1002 || !manifest.themes.is_empty();
1003
1004 if has_explicit {
1005 for ext in &manifest.extensions {
1006 let path = install_dir.join(ext);
1007 if path.exists() {
1008 resources.push(DiscoveredResource {
1009 kind: ResourceKind::Extension,
1010 path,
1011 relative_path: ext.clone(),
1012 });
1013 }
1014 }
1015 for skill in &manifest.skills {
1016 let path = install_dir.join(skill);
1017 if path.exists() {
1018 resources.push(DiscoveredResource {
1019 kind: ResourceKind::Skill,
1020 path,
1021 relative_path: skill.clone(),
1022 });
1023 }
1024 }
1025 for prompt in &manifest.prompts {
1026 let path = install_dir.join(prompt);
1027 if path.exists() {
1028 resources.push(DiscoveredResource {
1029 kind: ResourceKind::Prompt,
1030 path,
1031 relative_path: prompt.clone(),
1032 });
1033 }
1034 }
1035 for theme in &manifest.themes {
1036 let path = install_dir.join(theme);
1037 if path.exists() {
1038 resources.push(DiscoveredResource {
1039 kind: ResourceKind::Theme,
1040 path,
1041 relative_path: theme.clone(),
1042 });
1043 }
1044 }
1045 } else {
1046 resources.extend(discover_extensions(&install_dir));
1047 resources.extend(discover_skills(&install_dir));
1048 resources.extend(discover_prompts(&install_dir));
1049 resources.extend(discover_themes(&install_dir));
1050 }
1051
1052 Ok(resources)
1053 }
1054
1055 pub fn resource_counts(&self, name: &str) -> Result<ResourceCounts> {
1057 let resources = self.discover_resources(name)?;
1058 let mut counts = ResourceCounts::default();
1059 for r in &resources {
1060 match r.kind {
1061 ResourceKind::Extension => counts.extensions += 1,
1062 ResourceKind::Skill => counts.skills += 1,
1063 ResourceKind::Prompt => counts.prompts += 1,
1064 ResourceKind::Theme => counts.themes += 1,
1065 }
1066 }
1067 Ok(counts)
1068 }
1069
1070 pub fn resolve(&self) -> ResolvedPaths {
1072 let mut extensions = Vec::new();
1073 let mut skills = Vec::new();
1074 let mut prompts = Vec::new();
1075 let mut themes = Vec::new();
1076
1077 for name in self.installed.keys() {
1078 let install_dir = self.pkg_install_dir(name);
1079 if !install_dir.exists() {
1080 continue;
1081 }
1082
1083 let metadata = PathMetadata {
1084 source: name.clone(),
1085 scope: SourceScope::User,
1086 origin: ResourceOrigin::Package,
1087 base_dir: Some(install_dir.clone()),
1088 };
1089
1090 if let Ok(resources) = self.discover_resources(name) {
1092 for r in resources {
1093 match r.kind {
1094 ResourceKind::Extension => extensions.push(ResolvedResource {
1095 path: r.path,
1096 enabled: true,
1097 metadata: metadata.clone(),
1098 }),
1099 ResourceKind::Skill => skills.push(ResolvedResource {
1100 path: r.path,
1101 enabled: true,
1102 metadata: metadata.clone(),
1103 }),
1104 ResourceKind::Prompt => prompts.push(ResolvedResource {
1105 path: r.path,
1106 enabled: true,
1107 metadata: metadata.clone(),
1108 }),
1109 ResourceKind::Theme => themes.push(ResolvedResource {
1110 path: r.path,
1111 enabled: true,
1112 metadata: metadata.clone(),
1113 }),
1114 }
1115 }
1116 }
1117 }
1118
1119 ResolvedPaths {
1120 extensions,
1121 skills,
1122 prompts,
1123 themes,
1124 }
1125 }
1126
1127 pub fn resolve_dependencies(&self) -> Vec<(String, Vec<String>)> {
1132 let mut result = Vec::new();
1133 let installed_names: HashSet<&str> = self.installed.keys().map(|s| s.as_str()).collect();
1134
1135 for (name, manifest) in &self.installed {
1136 let missing: Vec<String> = manifest
1137 .dependencies
1138 .keys()
1139 .filter(|dep| !installed_names.contains(dep.as_str()))
1140 .cloned()
1141 .collect();
1142
1143 if !missing.is_empty() {
1144 result.push((name.clone(), missing));
1145 }
1146 }
1147
1148 result
1149 }
1150
1151 pub fn validate_package(dir: &Path) -> Result<Vec<String>> {
1153 let mut warnings = Vec::new();
1154
1155 if !dir.join(MANIFEST_NAME).exists() && !dir.join(NPM_MANIFEST_NAME).exists() {
1157 warnings.push(format!(
1158 "No {} or {} found",
1159 MANIFEST_NAME, NPM_MANIFEST_NAME
1160 ));
1161 }
1162
1163 if dir.join(MANIFEST_NAME).exists() {
1165 match Self::read_manifest(&dir.join(MANIFEST_NAME)) {
1166 Ok(m) => {
1167 if m.name.is_empty() {
1168 warnings.push("Package name is empty".to_string());
1169 }
1170 if m.version.is_empty() {
1171 warnings.push("Package version is empty".to_string());
1172 }
1173 if semver::Version::parse(&m.version).is_err() {
1174 warnings.push(format!("Version '{}' is not valid semver", m.version));
1175 }
1176 let has_resources = !m.extensions.is_empty()
1177 || !m.skills.is_empty()
1178 || !m.prompts.is_empty()
1179 || !m.themes.is_empty();
1180 if !has_resources {
1181 let discovered = discover_extensions(dir)
1183 .into_iter()
1184 .chain(discover_skills(dir))
1185 .chain(discover_prompts(dir))
1186 .chain(discover_themes(dir))
1187 .count();
1188 if discovered == 0 {
1189 warnings.push(
1190 "Package has no explicit resources and auto-discovery found nothing"
1191 .to_string(),
1192 );
1193 }
1194 }
1195
1196 for ext in &m.extensions {
1198 if !dir.join(ext).exists() {
1199 warnings.push(format!("Extension path '{}' does not exist", ext));
1200 }
1201 }
1202 for skill in &m.skills {
1203 if !dir.join(skill).exists() {
1204 warnings.push(format!("Skill path '{}' does not exist", skill));
1205 }
1206 }
1207 for prompt in &m.prompts {
1208 if !dir.join(prompt).exists() {
1209 warnings.push(format!("Prompt path '{}' does not exist", prompt));
1210 }
1211 }
1212 for theme in &m.themes {
1213 if !dir.join(theme).exists() {
1214 warnings.push(format!("Theme path '{}' does not exist", theme));
1215 }
1216 }
1217 }
1218 Err(e) => {
1219 warnings.push(format!("Failed to parse {}: {}", MANIFEST_NAME, e));
1220 }
1221 }
1222 }
1223
1224 if !dir.join(".gitignore").exists() && !dir.join(".ignore").exists() {
1226 warnings.push("No .gitignore or .ignore file found".to_string());
1227 }
1228
1229 Ok(warnings)
1230 }
1231
1232 pub fn get_installed_version(&self, name: &str) -> Option<&str> {
1236 self.installed.get(name).map(|m| m.version.as_str())
1237 }
1238
1239 pub fn version_satisfies(&self, name: &str, requirement: &str) -> bool {
1241 if let Some(version) = self.get_installed_version(name)
1242 && let Ok(v) = semver::Version::parse(version)
1243 && let Ok(req) = semver::VersionReq::parse(requirement)
1244 {
1245 return req.matches(&v);
1246 }
1247 false
1248 }
1249
1250 pub fn lockfile(&self) -> &Lockfile {
1252 &self.lockfile
1253 }
1254}
1255
1256fn run_on_fresh_runtime<F, T>(future: F) -> Result<T>
1264where
1265 F: Future<Output = Result<T>> + Send,
1266 T: Send,
1267{
1268 std::thread::scope(|s| {
1269 s.spawn(|| {
1270 let rt = tokio::runtime::Builder::new_current_thread()
1271 .enable_all()
1272 .build()
1273 .context("failed to build temp runtime")?;
1274 rt.block_on(future)
1275 })
1276 .join()
1277 .map_err(|_| anyhow::anyhow!("runtime thread panicked"))?
1278 })
1279}
1280
1281#[cfg(test)]
1282mod tests {
1283 use super::super::lockfile::{
1284 compute_dir_hash as _compute_dir_hash,
1285 verify_lockfile_integrity as _verify_lockfile_integrity,
1286 };
1287 use super::*;
1288 use std::path::PathBuf;
1289
1290 fn setup_temp_packages_dir() -> (tempfile::TempDir, PathBuf) {
1291 let tmp = tempfile::tempdir().unwrap();
1292 let packages_dir = tmp.path().join("packages");
1293 fs::create_dir_all(&packages_dir).unwrap();
1294 (tmp, packages_dir)
1295 }
1296
1297 fn create_test_package(base: &Path, name: &str, version: &str) -> PathBuf {
1298 let pkg_dir = base.join("source-pkg");
1299 fs::create_dir_all(&pkg_dir).unwrap();
1300
1301 let manifest = PackageManifest {
1302 name: name.to_string(),
1303 version: version.to_string(),
1304 extensions: vec!["ext1.so".to_string()],
1305 skills: vec!["skill-a".to_string()],
1306 prompts: vec![],
1307 themes: vec![],
1308 description: None,
1309 dependencies: BTreeMap::new(),
1310 };
1311
1312 let toml_content = toml::to_string_pretty(&manifest).unwrap();
1313 fs::write(pkg_dir.join(MANIFEST_NAME), toml_content).unwrap();
1314 fs::write(pkg_dir.join("ext1.so"), "fake extension").unwrap();
1315 fs::create_dir_all(pkg_dir.join("skill-a")).unwrap();
1316 fs::write(pkg_dir.join("skill-a").join("SKILL.md"), "# Skill A").unwrap();
1317
1318 pkg_dir
1319 }
1320
1321 fn create_test_package_with_auto_discovery(base: &Path, name: &str, version: &str) -> PathBuf {
1322 let pkg_dir = base.join("source-pkg-auto");
1323 fs::create_dir_all(&pkg_dir).unwrap();
1324
1325 let manifest = PackageManifest {
1326 name: name.to_string(),
1327 version: version.to_string(),
1328 extensions: vec![],
1329 skills: vec![],
1330 prompts: vec![],
1331 themes: vec![],
1332 description: None,
1333 dependencies: BTreeMap::new(),
1334 };
1335 let toml_content = toml::to_string_pretty(&manifest).unwrap();
1336 fs::write(pkg_dir.join(MANIFEST_NAME), toml_content).unwrap();
1337
1338 fs::write(pkg_dir.join("myext.so"), "extension").unwrap();
1339 fs::create_dir_all(pkg_dir.join("my-skill")).unwrap();
1340 fs::write(pkg_dir.join("my-skill").join("SKILL.md"), "# My Skill").unwrap();
1341 fs::create_dir_all(pkg_dir.join("prompts")).unwrap();
1342 fs::write(pkg_dir.join("prompts").join("review.md"), "# Review").unwrap();
1343 fs::create_dir_all(pkg_dir.join("themes")).unwrap();
1344 fs::write(pkg_dir.join("themes").join("dark.json"), "{}").unwrap();
1345
1346 pkg_dir
1347 }
1348
1349 #[test]
1350 fn test_install_and_list() {
1351 let (tmp, packages_dir) = setup_temp_packages_dir();
1352
1353 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
1354 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1355
1356 let manifest = mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1357 assert_eq!(manifest.name, "test-pkg");
1358 assert_eq!(manifest.version, "1.0.0");
1359
1360 let installed = mgr.list();
1361 assert_eq!(installed.len(), 1);
1362 assert_eq!(installed[0].name, "test-pkg");
1363 }
1364
1365 #[test]
1366 fn test_uninstall() {
1367 let (tmp, packages_dir) = setup_temp_packages_dir();
1368
1369 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
1370 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1371
1372 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1373 assert!(mgr.is_installed("test-pkg"));
1374
1375 mgr.uninstall("test-pkg").unwrap();
1376 assert!(!mgr.is_installed("test-pkg"));
1377 assert!(mgr.list().is_empty());
1378 }
1379
1380 #[test]
1381 fn test_uninstall_not_installed() {
1382 let (_tmp, packages_dir) = setup_temp_packages_dir();
1383 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1384
1385 let result = mgr.uninstall("nonexistent");
1386 assert!(result.is_err());
1387 }
1388
1389 #[test]
1390 fn test_install_scoped_package() {
1391 let (tmp, packages_dir) = setup_temp_packages_dir();
1392
1393 let pkg_dir = create_test_package(tmp.path(), "@foo/oxi-tools", "2.0.0");
1394 let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
1395
1396 let manifest = mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1397 assert_eq!(manifest.name, "@foo/oxi-tools");
1398
1399 let expected_dir = packages_dir.join("foo-oxi-tools");
1400 assert!(expected_dir.exists());
1401 }
1402
1403 #[test]
1404 fn test_reinstall_overwrites() {
1405 let (tmp, packages_dir) = setup_temp_packages_dir();
1406
1407 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
1408 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1409
1410 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1411
1412 let pkg_dir_v2 = tmp.path().join("source-pkg-v2");
1413 fs::create_dir_all(&pkg_dir_v2).unwrap();
1414 let manifest_v2 = PackageManifest {
1415 name: "test-pkg".to_string(),
1416 version: "2.0.0".to_string(),
1417 extensions: vec![],
1418 skills: vec![],
1419 prompts: vec![],
1420 themes: vec![],
1421 description: None,
1422 dependencies: BTreeMap::new(),
1423 };
1424 fs::write(
1425 pkg_dir_v2.join(MANIFEST_NAME),
1426 toml::to_string_pretty(&manifest_v2).unwrap(),
1427 )
1428 .unwrap();
1429
1430 mgr.install(pkg_dir_v2.to_str().unwrap()).unwrap();
1431
1432 let installed = mgr.list();
1433 assert_eq!(installed.len(), 1);
1434 assert_eq!(installed[0].version, "2.0.0");
1435 }
1436
1437 #[test]
1438 fn test_empty_packages_dir() {
1439 let (_tmp, packages_dir) = setup_temp_packages_dir();
1440 let mgr = PackageManager::with_dir(packages_dir).unwrap();
1441 assert!(mgr.list().is_empty());
1442 }
1443
1444 #[test]
1445 fn test_packages_dir_not_exists() {
1446 let tmp = tempfile::tempdir().unwrap();
1447 let nonexistent = tmp.path().join("does-not-exist");
1448 let mgr = PackageManager::with_dir(nonexistent).unwrap();
1449 assert!(mgr.list().is_empty());
1450 }
1451
1452 #[test]
1453 fn test_discover_resources_explicit() {
1454 let (tmp, packages_dir) = setup_temp_packages_dir();
1455
1456 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
1457 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1458 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1459
1460 let resources = mgr.discover_resources("test-pkg").unwrap();
1461 assert_eq!(resources.len(), 2);
1462
1463 let extensions: Vec<_> = resources
1464 .iter()
1465 .filter(|r| r.kind == ResourceKind::Extension)
1466 .collect();
1467 let skills: Vec<_> = resources
1468 .iter()
1469 .filter(|r| r.kind == ResourceKind::Skill)
1470 .collect();
1471 assert_eq!(extensions.len(), 1);
1472 assert_eq!(skills.len(), 1);
1473 }
1474
1475 #[test]
1476 fn test_discover_resources_auto() {
1477 let (tmp, packages_dir) = setup_temp_packages_dir();
1478
1479 let pkg_dir = create_test_package_with_auto_discovery(tmp.path(), "auto-pkg", "1.0.0");
1480 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1481 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1482
1483 let resources = mgr.discover_resources("auto-pkg").unwrap();
1484
1485 let ext_count = resources
1486 .iter()
1487 .filter(|r| r.kind == ResourceKind::Extension)
1488 .count();
1489 let skill_count = resources
1490 .iter()
1491 .filter(|r| r.kind == ResourceKind::Skill)
1492 .count();
1493 let prompt_count = resources
1494 .iter()
1495 .filter(|r| r.kind == ResourceKind::Prompt)
1496 .count();
1497 let theme_count = resources
1498 .iter()
1499 .filter(|r| r.kind == ResourceKind::Theme)
1500 .count();
1501
1502 assert!(
1503 ext_count >= 1,
1504 "Expected at least 1 extension, got {}",
1505 ext_count
1506 );
1507 assert!(
1508 skill_count >= 1,
1509 "Expected at least 1 skill, got {}",
1510 skill_count
1511 );
1512 assert!(
1513 prompt_count >= 1,
1514 "Expected at least 1 prompt, got {}",
1515 prompt_count
1516 );
1517 assert!(
1518 theme_count >= 1,
1519 "Expected at least 1 theme, got {}",
1520 theme_count
1521 );
1522 }
1523
1524 #[test]
1525 fn test_resource_counts() {
1526 let (tmp, packages_dir) = setup_temp_packages_dir();
1527
1528 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
1529 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1530 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1531
1532 let counts = mgr.resource_counts("test-pkg").unwrap();
1533 assert_eq!(counts.extensions, 1);
1534 assert_eq!(counts.skills, 1);
1535 assert_eq!(counts.prompts, 0);
1536 assert_eq!(counts.themes, 0);
1537 }
1538
1539 #[test]
1540 fn test_resource_counts_display() {
1541 let counts = ResourceCounts {
1542 extensions: 2,
1543 skills: 1,
1544 prompts: 0,
1545 themes: 3,
1546 };
1547 assert_eq!(counts.to_string(), "2 ext, 1 skill, 3 theme");
1548
1549 let empty = ResourceCounts::default();
1550 assert_eq!(empty.to_string(), "-");
1551 }
1552
1553 #[test]
1554 fn test_resource_kind_display() {
1555 assert_eq!(ResourceKind::Extension.to_string(), "extension");
1556 assert_eq!(ResourceKind::Skill.to_string(), "skill");
1557 assert_eq!(ResourceKind::Prompt.to_string(), "prompt");
1558 assert_eq!(ResourceKind::Theme.to_string(), "theme");
1559 }
1560
1561 #[test]
1562 fn test_get_install_dir() {
1563 let (tmp, packages_dir) = setup_temp_packages_dir();
1564
1565 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
1566 let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
1567 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1568
1569 let dir = mgr.get_install_dir("test-pkg").unwrap();
1570 assert!(dir.exists());
1571 assert!(dir.join(MANIFEST_NAME).exists());
1572
1573 assert!(mgr.get_install_dir("nonexistent").is_none());
1574 }
1575
1576 #[test]
1577 fn test_discover_resources_not_installed() {
1578 let (_tmp, packages_dir) = setup_temp_packages_dir();
1579 let mgr = PackageManager::with_dir(packages_dir).unwrap();
1580
1581 let result = mgr.discover_resources("nonexistent");
1582 assert!(result.is_err());
1583 }
1584
1585 #[test]
1586 fn test_update_not_installed() {
1587 let (_tmp, packages_dir) = setup_temp_packages_dir();
1588 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1589
1590 let result = mgr.update("nonexistent");
1591 assert!(result.is_err());
1592 }
1593
1594 #[test]
1597 fn test_parse_npm_source() {
1598 let parsed = ParsedSource::parse("npm:express@4.18.0");
1599 match parsed {
1600 ParsedSource::Npm { spec, name, pinned } => {
1601 assert_eq!(spec, "express@4.18.0");
1602 assert_eq!(name, "express");
1603 assert!(pinned);
1604 }
1605 _ => panic!("Expected Npm source"),
1606 }
1607
1608 let parsed = ParsedSource::parse("npm:lodash");
1609 match parsed {
1610 ParsedSource::Npm { name, pinned, .. } => {
1611 assert_eq!(name, "lodash");
1612 assert!(!pinned);
1613 }
1614 _ => panic!("Expected Npm source"),
1615 }
1616 }
1617
1618 #[test]
1619 fn test_parse_git_source() {
1620 let parsed = ParsedSource::parse("https://github.com/org/repo.git");
1621 match parsed {
1622 ParsedSource::Git {
1623 host, path, ref_, ..
1624 } => {
1625 assert_eq!(host, "github.com");
1626 assert_eq!(path, "org/repo");
1627 assert!(ref_.is_none());
1628 }
1629 _ => panic!("Expected Git source"),
1630 }
1631
1632 let parsed = ParsedSource::parse("https://github.com/org/repo.git@v1.0.0");
1633 match parsed {
1634 ParsedSource::Git { path, ref_, .. } => {
1635 assert_eq!(path, "org/repo");
1636 assert_eq!(ref_.as_deref(), Some("v1.0.0"));
1637 }
1638 _ => panic!("Expected Git source"),
1639 }
1640 }
1641
1642 #[test]
1643 fn test_parse_github_shorthand() {
1644 let parsed = ParsedSource::parse("github:org/repo@main");
1645 match parsed {
1646 ParsedSource::Git {
1647 host, path, ref_, ..
1648 } => {
1649 assert_eq!(host, "github.com");
1650 assert_eq!(path, "org/repo");
1651 assert_eq!(ref_.as_deref(), Some("main"));
1652 }
1653 _ => panic!("Expected Git source"),
1654 }
1655 }
1656
1657 #[test]
1658 fn test_parse_local_source() {
1659 let parsed = ParsedSource::parse("/path/to/package");
1660 match parsed {
1661 ParsedSource::Local { path } => {
1662 assert_eq!(path, "/path/to/package");
1663 }
1664 _ => panic!("Expected Local source"),
1665 }
1666
1667 let parsed = ParsedSource::parse("./relative/path");
1668 match parsed {
1669 ParsedSource::Local { path } => {
1670 assert_eq!(path, "./relative/path");
1671 }
1672 _ => panic!("Expected Local source"),
1673 }
1674 }
1675
1676 #[test]
1677 fn test_parse_url_source() {
1678 let parsed = ParsedSource::parse("https://example.com/pkg.tar.gz");
1679 match parsed {
1680 ParsedSource::Url { url } => {
1681 assert_eq!(url, "https://example.com/pkg.tar.gz");
1682 }
1683 _ => panic!("Expected Url source"),
1684 }
1685 }
1686
1687 #[test]
1688 fn test_source_identity() {
1689 let npm = ParsedSource::parse("npm:express@4.18.0");
1690 assert_eq!(npm.identity(), "npm:express");
1691
1692 let git = ParsedSource::parse("https://github.com/org/repo.git");
1693 assert_eq!(git.identity(), "git:github.com/org/repo");
1694
1695 let local = ParsedSource::parse("/path/to/pkg");
1696 assert_eq!(local.identity(), "local:/path/to/pkg");
1697 }
1698
1699 #[test]
1700 fn test_parse_npm_spec() {
1701 let (name, pinned) = parse_npm_spec("express@4.18.0");
1702 assert_eq!(name, "express");
1703 assert!(pinned);
1704
1705 let (name, pinned) = parse_npm_spec("express");
1706 assert_eq!(name, "express");
1707 assert!(!pinned);
1708
1709 let (name, pinned) = parse_npm_spec("@scope/pkg@1.0.0");
1710 assert_eq!(name, "@scope/pkg");
1711 assert!(pinned);
1712 }
1713
1714 #[test]
1717 fn test_lockfile_roundtrip() {
1718 let (tmp, _) = setup_temp_packages_dir();
1719 let lock_path = tmp.path().join(LOCKFILE_NAME);
1720
1721 let mut lock = Lockfile::new();
1722 lock.insert(LockEntry {
1723 source: "npm:express@4.18.0".to_string(),
1724 name: "express".to_string(),
1725 version: "4.18.0".to_string(),
1726 integrity: Some("sha256-abc123".to_string()),
1727 scope: SourceScope::User,
1728 source_type: "npm".to_string(),
1729 dependencies: BTreeMap::new(),
1730 });
1731
1732 lock.write(&lock_path).unwrap();
1733
1734 let loaded = Lockfile::read(&lock_path).unwrap().unwrap();
1735 assert_eq!(loaded.packages.len(), 1);
1736 assert_eq!(loaded.packages["express"].version, "4.18.0");
1737 assert_eq!(
1738 loaded.packages["express"].integrity.as_deref(),
1739 Some("sha256-abc123")
1740 );
1741 }
1742
1743 #[test]
1744 fn test_lockfile_install_roundtrip() {
1745 let (tmp, packages_dir) = setup_temp_packages_dir();
1746 let pkg_dir = create_test_package(tmp.path(), "locked-pkg", "1.0.0");
1747
1748 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1749 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1750
1751 let lock_path = mgr.packages_dir().join(LOCKFILE_NAME);
1753 assert!(lock_path.exists());
1754
1755 let lock = Lockfile::read(&lock_path).unwrap().unwrap();
1756 assert!(lock.contains("locked-pkg"));
1757 let entry = lock.get("locked-pkg").unwrap();
1758 assert_eq!(entry.version, "1.0.0");
1759 }
1760
1761 #[test]
1764 fn test_validate_valid_package() {
1765 let (tmp, _) = setup_temp_packages_dir();
1766 let pkg_dir = create_test_package(tmp.path(), "valid-pkg", "1.0.0");
1767 let warnings = PackageManager::validate_package(&pkg_dir).unwrap();
1768 assert!(
1770 warnings.len() <= 1,
1771 "Expected <= 1 warning, got {:?}",
1772 warnings
1773 );
1774 }
1775
1776 #[test]
1777 fn test_validate_empty_dir() {
1778 let tmp = tempfile::tempdir().unwrap();
1779 let empty_dir = tmp.path().join("empty-pkg");
1780 fs::create_dir_all(&empty_dir).unwrap();
1781 let warnings = PackageManager::validate_package(&empty_dir).unwrap();
1782 assert!(!warnings.is_empty());
1783 }
1784
1785 #[test]
1788 fn test_resolve_dependencies() {
1789 let (tmp, packages_dir) = setup_temp_packages_dir();
1790
1791 let pkg_dir = tmp.path().join("dep-pkg");
1793 fs::create_dir_all(&pkg_dir).unwrap();
1794 let mut deps = BTreeMap::new();
1795 deps.insert("lodash".to_string(), "^4.0.0".to_string());
1796 deps.insert("nonexistent-pkg".to_string(), "^1.0.0".to_string());
1797
1798 let manifest = PackageManifest {
1799 name: "dep-pkg".to_string(),
1800 version: "1.0.0".to_string(),
1801 extensions: vec![],
1802 skills: vec![],
1803 prompts: vec![],
1804 themes: vec![],
1805 description: None,
1806 dependencies: deps,
1807 };
1808 fs::write(
1809 pkg_dir.join(MANIFEST_NAME),
1810 toml::to_string_pretty(&manifest).unwrap(),
1811 )
1812 .unwrap();
1813
1814 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1815 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1816
1817 let missing = mgr.resolve_dependencies();
1818 assert_eq!(missing.len(), 1);
1819 assert_eq!(missing[0].0, "dep-pkg");
1820 assert!(
1821 missing[0].1.contains(&"lodash".to_string())
1822 || missing[0].1.contains(&"nonexistent-pkg".to_string())
1823 );
1824 }
1825
1826 #[test]
1829 fn test_version_satisfies() {
1830 let (tmp, packages_dir) = setup_temp_packages_dir();
1831 let pkg_dir = create_test_package(tmp.path(), "ver-pkg", "1.2.3");
1832 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1833 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1834
1835 assert!(mgr.version_satisfies("ver-pkg", "^1.0.0"));
1836 assert!(mgr.version_satisfies("ver-pkg", ">=1.0.0"));
1837 assert!(!mgr.version_satisfies("ver-pkg", "^2.0.0"));
1838 assert!(!mgr.version_satisfies("ver-pkg", "<1.0.0"));
1839 }
1840
1841 #[test]
1842 fn test_get_installed_version() {
1843 let (tmp, packages_dir) = setup_temp_packages_dir();
1844 let pkg_dir = create_test_package(tmp.path(), "ver-pkg", "3.1.4");
1845 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1846 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1847
1848 assert_eq!(mgr.get_installed_version("ver-pkg"), Some("3.1.4"));
1849 assert_eq!(mgr.get_installed_version("nonexistent"), None);
1850 }
1851
1852 #[test]
1855 fn test_resolve() {
1856 let (tmp, packages_dir) = setup_temp_packages_dir();
1857 let pkg_dir = create_test_package(tmp.path(), "resolve-pkg", "1.0.0");
1858 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1859 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1860
1861 let resolved = mgr.resolve();
1862 assert!(!resolved.extensions.is_empty() || !resolved.skills.is_empty());
1863 }
1864
1865 #[test]
1868 fn test_progress_callback() {
1869 use std::sync::{Arc, Mutex};
1870
1871 let events: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
1872 let events_clone = events.clone();
1873
1874 let (tmp, packages_dir) = setup_temp_packages_dir();
1875 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1876
1877 mgr.set_progress_callback(Box::new(move |event| {
1878 let mut e = events_clone.lock().unwrap();
1879 e.push(format!("{:?}:{:?}", event.event_type, event.action));
1880 }));
1881
1882 let pkg_dir = create_test_package(tmp.path(), "progress-pkg", "1.0.0");
1883 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1884
1885 let _event_count = events.lock().unwrap().len();
1888 }
1889
1890 #[test]
1891 fn test_list_configured() {
1892 let (tmp, packages_dir) = setup_temp_packages_dir();
1893 let pkg_dir = create_test_package(tmp.path(), "cfg-pkg", "1.0.0");
1894 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
1895 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1896
1897 let configured = mgr.list_configured();
1898 assert_eq!(configured.len(), 1);
1899 assert!(configured[0].source.contains("source-pkg"));
1900 }
1902
1903 #[test]
1908 fn verify_lockfile_integrity_accepts_matching_dir() {
1909 let tmp = tempfile::tempdir().unwrap();
1910 let pkg_dir = tmp.path().join("pkg");
1911 fs::create_dir_all(&pkg_dir).unwrap();
1912 fs::write(pkg_dir.join("a.txt"), b"hello").unwrap();
1913 fs::write(pkg_dir.join("b.txt"), b"world").unwrap();
1914
1915 let expected = _compute_dir_hash(&pkg_dir).expect("compute_dir_hash must succeed");
1916
1917 assert!(_verify_lockfile_integrity(&pkg_dir, &expected).is_ok());
1918 }
1919
1920 #[test]
1925 fn verify_lockfile_integrity_rejects_tampered_dir() {
1926 let tmp = tempfile::tempdir().unwrap();
1927 let pkg_dir = tmp.path().join("pkg");
1928 fs::create_dir_all(&pkg_dir).unwrap();
1929 fs::write(pkg_dir.join("a.txt"), b"hello").unwrap();
1930
1931 let expected = _compute_dir_hash(&pkg_dir).expect("compute_dir_hash must succeed");
1932
1933 fs::write(pkg_dir.join("a.txt"), b"tampered").unwrap();
1935
1936 let err = _verify_lockfile_integrity(&pkg_dir, &expected)
1937 .expect_err("tampered dir must not verify");
1938 assert!(err.contains("sha256 mismatch"), "unexpected error: {err}");
1939 }
1940
1941 #[test]
1943 fn verify_lockfile_integrity_rejects_bad_prefix() {
1944 let tmp = tempfile::tempdir().unwrap();
1945 let pkg_dir = tmp.path().join("pkg");
1946 fs::create_dir_all(&pkg_dir).unwrap();
1947
1948 let err = _verify_lockfile_integrity(&pkg_dir, "abc123")
1949 .expect_err("missing sha256- prefix must be rejected");
1950 assert!(err.contains("not in `sha256-<hex>` form"));
1951 }
1952
1953 #[test]
1956 fn load_installed_skips_tampered_package() {
1957 let (tmp, packages_dir) = setup_temp_packages_dir();
1958 let pkg_dir = create_test_package(tmp.path(), "tamper-pkg", "1.0.0");
1959
1960 {
1962 let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
1963 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
1964 }
1965
1966 let installed_name = "tamper-pkg";
1968 let installed_safe = installed_name.replace('@', "").replace('/', "-");
1969 let on_disk = packages_dir.join(installed_safe);
1970 fs::write(on_disk.join(MANIFEST_NAME), "tampered = true\n").unwrap();
1971
1972 let mgr2 = PackageManager::with_dir(packages_dir).unwrap();
1974 let names: Vec<&str> = mgr2.list().iter().map(|m| m.name.as_str()).collect();
1975 assert!(
1976 !names.contains(&"tamper-pkg"),
1977 "tampered package must be excluded from load_installed; loaded names: {names:?}"
1978 );
1979 }
1980}