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