1use crate::util::http_client::shared_http_client;
42use anyhow::{bail, Context, Result};
43use serde::{Deserialize, Serialize};
44use sha2::{Digest, Sha256};
45use std::collections::{BTreeMap, HashMap, HashSet};
46use std::fs;
47use std::path::{Path, PathBuf};
48use std::sync::LazyLock;
49
50static NPM_SPEC_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
52 regex::Regex::new(r"^(@?[^@]+(?:/[^@]+)?)(?:@(.+))?$").expect("valid static regex")
53});
54
55const LOCKFILE_NAME: &str = "oxi-lock.json";
58const MANIFEST_NAME: &str = "oxi-package.toml";
59const NPM_MANIFEST_NAME: &str = "package.json";
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
65#[serde(rename_all = "snake_case")]
66pub enum ResourceKind {
67 Extension,
69 Skill,
71 Prompt,
73 Theme,
75}
76
77impl std::fmt::Display for ResourceKind {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 match self {
80 ResourceKind::Extension => write!(f, "extension"),
81 ResourceKind::Skill => write!(f, "skill"),
82 ResourceKind::Prompt => write!(f, "prompt"),
83 ResourceKind::Theme => write!(f, "theme"),
84 }
85 }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct PackageManifest {
93 pub name: String,
95 pub version: String,
97 #[serde(default)]
99 pub extensions: Vec<String>,
100 #[serde(default)]
102 pub skills: Vec<String>,
103 #[serde(default)]
105 pub prompts: Vec<String>,
106 #[serde(default)]
108 pub themes: Vec<String>,
109 #[serde(default)]
111 pub description: Option<String>,
112 #[serde(default)]
114 pub dependencies: BTreeMap<String, String>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct DiscoveredResource {
120 pub kind: ResourceKind,
122 pub path: PathBuf,
124 pub relative_path: String,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct PathMetadata {
131 pub source: String,
133 pub scope: SourceScope,
135 pub origin: ResourceOrigin,
137 pub base_dir: Option<PathBuf>,
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "snake_case")]
144pub enum ResourceOrigin {
145 Package,
147 TopLevel,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
153#[serde(rename_all = "snake_case")]
154pub enum SourceScope {
155 User,
157 Project,
159}
160
161impl std::fmt::Display for SourceScope {
162 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163 match self {
164 SourceScope::User => write!(f, "user"),
165 SourceScope::Project => write!(f, "project"),
166 }
167 }
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct ResolvedResource {
173 pub path: PathBuf,
175 pub enabled: bool,
177 pub metadata: PathMetadata,
179}
180
181#[derive(Debug, Clone, Default, Serialize, Deserialize)]
183pub struct ResolvedPaths {
184 pub extensions: Vec<ResolvedResource>,
186 pub skills: Vec<ResolvedResource>,
188 pub prompts: Vec<ResolvedResource>,
190 pub themes: Vec<ResolvedResource>,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct ProgressEvent {
197 pub event_type: ProgressEventType,
199 pub action: ProgressAction,
201 pub source: String,
203 pub message: Option<String>,
205}
206
207#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
209#[serde(rename_all = "snake_case")]
210pub enum ProgressEventType {
211 Start,
213 Progress,
215 Complete,
217 Error,
219}
220
221#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum ProgressAction {
225 Install,
227 Remove,
229 Update,
231 Clone,
233 Pull,
235}
236
237impl std::fmt::Display for ProgressAction {
238 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 match self {
240 ProgressAction::Install => write!(f, "install"),
241 ProgressAction::Remove => write!(f, "remove"),
242 ProgressAction::Update => write!(f, "update"),
243 ProgressAction::Clone => write!(f, "clone"),
244 ProgressAction::Pull => write!(f, "pull"),
245 }
246 }
247}
248
249pub type ProgressCallback = Box<dyn Fn(ProgressEvent) + Send + Sync>;
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
256#[serde(tag = "type", rename_all = "snake_case")]
257pub enum ParsedSource {
258 Npm {
260 spec: String,
262 name: String,
264 pinned: bool,
266 },
267 Git {
269 repo: String,
271 host: String,
273 path: String,
275 ref_: Option<String>,
277 },
278 Local {
280 path: String,
282 },
283 Url {
285 url: String,
287 },
288}
289
290impl ParsedSource {
291 pub fn parse(source: &str) -> Self {
293 if let Some(rest) = source.strip_prefix("npm:") {
294 let spec = rest.trim();
295 let (name, pinned) = parse_npm_spec(spec);
296 return ParsedSource::Npm {
297 spec: spec.to_string(),
298 name,
299 pinned,
300 };
301 }
302
303 if let Some(rest) = source.strip_prefix("github:") {
304 let parts: Vec<&str> = rest.splitn(2, '/').collect();
305 if parts.len() == 2 {
306 let (path, ref_) = split_git_path_ref(rest);
307 return ParsedSource::Git {
308 repo: format!("https://github.com/{}.git", path),
309 host: "github.com".to_string(),
310 path,
311 ref_,
312 };
313 }
314 }
315
316 if source.starts_with("git+") || source.starts_with("git://") || source.starts_with("git@")
317 {
318 return parse_git_source(source);
319 }
320
321 if source.starts_with("https://") || source.starts_with("http://") {
322 if source.ends_with(".git")
324 || source.contains("github.com")
325 || source.contains("gitlab.com")
326 {
327 return parse_git_source(source);
328 }
329 if source.ends_with(".tar.gz")
331 || source.ends_with(".tgz")
332 || source.ends_with(".zip")
333 || source.ends_with(".tar.bz2")
334 {
335 return ParsedSource::Url {
336 url: source.to_string(),
337 };
338 }
339 return parse_git_source(source);
341 }
342
343 ParsedSource::Local {
345 path: source.to_string(),
346 }
347 }
348
349 pub fn identity(&self) -> String {
351 match self {
352 ParsedSource::Npm { name, .. } => format!("npm:{}", name),
353 ParsedSource::Git { host, path, .. } => format!("git:{}/{}", host, path),
354 ParsedSource::Local { path } => format!("local:{}", path),
355 ParsedSource::Url { url } => format!("url:{}", url),
356 }
357 }
358
359 pub fn display_name(&self) -> String {
361 match self {
362 ParsedSource::Npm { name, .. } => name.clone(),
363 ParsedSource::Git { host, path, .. } => format!("{}/{}", host, path),
364 ParsedSource::Local { path } => path.clone(),
365 ParsedSource::Url { url } => url.clone(),
366 }
367 }
368}
369
370fn parse_npm_spec(spec: &str) -> (String, bool) {
372 if let Some(caps) = NPM_SPEC_RE.captures(spec) {
374 let name = caps.get(1).map(|m| m.as_str()).unwrap_or(spec);
375 let has_version = caps.get(2).is_some();
376 return (name.to_string(), has_version);
377 }
378 (spec.to_string(), false)
379}
380
381fn split_git_path_ref(input: &str) -> (String, Option<String>) {
383 if let Some(at_pos) = input.rfind('@') {
384 if input[..at_pos].contains('/') {
386 return (
387 input[..at_pos].to_string(),
388 Some(input[at_pos + 1..].to_string()),
389 );
390 }
391 }
392 (input.to_string(), None)
393}
394
395fn parse_git_source(source: &str) -> ParsedSource {
397 if let Some(rest) = source.strip_prefix("git@") {
399 let colon_pos = rest.find(':').unwrap_or(rest.len());
400 let host = &rest[..colon_pos];
401 let path_part = rest.get(colon_pos + 1..).unwrap_or("");
402 let (path, ref_) = if let Some(hash_pos) = path_part.rfind('#') {
403 (
404 path_part[..hash_pos].to_string(),
405 Some(path_part[hash_pos + 1..].to_string()),
406 )
407 } else {
408 split_git_path_ref(path_part)
409 };
410 let repo = format!("git@{}:{}", host, path_part);
411 let host = host.to_string();
412 return ParsedSource::Git {
413 repo,
414 host,
415 path: path.trim_end_matches(".git").to_string(),
416 ref_,
417 };
418 }
419
420 let url_str = source
422 .strip_prefix("git+")
423 .unwrap_or(source)
424 .strip_prefix("git://")
425 .map(|s| format!("https://{}", s))
426 .unwrap_or_else(|| source.strip_prefix("git+").unwrap_or(source).to_string());
427
428 let url = match url::Url::parse(&url_str) {
430 Ok(u) => u,
431 Err(_) => {
432 return ParsedSource::Local {
433 path: source.to_string(),
434 }
435 }
436 };
437
438 let host = url.host_str().unwrap_or("unknown").to_string();
439 let full_path = url.path().trim_start_matches('/').to_string();
440
441 let fragment = url.fragment().map(|f| f.to_string());
443
444 let (path, ref_) = if let Some(frag) = fragment {
445 (full_path.trim_end_matches(".git").to_string(), Some(frag))
446 } else {
447 let (p, r) = split_git_path_ref(&full_path);
448 (p.trim_end_matches(".git").to_string(), r)
449 };
450
451 let repo = url_str.clone();
452
453 ParsedSource::Git {
454 repo,
455 host,
456 path,
457 ref_,
458 }
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize)]
465pub struct NpmPackageInfo {
466 pub name: String,
468 pub versions: BTreeMap<String, serde_json::Value>,
470 #[serde(rename = "dist-tags")]
472 pub dist_tags: BTreeMap<String, String>,
473}
474
475impl NpmPackageInfo {
476 pub async fn fetch(name: &str) -> Result<Self> {
478 let url = format!("https://registry.npmjs.org/{}", name);
479 let client = shared_http_client();
480
481 let resp = client
482 .get(&url)
483 .header("Accept", "application/json")
484 .send()
485 .await
486 .with_context(|| format!("Failed to fetch npm info for '{}'", name))?;
487
488 if !resp.status().is_success() {
489 bail!("npm registry returned {} for '{}'", resp.status(), name);
490 }
491
492 let info: NpmPackageInfo = resp
493 .json()
494 .await
495 .with_context(|| format!("Failed to parse npm registry response for '{}'", name))?;
496
497 Ok(info)
498 }
499
500 pub fn latest_version(&self) -> Option<&str> {
502 self.dist_tags.get("latest").map(|s| s.as_str())
503 }
504
505 pub fn resolve_version(&self, constraint: &str) -> Option<String> {
507 if constraint == "latest" || constraint.is_empty() {
508 return self.latest_version().map(|s| s.to_string());
509 }
510
511 if self.versions.contains_key(constraint) {
513 return Some(constraint.to_string());
514 }
515
516 if let Ok(req) = semver::VersionReq::parse(constraint) {
518 let mut best: Option<semver::Version> = None;
519 for ver_str in self.versions.keys() {
520 if let Ok(ver) = semver::Version::parse(ver_str) {
521 if req.matches(&ver) {
522 match &best {
523 Some(b) if ver > *b => best = Some(ver),
524 None => best = Some(ver),
525 _ => {}
526 }
527 }
528 }
529 }
530 if let Some(v) = best {
531 return Some(v.to_string());
532 }
533 }
534
535 None
536 }
537}
538
539pub async fn get_latest_npm_version(name: &str) -> Result<String> {
541 let info = NpmPackageInfo::fetch(name).await?;
542 info.latest_version()
543 .map(|s| s.to_string())
544 .context(format!("No latest version found for '{}'", name))
545}
546
547fn git_command(args: &[&str], cwd: Option<&Path>) -> Result<String> {
551 let mut cmd = std::process::Command::new("git");
552 cmd.args(args)
553 .env("GIT_TERMINAL_PROMPT", "0")
554 .stdout(std::process::Stdio::piped())
555 .stderr(std::process::Stdio::piped());
556
557 if let Some(dir) = cwd {
558 cmd.current_dir(dir);
559 }
560
561 let output = cmd.output().context("Failed to execute git")?;
562
563 if !output.status.success() {
564 let stderr = String::from_utf8_lossy(&output.stderr);
565 bail!(
566 "git {} failed ({}): {}",
567 args.join(" "),
568 output.status,
569 stderr.trim()
570 );
571 }
572
573 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
574}
575
576fn git_command_silent(args: &[&str], cwd: Option<&Path>) -> Result<()> {
578 let mut cmd = std::process::Command::new("git");
579 cmd.args(args)
580 .env("GIT_TERMINAL_PROMPT", "0")
581 .stdout(std::process::Stdio::null())
582 .stderr(std::process::Stdio::null());
583
584 if let Some(dir) = cwd {
585 cmd.current_dir(dir);
586 }
587
588 let status = cmd.status().context("Failed to execute git")?;
589 if !status.success() {
590 bail!("git {} failed ({})", args.join(" "), status);
591 }
592 Ok(())
593}
594
595pub fn git_clone(repo_url: &str, target_dir: &Path, ref_: Option<&str>) -> Result<()> {
597 if target_dir.exists() {
598 bail!("Target directory already exists: {}", target_dir.display());
599 }
600 fs::create_dir_all(target_dir)
601 .with_context(|| format!("Failed to create {}", target_dir.display()))?;
602
603 let target_str = target_dir.to_string_lossy().to_string();
604 let args = vec!["clone", repo_url, &target_str];
605
606 git_command_silent(&args, None)?;
607
608 if let Some(r) = ref_ {
609 git_command_silent(&["checkout", r], Some(target_dir))?;
610 }
611
612 Ok(())
613}
614
615pub fn git_update(repo_dir: &Path, ref_: Option<&str>) -> Result<bool> {
617 if !repo_dir.exists() {
618 bail!(
619 "Repository directory does not exist: {}",
620 repo_dir.display()
621 );
622 }
623
624 let local_head = git_command(&["rev-parse", "HEAD"], Some(repo_dir))?;
626
627 let fetch_ref = if let Some(r) = ref_ {
629 r.to_string()
630 } else {
631 match git_command(
633 &["rev-parse", "--abbrev-ref", "@{upstream}"],
634 Some(repo_dir),
635 ) {
636 Ok(upstream) => {
637 if let Some(branch) = upstream.strip_prefix("origin/") {
638 format!("+refs/heads/{branch}:refs/remotes/origin/{branch}")
639 } else {
640 "+HEAD:refs/remotes/origin/HEAD".to_string()
641 }
642 }
643 Err(_) => "+HEAD:refs/remotes/origin/HEAD".to_string(),
644 }
645 };
646
647 git_command_silent(
648 &["fetch", "--prune", "--no-tags", "origin", &fetch_ref],
649 Some(repo_dir),
650 )?;
651
652 let target_ref = ref_.unwrap_or("origin/HEAD");
654 let remote_head = git_command(&["rev-parse", target_ref], Some(repo_dir))?;
655
656 if local_head == remote_head {
657 return Ok(false); }
659
660 git_command_silent(&["reset", "--hard", target_ref], Some(repo_dir))?;
661 git_command_silent(&["clean", "-fdx"], Some(repo_dir))?;
662
663 Ok(true) }
665
666pub fn git_has_update(repo_dir: &Path) -> Result<bool> {
668 let local_head = git_command(&["rev-parse", "HEAD"], Some(repo_dir))?;
669
670 let upstream_ref = match git_command(
672 &["rev-parse", "--abbrev-ref", "@{upstream}"],
673 Some(repo_dir),
674 ) {
675 Ok(u) if u.starts_with("origin/") => {
676 let branch = &u["origin/".len()..];
677 format!("refs/heads/{branch}")
678 }
679 _ => "HEAD".to_string(),
680 };
681
682 let _ = git_command_silent(&["fetch", "--prune", "--no-tags", "origin"], Some(repo_dir));
684
685 let remote_head = git_command(&["ls-remote", "origin", &upstream_ref], None)?;
686
687 let remote_hash = remote_head
689 .lines()
690 .next()
691 .and_then(|line| line.split_whitespace().next())
692 .unwrap_or("");
693
694 Ok(local_head != remote_hash)
695}
696
697#[derive(Debug, Clone, Serialize, Deserialize)]
701pub struct LockEntry {
702 pub source: String,
704 pub name: String,
706 pub version: String,
708 pub integrity: Option<String>,
710 pub scope: SourceScope,
712 pub source_type: String,
714 #[serde(default)]
716 pub dependencies: BTreeMap<String, String>,
717}
718
719#[derive(Debug, Clone, Serialize, Deserialize)]
721pub struct Lockfile {
722 pub version: u32,
724 pub packages: BTreeMap<String, LockEntry>,
726}
727
728impl Lockfile {
729 pub fn new() -> Self {
731 Self {
732 version: 1,
733 packages: BTreeMap::new(),
734 }
735 }
736
737 pub fn read(path: &Path) -> Result<Option<Self>> {
739 if !path.exists() {
740 return Ok(None);
741 }
742 let content = fs::read_to_string(path)
743 .with_context(|| format!("Failed to read lockfile {}", path.display()))?;
744 let lock: Lockfile = serde_json::from_str(&content)
745 .with_context(|| format!("Failed to parse lockfile {}", path.display()))?;
746 Ok(Some(lock))
747 }
748
749 pub fn write(&self, path: &Path) -> Result<()> {
751 let content = serde_json::to_string_pretty(self).context("Failed to serialize lockfile")?;
752 fs::write(path, content)
753 .with_context(|| format!("Failed to write lockfile {}", path.display()))?;
754 Ok(())
755 }
756
757 pub fn insert(&mut self, entry: LockEntry) {
759 self.packages.insert(entry.name.clone(), entry);
760 }
761
762 pub fn remove(&mut self, name: &str) -> Option<LockEntry> {
764 self.packages.remove(name)
765 }
766
767 pub fn contains(&self, name: &str) -> bool {
769 self.packages.contains_key(name)
770 }
771
772 pub fn get(&self, name: &str) -> Option<&LockEntry> {
774 self.packages.get(name)
775 }
776}
777
778impl Default for Lockfile {
779 fn default() -> Self {
780 Self::new()
781 }
782}
783
784#[derive(Debug, Clone, Default, Serialize, Deserialize)]
788pub struct ResourceCounts {
789 pub extensions: usize,
791 pub skills: usize,
793 pub prompts: usize,
795 pub themes: usize,
797}
798
799impl std::fmt::Display for ResourceCounts {
800 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
801 let mut parts = Vec::new();
802 if self.extensions > 0 {
803 parts.push(format!("{} ext", self.extensions));
804 }
805 if self.skills > 0 {
806 parts.push(format!("{} skill", self.skills));
807 }
808 if self.prompts > 0 {
809 parts.push(format!("{} prompt", self.prompts));
810 }
811 if self.themes > 0 {
812 parts.push(format!("{} theme", self.themes));
813 }
814 if parts.is_empty() {
815 write!(f, "-")?;
816 } else {
817 write!(f, "{}", parts.join(", "))?;
818 }
819 Ok(())
820 }
821}
822
823#[derive(Debug, Clone, Serialize, Deserialize)]
827pub struct PackageUpdateInfo {
828 pub source: String,
830 pub display_name: String,
832 pub source_type: String, pub scope: SourceScope,
836}
837
838#[derive(Debug, Clone, Serialize, Deserialize)]
840pub struct ConfiguredPackage {
841 pub source: String,
843 pub scope: SourceScope,
845 pub filtered: bool,
847 pub installed_path: Option<PathBuf>,
849}
850
851pub struct PackageManager {
853 packages_dir: PathBuf,
854 project_dir: PathBuf,
856 installed: HashMap<String, PackageManifest>,
857 lockfile: Lockfile,
858 progress_callback: Option<Box<dyn Fn(ProgressEvent) + Send + Sync>>,
859}
860
861impl PackageManager {
862 pub fn new() -> Result<Self> {
864 let base = dirs::home_dir().context("Cannot determine home directory")?;
865 let packages_dir = base.join(".oxi").join("packages");
866 let project_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
867 let mut mgr = Self {
868 packages_dir,
869 project_dir,
870 installed: HashMap::new(),
871 lockfile: Lockfile::new(),
872 progress_callback: None,
873 };
874 mgr.load_installed()?;
875 mgr.load_lockfile()?;
876 Ok(mgr)
877 }
878
879 pub fn with_dir(packages_dir: PathBuf) -> Result<Self> {
881 let project_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
882 let mut mgr = Self {
883 packages_dir,
884 project_dir,
885 installed: HashMap::new(),
886 lockfile: Lockfile::new(),
887 progress_callback: None,
888 };
889 mgr.load_installed()?;
890 mgr.load_lockfile()?;
891 Ok(mgr)
892 }
893
894 pub fn set_project_dir(&mut self, dir: PathBuf) {
896 self.project_dir = dir;
897 }
898
899 pub fn set_progress_callback(&mut self, callback: Box<dyn Fn(ProgressEvent) + Send + Sync>) {
901 self.progress_callback = Some(callback);
902 }
903
904 fn emit_progress(&self, event: ProgressEvent) {
905 if let Some(ref cb) = self.progress_callback {
906 cb(event);
907 }
908 }
909
910 fn load_installed(&mut self) -> Result<()> {
914 if !self.packages_dir.exists() {
915 return Ok(());
916 }
917 for entry in fs::read_dir(&self.packages_dir)? {
918 let entry = entry?;
919 let manifest_path = entry.path().join(MANIFEST_NAME);
920 if manifest_path.exists() {
921 match Self::read_manifest(&manifest_path) {
922 Ok(manifest) => {
923 self.installed.insert(manifest.name.clone(), manifest);
924 }
925 Err(e) => {
926 tracing::warn!(
927 "Failed to load manifest {}: {}",
928 manifest_path.display(),
929 e
930 );
931 }
932 }
933 }
934 }
935 Ok(())
936 }
937
938 fn load_lockfile(&mut self) -> Result<()> {
940 let lock_path = self.packages_dir.join(LOCKFILE_NAME);
941 if let Some(lock) = Lockfile::read(&lock_path)? {
942 self.lockfile = lock;
943 }
944 Ok(())
945 }
946
947 fn save_lockfile(&self) -> Result<()> {
949 let lock_path = self.packages_dir.join(LOCKFILE_NAME);
950 self.lockfile.write(&lock_path)
951 }
952
953 fn read_manifest(path: &Path) -> Result<PackageManifest> {
957 let content = fs::read_to_string(path)
958 .with_context(|| format!("Failed to read manifest {}", path.display()))?;
959 let manifest: PackageManifest = toml::from_str(&content)
960 .with_context(|| format!("Failed to parse manifest {}", path.display()))?;
961 Ok(manifest)
962 }
963
964 fn read_package_json(dir: &Path) -> Option<serde_json::Value> {
966 let path = dir.join(NPM_MANIFEST_NAME);
967 let content = fs::read_to_string(path).ok()?;
968 serde_json::from_str(&content).ok()
969 }
970
971 fn pkg_install_dir(&self, name: &str) -> PathBuf {
975 let safe_name = name.replace('@', "").replace('/', "-");
976 self.packages_dir.join(safe_name)
977 }
978
979 pub fn packages_dir(&self) -> &Path {
981 &self.packages_dir
982 }
983
984 fn git_install_path(&self, host: &str, path: &str, scope: SourceScope) -> PathBuf {
986 match scope {
987 SourceScope::Project => self
988 .project_dir
989 .join(".oxi")
990 .join("git")
991 .join(host)
992 .join(path),
993 SourceScope::User => self.packages_dir.join("git").join(host).join(path),
994 }
995 }
996
997 fn npm_install_path(&self, name: &str, scope: SourceScope) -> PathBuf {
999 let safe_name = name.replace('@', "").replace('/', "-");
1000 match scope {
1001 SourceScope::Project => self.project_dir.join(".oxi").join("npm").join(safe_name),
1002 SourceScope::User => self.packages_dir.join("npm").join(safe_name),
1003 }
1004 }
1005
1006 fn ensure_packages_dir(&self) -> Result<()> {
1010 fs::create_dir_all(&self.packages_dir).with_context(|| {
1011 format!(
1012 "Failed to create packages directory {}",
1013 self.packages_dir.display()
1014 )
1015 })
1016 }
1017
1018 pub fn install(&mut self, source: &str) -> Result<PackageManifest> {
1020 let parsed = ParsedSource::parse(source);
1021 match parsed {
1022 ParsedSource::Local { path } => self.install_local(&path),
1023 _ => bail!("Use install_from_source() for non-local packages"),
1024 }
1025 }
1026
1027 fn install_local(&mut self, path: &str) -> Result<PackageManifest> {
1029 let source_path = Path::new(path);
1030 let manifest_path = source_path.join(MANIFEST_NAME);
1031
1032 let manifest = if manifest_path.exists() {
1033 Self::read_manifest(&manifest_path)
1034 .with_context(|| format!("No valid {} found in {}", MANIFEST_NAME, path))?
1035 } else {
1036 let name = source_path
1038 .file_name()
1039 .map(|n| n.to_string_lossy().to_string())
1040 .unwrap_or_else(|| "unknown".to_string());
1041 PackageManifest {
1042 name,
1043 version: "0.0.0".to_string(),
1044 extensions: Vec::new(),
1045 skills: Vec::new(),
1046 prompts: Vec::new(),
1047 themes: Vec::new(),
1048 description: None,
1049 dependencies: BTreeMap::new(),
1050 }
1051 };
1052
1053 let dest = self.pkg_install_dir(&manifest.name);
1054 self.ensure_packages_dir()?;
1055
1056 if dest.exists() {
1057 fs::remove_dir_all(&dest).with_context(|| {
1058 format!("Failed to remove existing package at {}", dest.display())
1059 })?;
1060 }
1061
1062 copy_dir_recursive(source_path, &dest).with_context(|| {
1063 format!("Failed to copy package from {} to {}", path, dest.display())
1064 })?;
1065
1066 let integrity = compute_dir_hash(&dest);
1067
1068 self.lockfile.insert(LockEntry {
1069 source: path.to_string(),
1070 name: manifest.name.clone(),
1071 version: manifest.version.clone(),
1072 integrity,
1073 scope: SourceScope::User,
1074 source_type: "local".to_string(),
1075 dependencies: manifest.dependencies.clone(),
1076 });
1077
1078 self.installed
1079 .insert(manifest.name.clone(), manifest.clone());
1080 let _ = self.save_lockfile();
1081 Ok(manifest)
1082 }
1083
1084 pub fn install_from_source(
1086 &mut self,
1087 source: &str,
1088 scope: SourceScope,
1089 ) -> Result<PackageManifest> {
1090 let parsed = ParsedSource::parse(source);
1091 self.emit_progress(ProgressEvent {
1092 event_type: ProgressEventType::Start,
1093 action: ProgressAction::Install,
1094 source: source.to_string(),
1095 message: Some(format!("Installing {}...", source)),
1096 });
1097 let result = match &parsed {
1098 ParsedSource::Npm { .. } => {
1099 let rt = tokio::runtime::Runtime::new()?;
1100 rt.block_on(self.install_npm_async(source, scope))
1101 }
1102 ParsedSource::Git { repo, ref_, .. } => {
1103 self.install_git_sync(source, repo, ref_.as_deref(), scope)
1104 }
1105 ParsedSource::Local { path } => self.install_local(path),
1106 ParsedSource::Url { url } => {
1107 let rt = tokio::runtime::Runtime::new()?;
1108 rt.block_on(self.install_url(url, scope))
1109 }
1110 };
1111 match &result {
1112 Ok(_) => self.emit_progress(ProgressEvent {
1113 event_type: ProgressEventType::Complete,
1114 action: ProgressAction::Install,
1115 source: source.to_string(),
1116 message: None,
1117 }),
1118 Err(e) => self.emit_progress(ProgressEvent {
1119 event_type: ProgressEventType::Error,
1120 action: ProgressAction::Install,
1121 source: source.to_string(),
1122 message: Some(e.to_string()),
1123 }),
1124 }
1125 result
1126 }
1127
1128 async fn install_npm_async(
1130 &mut self,
1131 source: &str,
1132 scope: SourceScope,
1133 ) -> Result<PackageManifest> {
1134 let parsed = ParsedSource::parse(source);
1135 let (spec, name, pinned) = match &parsed {
1136 ParsedSource::Npm { spec, name, pinned } => (spec.clone(), name.clone(), *pinned),
1137 _ => bail!("Expected npm source"),
1138 };
1139
1140 let _version = if pinned {
1142 let (_, ver) = parse_npm_spec(&spec);
1144 if ver {
1145 spec.rsplit('@').next().unwrap_or("latest").to_string()
1146 } else {
1147 "latest".to_string()
1148 }
1149 } else {
1150 get_latest_npm_version(&name)
1151 .await
1152 .unwrap_or_else(|_| "latest".to_string())
1153 };
1154
1155 self.install_npm_pack(&spec, scope)
1157 }
1158
1159 fn install_npm_pack(&mut self, spec: &str, scope: SourceScope) -> Result<PackageManifest> {
1161 let tmp_dir =
1162 tempfile::tempdir().context("Failed to create temp directory for npm install")?;
1163
1164 let output = std::process::Command::new("npm")
1165 .args(["pack", spec, "--pack-destination"])
1166 .arg(tmp_dir.path())
1167 .current_dir(tmp_dir.path())
1168 .output()
1169 .context("Failed to run npm pack")?;
1170
1171 if !output.status.success() {
1172 let stderr = String::from_utf8_lossy(&output.stderr);
1173 bail!("npm pack failed for '{}': {}", spec, stderr);
1174 }
1175
1176 let tarball = fs::read_dir(tmp_dir.path())?
1178 .filter_map(|e| e.ok())
1179 .find(|e| {
1180 e.path()
1181 .extension()
1182 .map(|ext| ext == "tgz")
1183 .unwrap_or(false)
1184 })
1185 .map(|e| e.path())
1186 .context("No .tgz file found after npm pack")?;
1187
1188 let extract_dir = tmp_dir.path().join("extracted");
1190 fs::create_dir_all(&extract_dir)?;
1191
1192 let tar_status = std::process::Command::new("tar")
1193 .args(["-xzf", &tarball.to_string_lossy(), "-C"])
1194 .arg(&extract_dir)
1195 .output()
1196 .context("Failed to run tar")?;
1197
1198 if !tar_status.status.success() {
1199 let stderr = String::from_utf8_lossy(&tar_status.stderr);
1200 bail!("tar extraction failed: {}", stderr);
1201 }
1202
1203 let pkg_source = extract_dir.join("package");
1205 let source_for_copy = if pkg_source.exists() {
1206 &pkg_source
1207 } else {
1208 extract_dir.as_path()
1210 };
1211
1212 self.ensure_packages_dir()?;
1213
1214 let manifest = if source_for_copy.join(MANIFEST_NAME).exists() {
1216 Self::read_manifest(&source_for_copy.join(MANIFEST_NAME))?
1217 } else if source_for_copy.join(NPM_MANIFEST_NAME).exists() {
1218 let pj = Self::read_package_json(source_for_copy);
1219 let (pkg_name, pkg_version) = pj
1220 .as_ref()
1221 .map(|v| {
1222 (
1223 v.get("name")
1224 .and_then(|n| n.as_str())
1225 .unwrap_or(spec)
1226 .to_string(),
1227 v.get("version")
1228 .and_then(|v| v.as_str())
1229 .unwrap_or("0.0.0")
1230 .to_string(),
1231 )
1232 })
1233 .unwrap_or((spec.to_string(), "0.0.0".to_string()));
1234
1235 PackageManifest {
1236 name: pkg_name,
1237 version: pkg_version,
1238 extensions: Vec::new(),
1239 skills: Vec::new(),
1240 prompts: Vec::new(),
1241 themes: Vec::new(),
1242 description: None,
1243 dependencies: BTreeMap::new(),
1244 }
1245 } else {
1246 PackageManifest {
1247 name: spec.to_string(),
1248 version: "0.0.0".to_string(),
1249 extensions: Vec::new(),
1250 skills: Vec::new(),
1251 prompts: Vec::new(),
1252 themes: Vec::new(),
1253 description: None,
1254 dependencies: BTreeMap::new(),
1255 }
1256 };
1257
1258 let dest = self.pkg_install_dir(&manifest.name);
1259 if dest.exists() {
1260 fs::remove_dir_all(&dest).with_context(|| {
1261 format!("Failed to remove existing package at {}", dest.display())
1262 })?;
1263 }
1264
1265 copy_dir_recursive(source_for_copy, &dest)
1266 .with_context(|| format!("Failed to copy npm package for '{}'", spec))?;
1267
1268 let integrity = compute_dir_hash(&dest);
1269
1270 self.lockfile.insert(LockEntry {
1271 source: format!("npm:{}", spec),
1272 name: manifest.name.clone(),
1273 version: manifest.version.clone(),
1274 integrity,
1275 scope,
1276 source_type: "npm".to_string(),
1277 dependencies: manifest.dependencies.clone(),
1278 });
1279
1280 self.installed
1281 .insert(manifest.name.clone(), manifest.clone());
1282 let _ = self.save_lockfile();
1283 Ok(manifest)
1284 }
1285
1286 fn install_git_sync(
1288 &mut self,
1289 source: &str,
1290 repo: &str,
1291 ref_: Option<&str>,
1292 scope: SourceScope,
1293 ) -> Result<PackageManifest> {
1294 let parsed = ParsedSource::parse(source);
1295 let (host, path) = match &parsed {
1296 ParsedSource::Git { host, path, .. } => (host.clone(), path.clone()),
1297 _ => bail!("Expected git source"),
1298 };
1299
1300 let target_dir = self.git_install_path(&host, &path, scope);
1301
1302 if target_dir.exists() {
1303 return self.load_manifest_from_dir(&target_dir, source, scope);
1305 }
1306
1307 let Some(parent) = target_dir.parent() else {
1308 bail!(
1309 "Invalid install path: no parent directory for {}",
1310 target_dir.display()
1311 );
1312 };
1313 fs::create_dir_all(parent)
1314 .with_context(|| format!("Failed to create parent dir for {}", target_dir.display()))?;
1315
1316 git_clone(repo, &target_dir, ref_)?;
1317
1318 if target_dir.join(NPM_MANIFEST_NAME).exists() {
1320 let _ = std::process::Command::new("npm")
1321 .args(["install", "--omit=dev"])
1322 .current_dir(&target_dir)
1323 .output();
1324 }
1325
1326 self.load_manifest_from_dir(&target_dir, source, scope)
1327 }
1328
1329 fn load_manifest_from_dir(
1331 &mut self,
1332 dir: &Path,
1333 source: &str,
1334 scope: SourceScope,
1335 ) -> Result<PackageManifest> {
1336 let manifest = if dir.join(MANIFEST_NAME).exists() {
1337 Self::read_manifest(&dir.join(MANIFEST_NAME))?
1338 } else {
1339 let name = dir
1340 .file_name()
1341 .map(|n| n.to_string_lossy().to_string())
1342 .unwrap_or_else(|| "unknown".to_string());
1343 PackageManifest {
1344 name,
1345 version: "0.0.0".to_string(),
1346 extensions: Vec::new(),
1347 skills: Vec::new(),
1348 prompts: Vec::new(),
1349 themes: Vec::new(),
1350 description: None,
1351 dependencies: BTreeMap::new(),
1352 }
1353 };
1354
1355 let integrity = compute_dir_hash(dir);
1356
1357 self.lockfile.insert(LockEntry {
1358 source: source.to_string(),
1359 name: manifest.name.clone(),
1360 version: manifest.version.clone(),
1361 integrity,
1362 scope,
1363 source_type: "git".to_string(),
1364 dependencies: manifest.dependencies.clone(),
1365 });
1366
1367 self.installed
1368 .insert(manifest.name.clone(), manifest.clone());
1369 let _ = self.save_lockfile();
1370 Ok(manifest)
1371 }
1372
1373 async fn install_url(&mut self, url: &str, scope: SourceScope) -> Result<PackageManifest> {
1375 let client = shared_http_client();
1376
1377 let resp = client.get(url).send().await?;
1378 if !resp.status().is_success() {
1379 bail!("Failed to download {}: {}", url, resp.status());
1380 }
1381
1382 let bytes = resp.bytes().await?;
1383
1384 let tmp_dir = tempfile::tempdir()?;
1385 let archive_name = url.split('/').next_back().unwrap_or("archive");
1386 let archive_path = tmp_dir.path().join(archive_name);
1387 fs::write(&archive_path, &bytes)?;
1388
1389 let extract_dir = tmp_dir.path().join("extracted");
1390 fs::create_dir_all(&extract_dir)?;
1391
1392 if archive_name.ends_with(".tar.gz") || archive_name.ends_with(".tgz") {
1393 let status = std::process::Command::new("tar")
1394 .args(["-xzf", &archive_path.to_string_lossy(), "-C"])
1395 .arg(&extract_dir)
1396 .output()?;
1397 if !status.status.success() {
1398 bail!("Failed to extract archive");
1399 }
1400 } else if archive_name.ends_with(".zip") {
1401 let status = std::process::Command::new("unzip")
1403 .arg("-o")
1404 .arg(&archive_path)
1405 .arg("-d")
1406 .arg(&extract_dir)
1407 .output()?;
1408 if !status.status.success() {
1409 bail!("Failed to extract zip archive");
1410 }
1411 } else {
1412 bail!("Unsupported archive format: {}", archive_name);
1413 }
1414
1415 let pkg_dir = find_single_subdir(&extract_dir).unwrap_or_else(|| extract_dir.to_path_buf());
1417
1418 self.ensure_packages_dir()?;
1419
1420 let manifest = if pkg_dir.join(MANIFEST_NAME).exists() {
1421 Self::read_manifest(&pkg_dir.join(MANIFEST_NAME))?
1422 } else {
1423 let name = url
1424 .split('/')
1425 .next_back()
1426 .unwrap_or("url-package")
1427 .trim_end_matches(".tar.gz")
1428 .trim_end_matches(".tgz")
1429 .trim_end_matches(".zip")
1430 .to_string();
1431 PackageManifest {
1432 name,
1433 version: "0.0.0".to_string(),
1434 extensions: Vec::new(),
1435 skills: Vec::new(),
1436 prompts: Vec::new(),
1437 themes: Vec::new(),
1438 description: None,
1439 dependencies: BTreeMap::new(),
1440 }
1441 };
1442
1443 let dest = self.pkg_install_dir(&manifest.name);
1444 if dest.exists() {
1445 fs::remove_dir_all(&dest)?;
1446 }
1447
1448 copy_dir_recursive(&pkg_dir, &dest)?;
1449
1450 let integrity = compute_dir_hash(&dest);
1451
1452 self.lockfile.insert(LockEntry {
1453 source: url.to_string(),
1454 name: manifest.name.clone(),
1455 version: manifest.version.clone(),
1456 integrity,
1457 scope,
1458 source_type: "url".to_string(),
1459 dependencies: manifest.dependencies.clone(),
1460 });
1461
1462 self.installed
1463 .insert(manifest.name.clone(), manifest.clone());
1464 let _ = self.save_lockfile();
1465 Ok(manifest)
1466 }
1467
1468 pub fn install_npm(&mut self, name: &str) -> Result<PackageManifest> {
1470 self.install_npm_pack(name, SourceScope::User)
1471 }
1472
1473 pub fn uninstall(&mut self, name: &str) -> Result<()> {
1477 if !self.installed.contains_key(name) {
1478 bail!("Package '{}' is not installed", name);
1479 }
1480
1481 let dest = self.pkg_install_dir(name);
1482 if dest.exists() {
1483 fs::remove_dir_all(&dest).with_context(|| {
1484 format!("Failed to remove package directory {}", dest.display())
1485 })?;
1486 }
1487
1488 let _ = self.lockfile.remove(name);
1491 let _ = self.save_lockfile();
1492
1493 self.installed.remove(name);
1494 Ok(())
1495 }
1496
1497 pub fn uninstall_from_source(&mut self, source: &str, scope: SourceScope) -> Result<()> {
1499 let parsed = ParsedSource::parse(source);
1500 self.emit_progress(ProgressEvent {
1501 event_type: ProgressEventType::Start,
1502 action: ProgressAction::Remove,
1503 source: source.to_string(),
1504 message: Some(format!("Removing {}...", source)),
1505 });
1506 let result = self.do_uninstall_from_source(&parsed, scope);
1507 match &result {
1508 Ok(_) => self.emit_progress(ProgressEvent {
1509 event_type: ProgressEventType::Complete,
1510 action: ProgressAction::Remove,
1511 source: source.to_string(),
1512 message: None,
1513 }),
1514 Err(e) => self.emit_progress(ProgressEvent {
1515 event_type: ProgressEventType::Error,
1516 action: ProgressAction::Remove,
1517 source: source.to_string(),
1518 message: Some(e.to_string()),
1519 }),
1520 }
1521 result
1522 }
1523
1524 fn do_uninstall_from_source(
1525 &mut self,
1526 parsed: &ParsedSource,
1527 scope: SourceScope,
1528 ) -> Result<()> {
1529 match parsed {
1530 ParsedSource::Npm { name, .. } => {
1531 let dest = self.npm_install_path(name, scope);
1532 if dest.exists() {
1533 fs::remove_dir_all(&dest)?;
1534 }
1535 self.installed.remove(name);
1536 self.lockfile.remove(name);
1537 let _ = self.save_lockfile();
1538 Ok(())
1539 }
1540 ParsedSource::Git { host, path, .. } => {
1541 let dest = self.git_install_path(host, path, scope);
1542 if dest.exists() {
1543 fs::remove_dir_all(&dest)?;
1544 prune_empty_parents(&dest, &self.packages_dir);
1545 }
1546 self.installed.retain(|_, m| {
1547 let parsed_m = ParsedSource::parse(m.name.as_str());
1548 parsed_m.identity() != parsed.identity()
1549 });
1550 self.lockfile.packages.retain(|_, entry| {
1551 let parsed_e = ParsedSource::parse(&entry.source);
1552 parsed_e.identity() != parsed.identity()
1553 });
1554 let _ = self.save_lockfile();
1555 Ok(())
1556 }
1557 ParsedSource::Local { .. } => Ok(()),
1558 ParsedSource::Url { .. } => {
1559 let identity = parsed.identity();
1560 self.lockfile
1561 .packages
1562 .retain(|_, e| ParsedSource::parse(&e.source).identity() != identity);
1563 let _ = self.save_lockfile();
1564 Ok(())
1565 }
1566 }
1567 }
1568
1569 pub fn update(&mut self, name: &str) -> Result<PackageManifest> {
1576 let lock_entry = self.lockfile.get(name).cloned();
1577
1578 if let Some(entry) = lock_entry {
1579 let parsed = ParsedSource::parse(&entry.source);
1580 return match &parsed {
1581 ParsedSource::Npm { spec, .. } => {
1582 self.emit_progress(ProgressEvent {
1583 event_type: ProgressEventType::Start,
1584 action: ProgressAction::Update,
1585 source: entry.source.clone(),
1586 message: Some(format!("Updating {}...", name)),
1587 });
1588 let result = self.install_npm_pack(spec, entry.scope);
1589 match &result {
1590 Ok(_) => self.emit_progress(ProgressEvent {
1591 event_type: ProgressEventType::Complete,
1592 action: ProgressAction::Update,
1593 source: entry.source.clone(),
1594 message: None,
1595 }),
1596 Err(e) => self.emit_progress(ProgressEvent {
1597 event_type: ProgressEventType::Error,
1598 action: ProgressAction::Update,
1599 source: entry.source.clone(),
1600 message: Some(e.to_string()),
1601 }),
1602 }
1603 result
1604 }
1605 ParsedSource::Git { repo, ref_, .. } => {
1606 let target_dir = match &parsed {
1607 ParsedSource::Git { host, path, .. } => {
1608 self.git_install_path(host, path, entry.scope)
1609 }
1610 _ => unreachable!(),
1611 };
1612 if target_dir.exists() {
1613 let updated = git_update(&target_dir, ref_.as_deref())?;
1614 if updated && target_dir.join(NPM_MANIFEST_NAME).exists() {
1615 let _ = std::process::Command::new("npm")
1616 .args(["install", "--omit=dev"])
1617 .current_dir(&target_dir)
1618 .output();
1619 }
1620 self.load_manifest_from_dir(&target_dir, &entry.source, entry.scope)
1621 } else {
1622 self.install_git_sync(&entry.source, repo, ref_.as_deref(), entry.scope)
1623 }
1624 }
1625 ParsedSource::Local { path } => self.install_local(path),
1626 ParsedSource::Url { url } => {
1627 let rt = tokio::runtime::Runtime::new()?;
1628 rt.block_on(self.install_url(url, entry.scope))
1629 }
1630 };
1631 }
1632
1633 if self.installed.contains_key(name) {
1635 self.install_npm_pack(name, SourceScope::User)
1636 } else {
1637 bail!("Package '{}' is not installed", name);
1638 }
1639 }
1640
1641 pub fn update_all(&mut self) -> Vec<(String, Result<PackageManifest>)> {
1643 let names: Vec<String> = self.installed.keys().cloned().collect();
1644 let mut results = Vec::new();
1645 for name in names {
1646 let result = self.update(&name);
1647 results.push((name, result));
1648 }
1649 results
1650 }
1651
1652 pub async fn check_for_updates(&self) -> Vec<PackageUpdateInfo> {
1654 let mut updates = Vec::new();
1655
1656 for lock_entry in self.lockfile.packages.values() {
1657 let parsed = ParsedSource::parse(&lock_entry.source);
1658
1659 match &parsed {
1660 ParsedSource::Npm { name: pkg_name, .. } => {
1661 match NpmPackageInfo::fetch(pkg_name).await {
1663 Ok(info) => {
1664 if let Some(latest) = info.latest_version() {
1665 if latest != lock_entry.version {
1666 updates.push(PackageUpdateInfo {
1667 source: lock_entry.source.clone(),
1668 display_name: pkg_name.clone(),
1669 source_type: "npm".to_string(),
1670 scope: lock_entry.scope,
1671 });
1672 }
1673 }
1674 }
1675 Err(_) => continue,
1676 }
1677 }
1678 ParsedSource::Git { host, path, .. } => {
1679 let install_path = self.git_install_path(host, path, lock_entry.scope);
1680 if install_path.exists() {
1681 match git_has_update(&install_path) {
1682 Ok(true) => {
1683 updates.push(PackageUpdateInfo {
1684 source: lock_entry.source.clone(),
1685 display_name: format!("{}/{}", host, path),
1686 source_type: "git".to_string(),
1687 scope: lock_entry.scope,
1688 });
1689 }
1690 _ => continue,
1691 }
1692 }
1693 }
1694 _ => continue,
1695 }
1696 }
1697
1698 updates
1699 }
1700
1701 pub fn list(&self) -> Vec<&PackageManifest> {
1705 self.installed.values().collect()
1706 }
1707
1708 pub fn list_configured(&self) -> Vec<ConfiguredPackage> {
1710 let mut result = Vec::new();
1711 for name in self.installed.keys() {
1712 let installed_path = self.get_install_dir(name);
1713 let lock_entry = self.lockfile.get(name);
1714 result.push(ConfiguredPackage {
1715 source: lock_entry
1716 .map(|e| e.source.clone())
1717 .unwrap_or_else(|| name.clone()),
1718 scope: lock_entry.map(|e| e.scope).unwrap_or(SourceScope::User),
1719 filtered: false,
1720 installed_path,
1721 });
1722 }
1723 result
1724 }
1725
1726 pub fn is_installed(&self, name: &str) -> bool {
1728 self.installed.contains_key(name)
1729 }
1730
1731 pub fn get_install_dir(&self, name: &str) -> Option<PathBuf> {
1733 let dir = self.pkg_install_dir(name);
1734 if dir.exists() {
1735 Some(dir)
1736 } else {
1737 None
1738 }
1739 }
1740
1741 pub fn get_installed_path_for_source(
1743 &self,
1744 source: &str,
1745 scope: SourceScope,
1746 ) -> Option<PathBuf> {
1747 let parsed = ParsedSource::parse(source);
1748 match &parsed {
1749 ParsedSource::Npm { name, .. } => {
1750 let path = self.npm_install_path(name, scope);
1751 if path.exists() {
1752 Some(path)
1753 } else {
1754 None
1755 }
1756 }
1757 ParsedSource::Git { host, path, .. } => {
1758 let path = self.git_install_path(host, path, scope);
1759 if path.exists() {
1760 Some(path)
1761 } else {
1762 None
1763 }
1764 }
1765 ParsedSource::Local { path } => {
1766 let p = PathBuf::from(path);
1767 if p.exists() {
1768 Some(p)
1769 } else {
1770 None
1771 }
1772 }
1773 ParsedSource::Url { .. } => None,
1774 }
1775 }
1776
1777 pub fn discover_resources(&self, name: &str) -> Result<Vec<DiscoveredResource>> {
1781 let manifest = self
1782 .installed
1783 .get(name)
1784 .with_context(|| format!("Package '{}' not found", name))?;
1785
1786 let install_dir = self.pkg_install_dir(name);
1787 if !install_dir.exists() {
1788 bail!("Install directory for '{}' does not exist", name);
1789 }
1790
1791 let mut resources = Vec::new();
1792
1793 let has_explicit = !manifest.extensions.is_empty()
1794 || !manifest.skills.is_empty()
1795 || !manifest.prompts.is_empty()
1796 || !manifest.themes.is_empty();
1797
1798 if has_explicit {
1799 for ext in &manifest.extensions {
1800 let path = install_dir.join(ext);
1801 if path.exists() {
1802 resources.push(DiscoveredResource {
1803 kind: ResourceKind::Extension,
1804 path,
1805 relative_path: ext.clone(),
1806 });
1807 }
1808 }
1809 for skill in &manifest.skills {
1810 let path = install_dir.join(skill);
1811 if path.exists() {
1812 resources.push(DiscoveredResource {
1813 kind: ResourceKind::Skill,
1814 path,
1815 relative_path: skill.clone(),
1816 });
1817 }
1818 }
1819 for prompt in &manifest.prompts {
1820 let path = install_dir.join(prompt);
1821 if path.exists() {
1822 resources.push(DiscoveredResource {
1823 kind: ResourceKind::Prompt,
1824 path,
1825 relative_path: prompt.clone(),
1826 });
1827 }
1828 }
1829 for theme in &manifest.themes {
1830 let path = install_dir.join(theme);
1831 if path.exists() {
1832 resources.push(DiscoveredResource {
1833 kind: ResourceKind::Theme,
1834 path,
1835 relative_path: theme.clone(),
1836 });
1837 }
1838 }
1839 } else {
1840 resources.extend(discover_extensions(&install_dir));
1841 resources.extend(discover_skills(&install_dir));
1842 resources.extend(discover_prompts(&install_dir));
1843 resources.extend(discover_themes(&install_dir));
1844 }
1845
1846 Ok(resources)
1847 }
1848
1849 pub fn resource_counts(&self, name: &str) -> Result<ResourceCounts> {
1851 let resources = self.discover_resources(name)?;
1852 let mut counts = ResourceCounts::default();
1853 for r in &resources {
1854 match r.kind {
1855 ResourceKind::Extension => counts.extensions += 1,
1856 ResourceKind::Skill => counts.skills += 1,
1857 ResourceKind::Prompt => counts.prompts += 1,
1858 ResourceKind::Theme => counts.themes += 1,
1859 }
1860 }
1861 Ok(counts)
1862 }
1863
1864 pub fn resolve(&self) -> ResolvedPaths {
1866 let mut extensions = Vec::new();
1867 let mut skills = Vec::new();
1868 let mut prompts = Vec::new();
1869 let mut themes = Vec::new();
1870
1871 for name in self.installed.keys() {
1872 let install_dir = self.pkg_install_dir(name);
1873 if !install_dir.exists() {
1874 continue;
1875 }
1876
1877 let metadata = PathMetadata {
1878 source: name.clone(),
1879 scope: SourceScope::User,
1880 origin: ResourceOrigin::Package,
1881 base_dir: Some(install_dir.clone()),
1882 };
1883
1884 if let Ok(resources) = self.discover_resources(name) {
1886 for r in resources {
1887 match r.kind {
1888 ResourceKind::Extension => extensions.push(ResolvedResource {
1889 path: r.path,
1890 enabled: true,
1891 metadata: metadata.clone(),
1892 }),
1893 ResourceKind::Skill => skills.push(ResolvedResource {
1894 path: r.path,
1895 enabled: true,
1896 metadata: metadata.clone(),
1897 }),
1898 ResourceKind::Prompt => prompts.push(ResolvedResource {
1899 path: r.path,
1900 enabled: true,
1901 metadata: metadata.clone(),
1902 }),
1903 ResourceKind::Theme => themes.push(ResolvedResource {
1904 path: r.path,
1905 enabled: true,
1906 metadata: metadata.clone(),
1907 }),
1908 }
1909 }
1910 }
1911 }
1912
1913 ResolvedPaths {
1914 extensions,
1915 skills,
1916 prompts,
1917 themes,
1918 }
1919 }
1920
1921 pub fn resolve_dependencies(&self) -> Vec<(String, Vec<String>)> {
1926 let mut result = Vec::new();
1927 let installed_names: HashSet<&str> = self.installed.keys().map(|s| s.as_str()).collect();
1928
1929 for (name, manifest) in &self.installed {
1930 let missing: Vec<String> = manifest
1931 .dependencies
1932 .keys()
1933 .filter(|dep| !installed_names.contains(dep.as_str()))
1934 .cloned()
1935 .collect();
1936
1937 if !missing.is_empty() {
1938 result.push((name.clone(), missing));
1939 }
1940 }
1941
1942 result
1943 }
1944
1945 pub fn validate_package(dir: &Path) -> Result<Vec<String>> {
1947 let mut warnings = Vec::new();
1948
1949 if !dir.join(MANIFEST_NAME).exists() && !dir.join(NPM_MANIFEST_NAME).exists() {
1951 warnings.push(format!(
1952 "No {} or {} found",
1953 MANIFEST_NAME, NPM_MANIFEST_NAME
1954 ));
1955 }
1956
1957 if dir.join(MANIFEST_NAME).exists() {
1959 match Self::read_manifest(&dir.join(MANIFEST_NAME)) {
1960 Ok(m) => {
1961 if m.name.is_empty() {
1962 warnings.push("Package name is empty".to_string());
1963 }
1964 if m.version.is_empty() {
1965 warnings.push("Package version is empty".to_string());
1966 }
1967 if semver::Version::parse(&m.version).is_err() {
1968 warnings.push(format!("Version '{}' is not valid semver", m.version));
1969 }
1970 let has_resources = !m.extensions.is_empty()
1971 || !m.skills.is_empty()
1972 || !m.prompts.is_empty()
1973 || !m.themes.is_empty();
1974 if !has_resources {
1975 let discovered = discover_extensions(dir)
1977 .into_iter()
1978 .chain(discover_skills(dir))
1979 .chain(discover_prompts(dir))
1980 .chain(discover_themes(dir))
1981 .count();
1982 if discovered == 0 {
1983 warnings.push(
1984 "Package has no explicit resources and auto-discovery found nothing"
1985 .to_string(),
1986 );
1987 }
1988 }
1989
1990 for ext in &m.extensions {
1992 if !dir.join(ext).exists() {
1993 warnings.push(format!("Extension path '{}' does not exist", ext));
1994 }
1995 }
1996 for skill in &m.skills {
1997 if !dir.join(skill).exists() {
1998 warnings.push(format!("Skill path '{}' does not exist", skill));
1999 }
2000 }
2001 for prompt in &m.prompts {
2002 if !dir.join(prompt).exists() {
2003 warnings.push(format!("Prompt path '{}' does not exist", prompt));
2004 }
2005 }
2006 for theme in &m.themes {
2007 if !dir.join(theme).exists() {
2008 warnings.push(format!("Theme path '{}' does not exist", theme));
2009 }
2010 }
2011 }
2012 Err(e) => {
2013 warnings.push(format!("Failed to parse {}: {}", MANIFEST_NAME, e));
2014 }
2015 }
2016 }
2017
2018 if !dir.join(".gitignore").exists() && !dir.join(".ignore").exists() {
2020 warnings.push("No .gitignore or .ignore file found".to_string());
2021 }
2022
2023 Ok(warnings)
2024 }
2025
2026 pub fn get_installed_version(&self, name: &str) -> Option<&str> {
2030 self.installed.get(name).map(|m| m.version.as_str())
2031 }
2032
2033 pub fn version_satisfies(&self, name: &str, requirement: &str) -> bool {
2035 if let Some(version) = self.get_installed_version(name) {
2036 if let Ok(v) = semver::Version::parse(version) {
2037 if let Ok(req) = semver::VersionReq::parse(requirement) {
2038 return req.matches(&v);
2039 }
2040 }
2041 }
2042 false
2043 }
2044
2045 pub fn lockfile(&self) -> &Lockfile {
2047 &self.lockfile
2048 }
2049}
2050
2051fn discover_extensions(dir: &Path) -> Vec<DiscoveredResource> {
2055 let mut results = Vec::new();
2056 discover_extensions_recursive(dir, dir, &mut results);
2057 results
2058}
2059
2060fn discover_extensions_recursive(
2061 base: &Path,
2062 current: &Path,
2063 results: &mut Vec<DiscoveredResource>,
2064) {
2065 if !current.exists() {
2066 return;
2067 }
2068
2069 let entries = match fs::read_dir(current) {
2070 Ok(e) => e,
2071 Err(_) => return,
2072 };
2073
2074 for entry in entries.flatten() {
2075 let path = entry.path();
2076 let name = entry.file_name();
2077 let name_str = name.to_string_lossy();
2078
2079 if name_str.starts_with('.') || name_str == "node_modules" {
2080 continue;
2081 }
2082
2083 if path.is_dir() {
2084 for index in &["index.ts", "index.js"] {
2086 let index_path = path.join(index);
2087 if index_path.exists() {
2088 let rel = path.strip_prefix(base).unwrap_or(&path);
2089 results.push(DiscoveredResource {
2090 kind: ResourceKind::Extension,
2091 path: index_path,
2092 relative_path: rel.join(index).to_string_lossy().to_string(),
2093 });
2094 }
2095 }
2096 } else {
2097 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
2098 if matches!(ext, "so" | "dylib" | "dll" | "ts" | "js") {
2099 let rel = path.strip_prefix(base).unwrap_or(&path);
2100 results.push(DiscoveredResource {
2101 kind: ResourceKind::Extension,
2102 path: path.clone(),
2103 relative_path: rel.to_string_lossy().to_string(),
2104 });
2105 }
2106 }
2107 }
2108}
2109
2110fn discover_skills(dir: &Path) -> Vec<DiscoveredResource> {
2112 let mut results = Vec::new();
2113 discover_skills_recursive(dir, dir, &mut results);
2114 results
2115}
2116
2117fn discover_skills_recursive(base: &Path, current: &Path, results: &mut Vec<DiscoveredResource>) {
2118 if !current.exists() {
2119 return;
2120 }
2121
2122 let entries = match fs::read_dir(current) {
2123 Ok(e) => e,
2124 Err(_) => return,
2125 };
2126
2127 for entry in entries.flatten() {
2128 let path = entry.path();
2129 let name = entry.file_name();
2130 let name_str = name.to_string_lossy();
2131
2132 if name_str.starts_with('.') || name_str == "node_modules" {
2133 continue;
2134 }
2135
2136 if path.is_dir() {
2137 let skill_file = path.join("SKILL.md");
2138 if skill_file.exists() {
2139 let rel = path.strip_prefix(base).unwrap_or(&path);
2140 results.push(DiscoveredResource {
2141 kind: ResourceKind::Skill,
2142 path: skill_file,
2143 relative_path: rel.join("SKILL.md").to_string_lossy().to_string(),
2144 });
2145 }
2146 discover_skills_recursive(base, &path, results);
2147 }
2148 }
2149}
2150
2151fn discover_prompts(dir: &Path) -> Vec<DiscoveredResource> {
2153 let prompts_dir = dir.join("prompts");
2154 discover_files_by_ext(
2155 if prompts_dir.exists() {
2156 &prompts_dir
2157 } else {
2158 dir
2159 },
2160 "md",
2161 ResourceKind::Prompt,
2162 )
2163}
2164
2165fn discover_themes(dir: &Path) -> Vec<DiscoveredResource> {
2167 let themes_dir = dir.join("themes");
2168 discover_files_by_ext(
2169 if themes_dir.exists() {
2170 &themes_dir
2171 } else {
2172 dir
2173 },
2174 "json",
2175 ResourceKind::Theme,
2176 )
2177}
2178
2179fn discover_files_by_ext(dir: &Path, ext: &str, kind: ResourceKind) -> Vec<DiscoveredResource> {
2181 let mut results = Vec::new();
2182 discover_files_recursive(dir, dir, ext, kind, &mut results);
2183 results
2184}
2185
2186fn discover_files_recursive(
2187 base: &Path,
2188 current: &Path,
2189 ext: &str,
2190 kind: ResourceKind,
2191 results: &mut Vec<DiscoveredResource>,
2192) {
2193 if !current.exists() {
2194 return;
2195 }
2196
2197 let entries = match fs::read_dir(current) {
2198 Ok(e) => e,
2199 Err(_) => return,
2200 };
2201
2202 for entry in entries.flatten() {
2203 let path = entry.path();
2204 let name = entry.file_name();
2205 let name_str = name.to_string_lossy();
2206
2207 if name_str.starts_with('.') || name_str == "node_modules" {
2208 continue;
2209 }
2210
2211 if path.is_dir() {
2212 discover_files_recursive(base, &path, ext, kind, results);
2213 } else if path.extension().and_then(|e| e.to_str()) == Some(ext) {
2214 let rel = path.strip_prefix(base).unwrap_or(&path);
2215 results.push(DiscoveredResource {
2216 kind,
2217 path: path.clone(),
2218 relative_path: rel.to_string_lossy().to_string(),
2219 });
2220 }
2221 }
2222}
2223
2224fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
2228 if !dst.exists() {
2229 fs::create_dir_all(dst)?;
2230 }
2231
2232 for entry in fs::read_dir(src)? {
2233 let entry = entry?;
2234 let src_path = entry.path();
2235 let dst_path = dst.join(entry.file_name());
2236
2237 if src_path.is_dir() {
2238 copy_dir_recursive(&src_path, &dst_path)?;
2239 } else {
2240 fs::copy(&src_path, &dst_path)?;
2241 }
2242 }
2243
2244 Ok(())
2245}
2246
2247fn compute_dir_hash(dir: &Path) -> Option<String> {
2249 let mut hasher = Sha256::new();
2250 let mut files = collect_file_paths(dir);
2251 files.sort();
2252
2253 for file_path in &files {
2254 if let Ok(content) = fs::read(file_path) {
2255 hasher.update(&content);
2256 }
2257 }
2258
2259 let result = hasher.finalize();
2260 Some(format!("sha256-{:x}", result))
2261}
2262
2263fn collect_file_paths(dir: &Path) -> Vec<PathBuf> {
2265 let mut paths = Vec::new();
2266 if !dir.exists() {
2267 return paths;
2268 }
2269
2270 let entries = match fs::read_dir(dir) {
2271 Ok(e) => e,
2272 Err(_) => return paths,
2273 };
2274
2275 for entry in entries.flatten() {
2276 let path = entry.path();
2277 if path.is_dir() {
2278 paths.extend(collect_file_paths(&path));
2279 } else {
2280 paths.push(path);
2281 }
2282 }
2283
2284 paths
2285}
2286
2287fn find_single_subdir(dir: &Path) -> Option<PathBuf> {
2289 let entries: Vec<_> = fs::read_dir(dir).ok()?.filter_map(|e| e.ok()).collect();
2290 if entries.len() == 1 && entries[0].path().is_dir() {
2291 Some(entries[0].path())
2292 } else {
2293 None
2294 }
2295}
2296
2297fn prune_empty_parents(target: &Path, root: &Path) {
2299 let mut current = target.parent();
2300 while let Some(dir) = current {
2301 if dir == root || !dir.starts_with(root) {
2302 break;
2303 }
2304 if dir.exists() {
2305 let is_empty = fs::read_dir(dir)
2306 .map(|mut rd| rd.next().is_none())
2307 .unwrap_or(false);
2308 if is_empty {
2309 let _ = fs::remove_dir(dir);
2310 } else {
2311 break;
2312 }
2313 }
2314 current = dir.parent();
2315 }
2316}
2317
2318#[cfg(test)]
2319mod tests {
2320 use super::*;
2321
2322 fn setup_temp_packages_dir() -> (tempfile::TempDir, PathBuf) {
2323 let tmp = tempfile::tempdir().unwrap();
2324 let packages_dir = tmp.path().join("packages");
2325 fs::create_dir_all(&packages_dir).unwrap();
2326 (tmp, packages_dir)
2327 }
2328
2329 fn create_test_package(base: &Path, name: &str, version: &str) -> PathBuf {
2330 let pkg_dir = base.join("source-pkg");
2331 fs::create_dir_all(&pkg_dir).unwrap();
2332
2333 let manifest = PackageManifest {
2334 name: name.to_string(),
2335 version: version.to_string(),
2336 extensions: vec!["ext1.so".to_string()],
2337 skills: vec!["skill-a".to_string()],
2338 prompts: vec![],
2339 themes: vec![],
2340 description: None,
2341 dependencies: BTreeMap::new(),
2342 };
2343
2344 let toml_content = toml::to_string_pretty(&manifest).unwrap();
2345 fs::write(pkg_dir.join(MANIFEST_NAME), toml_content).unwrap();
2346 fs::write(pkg_dir.join("ext1.so"), "fake extension").unwrap();
2347 fs::create_dir_all(pkg_dir.join("skill-a")).unwrap();
2348 fs::write(pkg_dir.join("skill-a").join("SKILL.md"), "# Skill A").unwrap();
2349
2350 pkg_dir
2351 }
2352
2353 fn create_test_package_with_auto_discovery(base: &Path, name: &str, version: &str) -> PathBuf {
2354 let pkg_dir = base.join("source-pkg-auto");
2355 fs::create_dir_all(&pkg_dir).unwrap();
2356
2357 let manifest = PackageManifest {
2358 name: name.to_string(),
2359 version: version.to_string(),
2360 extensions: vec![],
2361 skills: vec![],
2362 prompts: vec![],
2363 themes: vec![],
2364 description: None,
2365 dependencies: BTreeMap::new(),
2366 };
2367 let toml_content = toml::to_string_pretty(&manifest).unwrap();
2368 fs::write(pkg_dir.join(MANIFEST_NAME), toml_content).unwrap();
2369
2370 fs::write(pkg_dir.join("myext.so"), "extension").unwrap();
2371 fs::create_dir_all(pkg_dir.join("my-skill")).unwrap();
2372 fs::write(pkg_dir.join("my-skill").join("SKILL.md"), "# My Skill").unwrap();
2373 fs::create_dir_all(pkg_dir.join("prompts")).unwrap();
2374 fs::write(pkg_dir.join("prompts").join("review.md"), "# Review").unwrap();
2375 fs::create_dir_all(pkg_dir.join("themes")).unwrap();
2376 fs::write(pkg_dir.join("themes").join("dark.json"), "{}").unwrap();
2377
2378 pkg_dir
2379 }
2380
2381 #[test]
2382 fn test_install_and_list() {
2383 let (tmp, packages_dir) = setup_temp_packages_dir();
2384
2385 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2386 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2387
2388 let manifest = mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2389 assert_eq!(manifest.name, "test-pkg");
2390 assert_eq!(manifest.version, "1.0.0");
2391
2392 let installed = mgr.list();
2393 assert_eq!(installed.len(), 1);
2394 assert_eq!(installed[0].name, "test-pkg");
2395 }
2396
2397 #[test]
2398 fn test_uninstall() {
2399 let (tmp, packages_dir) = setup_temp_packages_dir();
2400
2401 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2402 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2403
2404 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2405 assert!(mgr.is_installed("test-pkg"));
2406
2407 mgr.uninstall("test-pkg").unwrap();
2408 assert!(!mgr.is_installed("test-pkg"));
2409 assert!(mgr.list().is_empty());
2410 }
2411
2412 #[test]
2413 fn test_uninstall_not_installed() {
2414 let (_tmp, packages_dir) = setup_temp_packages_dir();
2415 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2416
2417 let result = mgr.uninstall("nonexistent");
2418 assert!(result.is_err());
2419 }
2420
2421 #[test]
2422 fn test_install_scoped_package() {
2423 let (tmp, packages_dir) = setup_temp_packages_dir();
2424
2425 let pkg_dir = create_test_package(tmp.path(), "@foo/oxi-tools", "2.0.0");
2426 let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
2427
2428 let manifest = mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2429 assert_eq!(manifest.name, "@foo/oxi-tools");
2430
2431 let expected_dir = packages_dir.join("foo-oxi-tools");
2432 assert!(expected_dir.exists());
2433 }
2434
2435 #[test]
2436 fn test_reinstall_overwrites() {
2437 let (tmp, packages_dir) = setup_temp_packages_dir();
2438
2439 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2440 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2441
2442 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2443
2444 let pkg_dir_v2 = tmp.path().join("source-pkg-v2");
2445 fs::create_dir_all(&pkg_dir_v2).unwrap();
2446 let manifest_v2 = PackageManifest {
2447 name: "test-pkg".to_string(),
2448 version: "2.0.0".to_string(),
2449 extensions: vec![],
2450 skills: vec![],
2451 prompts: vec![],
2452 themes: vec![],
2453 description: None,
2454 dependencies: BTreeMap::new(),
2455 };
2456 fs::write(
2457 pkg_dir_v2.join(MANIFEST_NAME),
2458 toml::to_string_pretty(&manifest_v2).unwrap(),
2459 )
2460 .unwrap();
2461
2462 mgr.install(pkg_dir_v2.to_str().unwrap()).unwrap();
2463
2464 let installed = mgr.list();
2465 assert_eq!(installed.len(), 1);
2466 assert_eq!(installed[0].version, "2.0.0");
2467 }
2468
2469 #[test]
2470 fn test_empty_packages_dir() {
2471 let (_tmp, packages_dir) = setup_temp_packages_dir();
2472 let mgr = PackageManager::with_dir(packages_dir).unwrap();
2473 assert!(mgr.list().is_empty());
2474 }
2475
2476 #[test]
2477 fn test_packages_dir_not_exists() {
2478 let tmp = tempfile::tempdir().unwrap();
2479 let nonexistent = tmp.path().join("does-not-exist");
2480 let mgr = PackageManager::with_dir(nonexistent).unwrap();
2481 assert!(mgr.list().is_empty());
2482 }
2483
2484 #[test]
2485 fn test_discover_resources_explicit() {
2486 let (tmp, packages_dir) = setup_temp_packages_dir();
2487
2488 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2489 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2490 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2491
2492 let resources = mgr.discover_resources("test-pkg").unwrap();
2493 assert_eq!(resources.len(), 2);
2494
2495 let extensions: Vec<_> = resources
2496 .iter()
2497 .filter(|r| r.kind == ResourceKind::Extension)
2498 .collect();
2499 let skills: Vec<_> = resources
2500 .iter()
2501 .filter(|r| r.kind == ResourceKind::Skill)
2502 .collect();
2503 assert_eq!(extensions.len(), 1);
2504 assert_eq!(skills.len(), 1);
2505 }
2506
2507 #[test]
2508 fn test_discover_resources_auto() {
2509 let (tmp, packages_dir) = setup_temp_packages_dir();
2510
2511 let pkg_dir = create_test_package_with_auto_discovery(tmp.path(), "auto-pkg", "1.0.0");
2512 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2513 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2514
2515 let resources = mgr.discover_resources("auto-pkg").unwrap();
2516
2517 let ext_count = resources
2518 .iter()
2519 .filter(|r| r.kind == ResourceKind::Extension)
2520 .count();
2521 let skill_count = resources
2522 .iter()
2523 .filter(|r| r.kind == ResourceKind::Skill)
2524 .count();
2525 let prompt_count = resources
2526 .iter()
2527 .filter(|r| r.kind == ResourceKind::Prompt)
2528 .count();
2529 let theme_count = resources
2530 .iter()
2531 .filter(|r| r.kind == ResourceKind::Theme)
2532 .count();
2533
2534 assert!(
2535 ext_count >= 1,
2536 "Expected at least 1 extension, got {}",
2537 ext_count
2538 );
2539 assert!(
2540 skill_count >= 1,
2541 "Expected at least 1 skill, got {}",
2542 skill_count
2543 );
2544 assert!(
2545 prompt_count >= 1,
2546 "Expected at least 1 prompt, got {}",
2547 prompt_count
2548 );
2549 assert!(
2550 theme_count >= 1,
2551 "Expected at least 1 theme, got {}",
2552 theme_count
2553 );
2554 }
2555
2556 #[test]
2557 fn test_resource_counts() {
2558 let (tmp, packages_dir) = setup_temp_packages_dir();
2559
2560 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2561 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2562 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2563
2564 let counts = mgr.resource_counts("test-pkg").unwrap();
2565 assert_eq!(counts.extensions, 1);
2566 assert_eq!(counts.skills, 1);
2567 assert_eq!(counts.prompts, 0);
2568 assert_eq!(counts.themes, 0);
2569 }
2570
2571 #[test]
2572 fn test_resource_counts_display() {
2573 let counts = ResourceCounts {
2574 extensions: 2,
2575 skills: 1,
2576 prompts: 0,
2577 themes: 3,
2578 };
2579 assert_eq!(counts.to_string(), "2 ext, 1 skill, 3 theme");
2580
2581 let empty = ResourceCounts::default();
2582 assert_eq!(empty.to_string(), "-");
2583 }
2584
2585 #[test]
2586 fn test_resource_kind_display() {
2587 assert_eq!(ResourceKind::Extension.to_string(), "extension");
2588 assert_eq!(ResourceKind::Skill.to_string(), "skill");
2589 assert_eq!(ResourceKind::Prompt.to_string(), "prompt");
2590 assert_eq!(ResourceKind::Theme.to_string(), "theme");
2591 }
2592
2593 #[test]
2594 fn test_get_install_dir() {
2595 let (tmp, packages_dir) = setup_temp_packages_dir();
2596
2597 let pkg_dir = create_test_package(tmp.path(), "test-pkg", "1.0.0");
2598 let mut mgr = PackageManager::with_dir(packages_dir.clone()).unwrap();
2599 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2600
2601 let dir = mgr.get_install_dir("test-pkg").unwrap();
2602 assert!(dir.exists());
2603 assert!(dir.join(MANIFEST_NAME).exists());
2604
2605 assert!(mgr.get_install_dir("nonexistent").is_none());
2606 }
2607
2608 #[test]
2609 fn test_discover_resources_not_installed() {
2610 let (_tmp, packages_dir) = setup_temp_packages_dir();
2611 let mgr = PackageManager::with_dir(packages_dir).unwrap();
2612
2613 let result = mgr.discover_resources("nonexistent");
2614 assert!(result.is_err());
2615 }
2616
2617 #[test]
2618 fn test_update_not_installed() {
2619 let (_tmp, packages_dir) = setup_temp_packages_dir();
2620 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2621
2622 let result = mgr.update("nonexistent");
2623 assert!(result.is_err());
2624 }
2625
2626 #[test]
2629 fn test_parse_npm_source() {
2630 let parsed = ParsedSource::parse("npm:express@4.18.0");
2631 match parsed {
2632 ParsedSource::Npm { spec, name, pinned } => {
2633 assert_eq!(spec, "express@4.18.0");
2634 assert_eq!(name, "express");
2635 assert!(pinned);
2636 }
2637 _ => panic!("Expected Npm source"),
2638 }
2639
2640 let parsed = ParsedSource::parse("npm:lodash");
2641 match parsed {
2642 ParsedSource::Npm { name, pinned, .. } => {
2643 assert_eq!(name, "lodash");
2644 assert!(!pinned);
2645 }
2646 _ => panic!("Expected Npm source"),
2647 }
2648 }
2649
2650 #[test]
2651 fn test_parse_git_source() {
2652 let parsed = ParsedSource::parse("https://github.com/org/repo.git");
2653 match parsed {
2654 ParsedSource::Git {
2655 host, path, ref_, ..
2656 } => {
2657 assert_eq!(host, "github.com");
2658 assert_eq!(path, "org/repo");
2659 assert!(ref_.is_none());
2660 }
2661 _ => panic!("Expected Git source"),
2662 }
2663
2664 let parsed = ParsedSource::parse("https://github.com/org/repo.git@v1.0.0");
2665 match parsed {
2666 ParsedSource::Git { path, ref_, .. } => {
2667 assert_eq!(path, "org/repo");
2668 assert_eq!(ref_.as_deref(), Some("v1.0.0"));
2669 }
2670 _ => panic!("Expected Git source"),
2671 }
2672 }
2673
2674 #[test]
2675 fn test_parse_github_shorthand() {
2676 let parsed = ParsedSource::parse("github:org/repo@main");
2677 match parsed {
2678 ParsedSource::Git {
2679 host, path, ref_, ..
2680 } => {
2681 assert_eq!(host, "github.com");
2682 assert_eq!(path, "org/repo");
2683 assert_eq!(ref_.as_deref(), Some("main"));
2684 }
2685 _ => panic!("Expected Git source"),
2686 }
2687 }
2688
2689 #[test]
2690 fn test_parse_local_source() {
2691 let parsed = ParsedSource::parse("/path/to/package");
2692 match parsed {
2693 ParsedSource::Local { path } => {
2694 assert_eq!(path, "/path/to/package");
2695 }
2696 _ => panic!("Expected Local source"),
2697 }
2698
2699 let parsed = ParsedSource::parse("./relative/path");
2700 match parsed {
2701 ParsedSource::Local { path } => {
2702 assert_eq!(path, "./relative/path");
2703 }
2704 _ => panic!("Expected Local source"),
2705 }
2706 }
2707
2708 #[test]
2709 fn test_parse_url_source() {
2710 let parsed = ParsedSource::parse("https://example.com/pkg.tar.gz");
2711 match parsed {
2712 ParsedSource::Url { url } => {
2713 assert_eq!(url, "https://example.com/pkg.tar.gz");
2714 }
2715 _ => panic!("Expected Url source"),
2716 }
2717 }
2718
2719 #[test]
2720 fn test_source_identity() {
2721 let npm = ParsedSource::parse("npm:express@4.18.0");
2722 assert_eq!(npm.identity(), "npm:express");
2723
2724 let git = ParsedSource::parse("https://github.com/org/repo.git");
2725 assert_eq!(git.identity(), "git:github.com/org/repo");
2726
2727 let local = ParsedSource::parse("/path/to/pkg");
2728 assert_eq!(local.identity(), "local:/path/to/pkg");
2729 }
2730
2731 #[test]
2732 fn test_parse_npm_spec() {
2733 let (name, pinned) = parse_npm_spec("express@4.18.0");
2734 assert_eq!(name, "express");
2735 assert!(pinned);
2736
2737 let (name, pinned) = parse_npm_spec("express");
2738 assert_eq!(name, "express");
2739 assert!(!pinned);
2740
2741 let (name, pinned) = parse_npm_spec("@scope/pkg@1.0.0");
2742 assert_eq!(name, "@scope/pkg");
2743 assert!(pinned);
2744 }
2745
2746 #[test]
2749 fn test_lockfile_roundtrip() {
2750 let (tmp, _) = setup_temp_packages_dir();
2751 let lock_path = tmp.path().join(LOCKFILE_NAME);
2752
2753 let mut lock = Lockfile::new();
2754 lock.insert(LockEntry {
2755 source: "npm:express@4.18.0".to_string(),
2756 name: "express".to_string(),
2757 version: "4.18.0".to_string(),
2758 integrity: Some("sha256-abc123".to_string()),
2759 scope: SourceScope::User,
2760 source_type: "npm".to_string(),
2761 dependencies: BTreeMap::new(),
2762 });
2763
2764 lock.write(&lock_path).unwrap();
2765
2766 let loaded = Lockfile::read(&lock_path).unwrap().unwrap();
2767 assert_eq!(loaded.packages.len(), 1);
2768 assert_eq!(loaded.packages["express"].version, "4.18.0");
2769 assert_eq!(
2770 loaded.packages["express"].integrity.as_deref(),
2771 Some("sha256-abc123")
2772 );
2773 }
2774
2775 #[test]
2776 fn test_lockfile_install_roundtrip() {
2777 let (tmp, packages_dir) = setup_temp_packages_dir();
2778 let pkg_dir = create_test_package(tmp.path(), "locked-pkg", "1.0.0");
2779
2780 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2781 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2782
2783 let lock_path = mgr.packages_dir().join(LOCKFILE_NAME);
2785 assert!(lock_path.exists());
2786
2787 let lock = Lockfile::read(&lock_path).unwrap().unwrap();
2788 assert!(lock.contains("locked-pkg"));
2789 let entry = lock.get("locked-pkg").unwrap();
2790 assert_eq!(entry.version, "1.0.0");
2791 }
2792
2793 #[test]
2796 fn test_validate_valid_package() {
2797 let (tmp, _) = setup_temp_packages_dir();
2798 let pkg_dir = create_test_package(tmp.path(), "valid-pkg", "1.0.0");
2799 let warnings = PackageManager::validate_package(&pkg_dir).unwrap();
2800 assert!(
2802 warnings.len() <= 1,
2803 "Expected <= 1 warning, got {:?}",
2804 warnings
2805 );
2806 }
2807
2808 #[test]
2809 fn test_validate_empty_dir() {
2810 let tmp = tempfile::tempdir().unwrap();
2811 let empty_dir = tmp.path().join("empty-pkg");
2812 fs::create_dir_all(&empty_dir).unwrap();
2813 let warnings = PackageManager::validate_package(&empty_dir).unwrap();
2814 assert!(!warnings.is_empty());
2815 }
2816
2817 #[test]
2820 fn test_resolve_dependencies() {
2821 let (tmp, packages_dir) = setup_temp_packages_dir();
2822
2823 let pkg_dir = tmp.path().join("dep-pkg");
2825 fs::create_dir_all(&pkg_dir).unwrap();
2826 let mut deps = BTreeMap::new();
2827 deps.insert("lodash".to_string(), "^4.0.0".to_string());
2828 deps.insert("nonexistent-pkg".to_string(), "^1.0.0".to_string());
2829
2830 let manifest = PackageManifest {
2831 name: "dep-pkg".to_string(),
2832 version: "1.0.0".to_string(),
2833 extensions: vec![],
2834 skills: vec![],
2835 prompts: vec![],
2836 themes: vec![],
2837 description: None,
2838 dependencies: deps,
2839 };
2840 fs::write(
2841 pkg_dir.join(MANIFEST_NAME),
2842 toml::to_string_pretty(&manifest).unwrap(),
2843 )
2844 .unwrap();
2845
2846 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2847 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2848
2849 let missing = mgr.resolve_dependencies();
2850 assert_eq!(missing.len(), 1);
2851 assert_eq!(missing[0].0, "dep-pkg");
2852 assert!(
2853 missing[0].1.contains(&"lodash".to_string())
2854 || missing[0].1.contains(&"nonexistent-pkg".to_string())
2855 );
2856 }
2857
2858 #[test]
2861 fn test_version_satisfies() {
2862 let (tmp, packages_dir) = setup_temp_packages_dir();
2863 let pkg_dir = create_test_package(tmp.path(), "ver-pkg", "1.2.3");
2864 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2865 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2866
2867 assert!(mgr.version_satisfies("ver-pkg", "^1.0.0"));
2868 assert!(mgr.version_satisfies("ver-pkg", ">=1.0.0"));
2869 assert!(!mgr.version_satisfies("ver-pkg", "^2.0.0"));
2870 assert!(!mgr.version_satisfies("ver-pkg", "<1.0.0"));
2871 }
2872
2873 #[test]
2874 fn test_get_installed_version() {
2875 let (tmp, packages_dir) = setup_temp_packages_dir();
2876 let pkg_dir = create_test_package(tmp.path(), "ver-pkg", "3.1.4");
2877 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2878 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2879
2880 assert_eq!(mgr.get_installed_version("ver-pkg"), Some("3.1.4"));
2881 assert_eq!(mgr.get_installed_version("nonexistent"), None);
2882 }
2883
2884 #[test]
2887 fn test_resolve() {
2888 let (tmp, packages_dir) = setup_temp_packages_dir();
2889 let pkg_dir = create_test_package(tmp.path(), "resolve-pkg", "1.0.0");
2890 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2891 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2892
2893 let resolved = mgr.resolve();
2894 assert!(!resolved.extensions.is_empty() || !resolved.skills.is_empty());
2895 }
2896
2897 #[test]
2900 fn test_progress_callback() {
2901 use std::sync::{Arc, Mutex};
2902
2903 let events: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
2904 let events_clone = events.clone();
2905
2906 let (tmp, packages_dir) = setup_temp_packages_dir();
2907 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2908
2909 mgr.set_progress_callback(Box::new(move |event| {
2910 let mut e = events_clone.lock().unwrap();
2911 e.push(format!("{:?}:{:?}", event.event_type, event.action));
2912 }));
2913
2914 let pkg_dir = create_test_package(tmp.path(), "progress-pkg", "1.0.0");
2915 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2916
2917 let _event_count = events.lock().unwrap().len();
2920 }
2921
2922 #[test]
2923 fn test_list_configured() {
2924 let (tmp, packages_dir) = setup_temp_packages_dir();
2925 let pkg_dir = create_test_package(tmp.path(), "cfg-pkg", "1.0.0");
2926 let mut mgr = PackageManager::with_dir(packages_dir).unwrap();
2927 mgr.install(pkg_dir.to_str().unwrap()).unwrap();
2928
2929 let configured = mgr.list_configured();
2930 assert_eq!(configured.len(), 1);
2931 assert!(configured[0].source.contains("source-pkg"));
2932 }
2934}