1use std::collections::{BTreeMap, HashMap, HashSet};
10use std::path::{Path, PathBuf};
11
12use serde_json::Value;
13
14use crate::config;
15
16const PACKAGE_SOURCE_MARKER: &str = ".rpi-package-source.json";
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct PackageRoot {
20 pub root: PathBuf,
21 pub name: String,
22 pub version: Option<String>,
23 pub manifest: Option<PathBuf>,
24 pub spec: String,
26 pub source: PackageSource,
29 npm_install_root: Option<PathBuf>,
34 legacy_npm_root: Option<PathBuf>,
38 autoload_delta: bool,
41 scope: ResolveScope,
42 git_store_root: Option<PathBuf>,
43 git_revision: Option<String>,
44 missing_install: bool,
48 filter: Option<crate::settings::PackageFilter>,
49 skills: Vec<PathBuf>,
50 prompts: Vec<PathBuf>,
51 themes: Vec<PathBuf>,
52 system_prompts: Vec<PathBuf>,
53 append_system_prompts: Vec<PathBuf>,
54 pub extensions: Vec<PathBuf>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum PackageSource {
61 Npm {
62 name: String,
63 spec: String,
64 requested: Option<String>,
66 pinned: bool,
68 },
69 Git,
70 Local,
71 Unknown,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub(crate) struct ParsedNpmPackageSpec {
76 pub(crate) install_name: String,
78 pub(crate) manifest_name: String,
81 pub(crate) requested: Option<String>,
83 pub(crate) target_selector: Option<String>,
87 pub(crate) is_alias: bool,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct PackageDiagnostic {
92 pub spec: String,
93 pub message: String,
94 pub blocks_update: bool,
95}
96
97#[derive(Debug, Clone, Default)]
98pub struct PackageResources {
99 pub packages: Vec<PackageRoot>,
100 pub diagnostics: Vec<PackageDiagnostic>,
101}
102
103impl PackageResources {
104 pub fn extension_paths(&self) -> Vec<PathBuf> {
105 self.packages
106 .iter()
107 .flat_map(|p| p.extensions.iter().cloned())
108 .collect()
109 }
110 pub fn skill_dirs(&self) -> Vec<PathBuf> {
111 self.packages
112 .iter()
113 .flat_map(|p| p.skills.iter().cloned())
114 .collect()
115 }
116
117 pub fn prompt_dirs(&self) -> Vec<PathBuf> {
118 self.packages
119 .iter()
120 .flat_map(|p| p.prompts.iter().cloned())
121 .collect()
122 }
123
124 pub fn theme_files(&self) -> Vec<PathBuf> {
125 self.packages
126 .iter()
127 .flat_map(|p| resource_inventory(&p.root, &p.themes, FilterResourceKind::Themes))
128 .collect()
129 }
130
131 pub fn system_prompt_files(&self) -> Vec<PathBuf> {
132 self.packages
133 .iter()
134 .flat_map(|p| p.system_prompts.iter().cloned())
135 .collect()
136 }
137
138 pub fn append_system_prompt_files(&self) -> Vec<PathBuf> {
139 self.packages
140 .iter()
141 .flat_map(|p| p.append_system_prompts.iter().cloned())
142 .collect()
143 }
144
145 pub fn find_theme(&self, name: &str) -> Option<PathBuf> {
146 let wanted = Path::new(name);
147 self.theme_files().into_iter().find(|path| {
148 path == wanted
149 || path.file_stem().and_then(|s| s.to_str()) == Some(name)
150 || path.file_name().and_then(|s| s.to_str()) == Some(name)
151 })
152 }
153}
154
155pub fn discover_from_settings(cwd: &Path) -> PackageResources {
160 discover_configured_packages(cwd, false, true)
161}
162
163fn discover_from_settings_for_update(
164 cwd: &Path,
165 project_trusted: bool,
166) -> Result<(PackageResources, Option<crate::npm::NpmCommand>), String> {
167 let project_settings = if project_trusted {
168 crate::settings::load_active_project_settings(cwd)
169 .map_err(|error| format!("could not load project package settings: {error}"))?
170 .map(|(_, settings)| settings)
171 } else {
172 None
173 };
174 let user_settings = crate::settings::load_settings()
175 .map_err(|error| format!("could not load global package settings: {error}"))?;
176 let project_specs = project_settings
177 .as_ref()
178 .and_then(|settings| settings.packages.as_deref())
179 .unwrap_or_default();
180 let user_specs = user_settings.packages.as_deref().unwrap_or_default();
181 let has_configured_packages = !project_specs.is_empty() || !user_specs.is_empty();
182 let npm_command = if has_configured_packages {
183 let configured = project_settings
184 .as_ref()
185 .and_then(|settings| settings.npm_command.as_deref())
186 .or(user_settings.npm_command.as_deref());
187 Some(
188 crate::npm::NpmCommand::from_argv(configured).map_err(|error| {
189 format!("invalid npmCommand in active package settings: {error}")
190 })?,
191 )
192 } else {
193 None
194 };
195 let resources = discover_configured_package_specs_for_update_with_command(
196 cwd,
197 project_specs,
198 user_specs,
199 npm_command.as_ref(),
200 );
201 Ok((resources, npm_command))
202}
203
204fn discover_configured_packages(
205 cwd: &Path,
206 recover_for_update: bool,
207 include_project: bool,
208) -> PackageResources {
209 let project_specs = include_project
210 .then(|| {
211 crate::settings::load_project_settings(cwd)
212 .into_iter()
213 .filter_map(|settings| settings.packages)
214 .flatten()
215 .collect::<Vec<_>>()
216 })
217 .unwrap_or_default();
218 let user_specs = crate::settings::load_settings()
219 .ok()
220 .and_then(|settings| settings.packages)
221 .unwrap_or_default();
222 discover_configured_package_specs(
223 cwd,
224 &project_specs,
225 &user_specs,
226 recover_for_update,
227 include_project,
228 )
229}
230
231fn discover_configured_package_specs(
232 cwd: &Path,
233 project_specs: &[crate::settings::PackageSetting],
234 user_specs: &[crate::settings::PackageSetting],
235 recover_for_update: bool,
236 include_project: bool,
237) -> PackageResources {
238 let npm_command = crate::npm::NpmCommand::resolve(cwd, include_project).ok();
239 discover_configured_package_specs_with_command(
240 cwd,
241 project_specs,
242 user_specs,
243 recover_for_update,
244 npm_command.as_ref(),
245 )
246}
247
248fn discover_configured_package_specs_with_command(
249 cwd: &Path,
250 project_specs: &[crate::settings::PackageSetting],
251 user_specs: &[crate::settings::PackageSetting],
252 recover_for_update: bool,
253 npm_command: Option<&crate::npm::NpmCommand>,
254) -> PackageResources {
255 let project = discover_with_scope_and_command(
256 cwd,
257 project_specs,
258 ResolveScope::Project,
259 recover_for_update,
260 npm_command,
261 );
262 let user = discover_with_scope_and_command(
263 cwd,
264 user_specs,
265 ResolveScope::User,
266 recover_for_update,
267 npm_command,
268 );
269 merge_scoped_resources([project, user])
270}
271
272fn discover_configured_package_specs_for_update_with_command(
273 cwd: &Path,
274 project_specs: &[crate::settings::PackageSetting],
275 user_specs: &[crate::settings::PackageSetting],
276 npm_command: Option<&crate::npm::NpmCommand>,
277) -> PackageResources {
278 let project = discover_with_scope_and_command(
279 cwd,
280 project_specs,
281 ResolveScope::Project,
282 true,
283 npm_command,
284 );
285 let user =
286 discover_with_scope_and_command(cwd, user_specs, ResolveScope::User, true, npm_command);
287 let mut combined = PackageResources::default();
291 for mut resources in [project, user] {
292 combined.packages.append(&mut resources.packages);
293 combined.diagnostics.append(&mut resources.diagnostics);
294 }
295 combined
296}
297
298pub fn resolve_from_settings(cwd: &Path) -> PackageResources {
304 resolve_configured_packages_for_runtime(cwd, true)
305}
306
307pub fn resolve_from_global_settings(cwd: &Path) -> PackageResources {
311 resolve_configured_packages_for_runtime(cwd, false)
312}
313
314pub fn resolve_offline_from_settings(cwd: &Path) -> PackageResources {
319 resolve_configured_packages_offline(cwd, true)
320}
321
322pub fn resolve_offline_from_global_settings(cwd: &Path) -> PackageResources {
323 resolve_configured_packages_offline(cwd, false)
324}
325
326fn resolve_configured_packages_offline(cwd: &Path, include_project: bool) -> PackageResources {
327 let project_specs = include_project
328 .then(|| {
329 crate::settings::load_project_settings(cwd)
330 .into_iter()
331 .filter_map(|settings| settings.packages)
332 .flatten()
333 .collect::<Vec<_>>()
334 })
335 .unwrap_or_default();
336 let user_specs = crate::settings::load_settings()
337 .ok()
338 .and_then(|settings| settings.packages)
339 .unwrap_or_default();
340 let mut resources = discover_configured_package_specs_with_command(
341 cwd,
342 &project_specs,
343 &user_specs,
344 false,
345 None,
346 );
347 let failures = runtime_npm_mismatch_failures(
348 &resources,
349 "configured npm version is unavailable while offline",
350 );
351 apply_runtime_failures(&mut resources, failures);
352 resources
353}
354
355fn resolve_configured_packages_for_runtime(cwd: &Path, include_project: bool) -> PackageResources {
356 let project_specs = include_project
357 .then(|| {
358 crate::settings::load_project_settings(cwd)
359 .into_iter()
360 .filter_map(|settings| settings.packages)
361 .flatten()
362 .collect::<Vec<_>>()
363 })
364 .unwrap_or_default();
365 let user_specs = crate::settings::load_settings()
366 .ok()
367 .and_then(|settings| settings.packages)
368 .unwrap_or_default();
369 let planned =
370 discover_configured_package_specs(cwd, &project_specs, &user_specs, true, include_project);
371
372 let mut npm_roots: BTreeMap<PathBuf, Vec<(String, String, String)>> = BTreeMap::new();
376 let mut standalone_npm = Vec::new();
377 let mut missing_git = Vec::new();
378 let mut planning_failures: HashMap<String, (String, String)> = HashMap::new();
379
380 for package in &planned.packages {
381 if runtime_npm_needs_install(package) {
382 let PackageSource::Npm { name, spec, .. } = &package.source else {
383 unreachable!();
384 };
385 let identity = package_identity(package);
386 match package.npm_store_root_for_update(cwd, include_project) {
387 Ok(Some(root)) => {
388 npm_roots
389 .entry(root)
390 .or_default()
391 .push((name.clone(), spec.clone(), identity))
392 }
393 Ok(None) => standalone_npm.push((
394 package.root.clone(),
395 package.name.clone(),
396 name.clone(),
397 spec.clone(),
398 identity,
399 )),
400 Err(error) => {
401 planning_failures.insert(identity, (package.spec.clone(), error));
402 }
403 }
404 } else if package.missing_install && matches!(package.source, PackageSource::Git) {
405 missing_git.push(package.clone());
406 }
407 }
408
409 if npm_roots.is_empty()
410 && standalone_npm.is_empty()
411 && missing_git.is_empty()
412 && planning_failures.is_empty()
413 {
414 return planned;
415 }
416
417 let mut failures = planning_failures;
418 match crate::npm::NpmCommand::resolve(cwd, include_project) {
419 Ok(npm_command) => {
420 for (root, display_name, name, source_spec, identity) in standalone_npm {
421 if let Err(error) = crate::install_pi::update_npm_package_for_startup(
422 &root,
423 &name,
424 &source_spec,
425 &npm_command,
426 ) {
427 failures.insert(
428 identity,
429 (
430 source_spec,
431 format!("could not restore npm package {display_name}: {error}"),
432 ),
433 );
434 }
435 }
436 for (root, packages) in npm_roots {
437 let install_specs = packages
438 .iter()
439 .map(|(name, source, _)| (name.clone(), source.clone()))
440 .collect::<Vec<_>>();
441 if let Err(error) = crate::install_pi::update_npm_store_root_for_startup(
442 &root,
443 &install_specs,
444 &npm_command,
445 cwd,
446 include_project,
447 ) {
448 for (_, source, identity) in packages {
449 failures.insert(
450 identity,
451 (source, format!("could not restore npm package: {error}")),
452 );
453 }
454 }
455 }
456 for package in missing_git {
457 let identity = package_identity(&package);
458 if let Err(error) = crate::install_pi::install_missing_git_package_for_startup(
459 cwd,
460 package.scope == ResolveScope::User,
461 &package.spec,
462 &npm_command,
463 ) {
464 failures.insert(
465 identity,
466 (
467 package.spec.clone(),
468 format!("could not restore git package: {error}"),
469 ),
470 );
471 }
472 }
473 }
474 Err(error) => {
475 for (_, source, identity) in npm_roots.into_values().flatten() {
476 failures.insert(identity, (source, error.clone()));
477 }
478 for (_, _, _, source, identity) in standalone_npm {
479 failures.insert(identity, (source, error.clone()));
480 }
481 for package in missing_git {
482 failures.insert(package_identity(&package), (package.spec, error.clone()));
483 }
484 }
485 }
486
487 let mut resolved =
488 discover_configured_package_specs(cwd, &project_specs, &user_specs, false, include_project);
489 failures.extend(runtime_npm_mismatch_failures(
490 &resolved,
491 "package manager completed but the installed npm version still does not satisfy settings",
492 ));
493 if failures.is_empty() {
494 return resolved;
495 }
496 apply_runtime_failures(&mut resolved, failures);
497 resolved
498}
499
500fn runtime_npm_mismatch_failures(
501 resources: &PackageResources,
502 message: &str,
503) -> HashMap<String, (String, String)> {
504 resources
505 .packages
506 .iter()
507 .filter(|package| runtime_npm_needs_install(package))
508 .map(|package| {
509 (
510 package_identity(package),
511 (package.spec.clone(), message.to_string()),
512 )
513 })
514 .collect()
515}
516
517fn apply_runtime_failures(
518 resources: &mut PackageResources,
519 failures: HashMap<String, (String, String)>,
520) {
521 if failures.is_empty() {
522 return;
523 }
524 resources
525 .packages
526 .retain(|package| !failures.contains_key(&package_identity(package)));
527 resources.diagnostics.retain(|diagnostic| {
528 !failures
529 .values()
530 .any(|(source, _)| source == &diagnostic.spec)
531 });
532 resources.diagnostics.extend(
533 failures
534 .into_values()
535 .map(|(spec, message)| PackageDiagnostic {
536 spec,
537 message,
538 blocks_update: true,
539 }),
540 );
541}
542
543fn runtime_npm_needs_install(package: &PackageRoot) -> bool {
544 let PackageSource::Npm { spec, .. } = &package.source else {
545 return false;
546 };
547 if package.missing_install {
548 return true;
549 }
550 let Some(requested) = parse_npm_package_spec(spec).and_then(|parsed| parsed.target_selector)
551 else {
552 return false;
553 };
554 npm_version_matches_requirement(package.version.as_deref(), &requested) == Some(false)
555}
556
557fn npm_version_matches_requirement(installed: Option<&str>, requested: &str) -> Option<bool> {
562 let requested = requested.trim();
563 if requested.is_empty() {
564 return None;
565 }
566 if let Some((left, right)) = requested.split_once("||") {
567 let mut recognized = false;
568 for branch in std::iter::once(left).chain(right.split("||")) {
569 if let Some(matches) = npm_version_matches_requirement(installed, branch) {
570 recognized = true;
571 if matches {
572 return Some(true);
573 }
574 }
575 }
576 return recognized.then_some(false);
577 }
578 let installed = installed
579 .and_then(|version| semver::Version::parse(version.trim().trim_start_matches('v')).ok());
580 if is_exact_npm_version(requested) {
581 let expected = semver::Version::parse(requested.trim_start_matches('v')).ok()?;
582 return Some(installed.as_ref() == Some(&expected));
583 }
584 if let Some((minimum, maximum)) = requested.split_once(" - ") {
585 let (minimum, _) = parse_npm_partial_version(minimum)?;
586 let (maximum, maximum_parts) = parse_npm_partial_version(maximum)?;
587 return Some(installed.as_ref().is_some_and(|installed| {
588 let below_upper = match maximum_parts {
589 1 => maximum
590 .major
591 .checked_add(1)
592 .is_some_and(|major| installed < &semver::Version::new(major, 0, 0)),
593 2 => maximum.minor.checked_add(1).is_some_and(|minor| {
594 installed < &semver::Version::new(maximum.major, minor, 0)
595 }),
596 _ => installed <= &maximum,
597 };
598 installed >= &minimum && below_upper
599 }));
600 }
601
602 let tokens = requested.split_whitespace().collect::<Vec<_>>();
603 let normalized = normalize_npm_comparator_set(&tokens).unwrap_or_else(|| requested.to_string());
604 if let Some((partial, parts)) = parse_npm_partial_version(requested) {
607 if parts < 3 {
608 return if parts == 1 {
609 Some(
610 installed
611 .as_ref()
612 .is_some_and(|version| version.major == partial.major),
613 )
614 } else {
615 Some(installed.as_ref().is_some_and(|version| {
616 version.major == partial.major && version.minor == partial.minor
617 }))
618 };
619 }
620 }
621 semver::VersionReq::parse(&normalized)
622 .ok()
623 .map(|requirement| {
624 installed
625 .as_ref()
626 .is_some_and(|installed| requirement.matches(installed))
627 })
628}
629
630fn normalize_npm_comparator_set(tokens: &[&str]) -> Option<String> {
635 if tokens.len() <= 1 {
636 return None;
637 }
638 let mut normalized = Vec::new();
639 let mut index = 0;
640 while index < tokens.len() {
641 let token = tokens[index];
642 if matches!(token, "<" | "<=" | ">" | ">=" | "=" | "^" | "~") {
643 let version = *tokens.get(index + 1)?;
644 if !version
645 .trim_start_matches('v')
646 .chars()
647 .next()
648 .is_some_and(|character| character.is_ascii_digit())
649 {
650 return None;
651 }
652 normalized.push(format!("{token}{version}"));
653 index += 2;
654 continue;
655 }
656 if !token.chars().next().is_some_and(|character| {
657 matches!(character, '<' | '>' | '=' | '^' | '~') || character.is_ascii_digit()
658 }) {
659 return None;
660 }
661 normalized.push(token.to_string());
662 index += 1;
663 }
664 Some(normalized.join(", "))
665}
666
667fn parse_npm_partial_version(value: &str) -> Option<(semver::Version, usize)> {
668 let value = value.trim().trim_start_matches('v');
669 if let Ok(version) = semver::Version::parse(value) {
670 return Some((version, 3));
671 }
672 let parts = value.split('.').collect::<Vec<_>>();
673 if parts.is_empty()
674 || parts.len() > 3
675 || parts
676 .iter()
677 .any(|part| part.is_empty() || !part.chars().all(|ch| ch.is_ascii_digit()))
678 {
679 return None;
680 }
681 let major = parts[0].parse().ok()?;
682 let minor = parts.get(1).and_then(|part| part.parse().ok()).unwrap_or(0);
683 let patch = parts.get(2).and_then(|part| part.parse().ok()).unwrap_or(0);
684 Some((semver::Version::new(major, minor, patch), parts.len()))
685}
686
687fn merge_scoped_resources(
688 resources: impl IntoIterator<Item = PackageResources>,
689) -> PackageResources {
690 let mut merged = PackageResources::default();
691 let mut seen_identities: HashMap<String, usize> = HashMap::new();
692 for mut resource in resources {
693 merged.diagnostics.append(&mut resource.diagnostics);
694 for package in resource.packages {
695 let identity = package_identity(&package);
696 if let Some(existing_index) = seen_identities.get(&identity).copied() {
697 let existing = &merged.packages[existing_index];
698 if package.scope == ResolveScope::User && existing.autoload_delta {
702 let mut base = package;
703 if let Some(filter) = existing.filter.as_ref() {
704 apply_autoload_delta_to_package(&mut base, filter);
705 }
706 merged.packages[existing_index] = base;
707 }
708 } else {
709 seen_identities.insert(identity, merged.packages.len());
710 merged.packages.push(package);
711 }
712 }
713 }
714 merged
715}
716
717pub fn discover_from_global_settings(cwd: &Path) -> PackageResources {
721 let specs = crate::settings::load_settings()
722 .ok()
723 .and_then(|settings| settings.packages)
724 .unwrap_or_default();
725 discover_with_scope(cwd, &specs, ResolveScope::User, false)
726}
727
728pub fn discover(cwd: &Path, specs: &[String]) -> PackageResources {
732 let entries = specs
733 .iter()
734 .cloned()
735 .map(crate::settings::PackageSetting::from)
736 .collect::<Vec<_>>();
737 discover_with_scope(cwd, &entries, ResolveScope::Any, false)
738}
739
740fn discover_with_scope(
741 cwd: &Path,
742 specs: &[crate::settings::PackageSetting],
743 scope: ResolveScope,
744 recover_for_update: bool,
745) -> PackageResources {
746 let global_npm_command = if matches!(scope, ResolveScope::Any | ResolveScope::User) {
747 crate::npm::NpmCommand::resolve(cwd, false).ok()
748 } else {
749 None
750 };
751 discover_with_scope_and_command(
752 cwd,
753 specs,
754 scope,
755 recover_for_update,
756 global_npm_command.as_ref(),
757 )
758}
759
760fn discover_with_scope_and_command(
761 cwd: &Path,
762 specs: &[crate::settings::PackageSetting],
763 scope: ResolveScope,
764 recover_for_update: bool,
765 global_npm_command: Option<&crate::npm::NpmCommand>,
766) -> PackageResources {
767 let mut out = PackageResources::default();
768 let mut seen = HashSet::new();
769 let legacy_npm_names = specs
770 .iter()
771 .filter_map(|entry| npm_source_from_spec(entry.source()))
772 .filter_map(|source| match source {
773 PackageSource::Npm { name, .. } => Some(name),
774 _ => None,
775 })
776 .collect::<HashSet<_>>()
777 .into_iter()
778 .collect::<Vec<_>>();
779 let mut legacy_npm_paths = None;
780 for entry in specs
781 .iter()
782 .filter(|entry| !entry.source().trim().is_empty())
783 {
784 let spec = entry.source();
785 let filter = match entry {
786 crate::settings::PackageSetting::Filtered(filter) => Some(filter),
787 crate::settings::PackageSetting::Source(_) => None,
788 };
789 if recover_for_update {
790 let mut recovery_failed = false;
791 for target in update_recovery_targets(cwd, spec, scope) {
792 if let Err(message) = crate::install_pi::recover_configured_package_root(&target) {
793 out.diagnostics.push(PackageDiagnostic {
794 spec: spec.to_string(),
795 message,
796 blocks_update: true,
797 });
798 recovery_failed = true;
799 break;
800 }
801 if target.is_dir() {
802 break;
803 }
804 }
805 if recovery_failed {
806 continue;
807 }
808 }
809 let Some(resolved) = resolve_spec_with_command(
810 cwd,
811 spec,
812 scope,
813 global_npm_command,
814 &legacy_npm_names,
815 &mut legacy_npm_paths,
816 ) else {
817 if recover_for_update {
818 match missing_package_for_update(cwd, spec, scope, filter) {
819 Ok(Some(package)) => {
820 let key = package_identity(&package);
821 if seen.insert(key) {
822 out.packages.push(package);
823 }
824 continue;
825 }
826 Ok(None) => continue,
830 Err(message) => {
831 out.diagnostics.push(PackageDiagnostic {
832 spec: spec.to_string(),
833 message,
834 blocks_update: true,
835 });
836 continue;
837 }
838 }
839 }
840 out.diagnostics.push(PackageDiagnostic {
841 spec: spec.to_string(),
842 message: "package path/name could not be resolved".to_string(),
843 blocks_update: true,
844 });
845 continue;
846 };
847 let root = resolved.root;
848 let key = normalize_key(&root);
849 if !seen.insert(key) {
850 continue;
851 }
852 match load_package_with_legacy_root(
853 root,
854 spec,
855 cwd,
856 scope,
857 filter,
858 resolved.legacy_npm_root,
859 ) {
860 Ok(package) => out.packages.push(package),
861 Err(message) => out.diagnostics.push(PackageDiagnostic {
862 spec: spec.to_string(),
863 message,
864 blocks_update: true,
865 }),
866 }
867 }
868 out
869}
870
871fn missing_package_for_update(
872 cwd: &Path,
873 spec: &str,
874 scope: ResolveScope,
875 filter: Option<&crate::settings::PackageFilter>,
876) -> Result<Option<PackageRoot>, String> {
877 let (root, name, source, npm_install_root, git_store_root, git_revision) =
878 if let Some(source @ PackageSource::Npm { .. }) = npm_source_from_spec(spec) {
879 let PackageSource::Npm { name, .. } = &source else {
880 unreachable!();
881 };
882 let name = name.clone();
883 let install_root = managed_npm_root_for_scope(cwd, scope)?;
884 let root = install_root.join("node_modules").join(&name);
885 (root, name, source, Some(install_root), None, None)
886 } else if let Some(git) = parse_git_source(spec) {
887 let store_root = managed_git_root_for_scope(cwd, scope)?;
888 let root = store_root.join(&git.host).join(&git.path);
889 let name = git
890 .path
891 .rsplit('/')
892 .next()
893 .filter(|name| !name.is_empty())
894 .unwrap_or(&git.path)
895 .to_string();
896 (
897 root,
898 name,
899 PackageSource::Git,
900 None,
901 Some(store_root),
902 git.revision,
903 )
904 } else {
905 return Ok(None);
906 };
907
908 Ok(Some(PackageRoot {
909 root,
910 name,
911 version: None,
912 manifest: None,
913 spec: spec.to_string(),
914 source,
915 npm_install_root,
916 legacy_npm_root: None,
917 autoload_delta: filter.is_some_and(|filter| filter.autoload == Some(false)),
918 scope,
919 git_store_root,
920 git_revision,
921 missing_install: true,
922 filter: filter.cloned(),
923 skills: Vec::new(),
924 prompts: Vec::new(),
925 themes: Vec::new(),
926 system_prompts: Vec::new(),
927 append_system_prompts: Vec::new(),
928 extensions: Vec::new(),
929 }))
930}
931
932fn managed_npm_root_for_scope(cwd: &Path, scope: ResolveScope) -> Result<PathBuf, String> {
933 match scope {
934 ResolveScope::Project => Ok(cwd.join(".pi/npm")),
935 ResolveScope::User | ResolveScope::Any => config::agent_dir()
936 .map(|agent| agent.join("npm"))
937 .map_err(|error| error.to_string()),
938 }
939}
940
941fn managed_git_root_for_scope(cwd: &Path, scope: ResolveScope) -> Result<PathBuf, String> {
942 match scope {
943 ResolveScope::Project => Ok(cwd.join(".pi/git")),
944 ResolveScope::User | ResolveScope::Any => config::agent_dir()
945 .map(|agent| agent.join("git"))
946 .map_err(|error| error.to_string()),
947 }
948}
949
950fn absolute_file_spec_path(spec: &str) -> Option<PathBuf> {
951 let path = PathBuf::from(spec.strip_prefix("file:")?);
952 path.is_absolute().then_some(path)
953}
954
955fn update_recovery_targets(cwd: &Path, spec: &str, scope: ResolveScope) -> Vec<PathBuf> {
956 let mut targets = absolute_file_spec_path(spec)
957 .into_iter()
958 .collect::<Vec<_>>();
959 if let Some(git) = parse_git_source(spec) {
960 let relative = Path::new(&git.host).join(&git.path);
961 if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
962 targets.push(cwd.join(".rpi/git").join(&relative));
963 targets.push(cwd.join(".pi/git").join(&relative));
964 }
965 if matches!(scope, ResolveScope::Any | ResolveScope::User) {
966 if let Ok(agent) = config::agent_dir() {
967 targets.push(agent.join("git").join(&relative));
968 }
969 if let Some(home) = dirs::home_dir() {
970 targets.push(home.join(".pi/agent/git").join(&relative));
971 }
972 }
973 return targets;
974 }
975 let Some(PackageSource::Npm { name, .. }) = npm_source_from_spec(spec) else {
976 return targets;
977 };
978 let package_key = name.strip_prefix('@').unwrap_or(&name).replace('/', "__");
979 if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
980 targets.push(cwd.join(".rpi/packages").join(&name));
981 if package_key != name {
982 targets.push(cwd.join(".rpi/packages").join(&package_key));
983 }
984 targets.push(cwd.join(".pi/packages").join(&name));
985 if package_key != name {
986 targets.push(cwd.join(".pi/packages").join(&package_key));
987 }
988 targets.push(cwd.join(".pi/npm/node_modules").join(&name));
989 }
990 if matches!(scope, ResolveScope::Any | ResolveScope::User) {
991 if let Ok(agent) = config::agent_dir() {
992 targets.push(agent.join("packages").join(&name));
993 if package_key != name {
994 targets.push(agent.join("packages").join(&package_key));
995 }
996 targets.push(agent.join("npm/node_modules").join(&name));
997 }
998 if let Some(home) = dirs::home_dir() {
999 targets.push(home.join(".pi/agent/packages").join(&name));
1000 if package_key != name {
1001 targets.push(home.join(".pi/agent/packages").join(&package_key));
1002 }
1003 targets.push(home.join(".pi/agent/npm/node_modules").join(&name));
1004 }
1005 }
1006 targets
1007}
1008
1009pub fn resolve_package(cwd: &Path, spec: &str) -> Result<PackageRoot, String> {
1012 let root = resolve_spec(cwd, spec, ResolveScope::Any)
1013 .ok_or_else(|| "package path/name could not be resolved".to_string())?;
1014 load_package(root, spec, cwd, ResolveScope::Any, None)
1015}
1016
1017pub fn load_theme(cwd: &Path, name_or_path: &str) -> Result<rpi_tui::Theme, String> {
1021 load_theme_with_resources(cwd, name_or_path, &PackageResources::default())
1022}
1023
1024pub fn load_theme_with_resources(
1028 _cwd: &Path,
1029 name_or_path: &str,
1030 resources: &PackageResources,
1031) -> Result<rpi_tui::Theme, String> {
1032 let path = {
1033 let direct = PathBuf::from(name_or_path);
1034 if direct.is_file() {
1035 Some(direct)
1036 } else {
1037 resources.find_theme(name_or_path)
1038 }
1039 }
1040 .ok_or_else(|| format!("theme `{name_or_path}` was not found in enabled packages"))?;
1041 let text = std::fs::read_to_string(&path)
1042 .map_err(|error| format!("could not read theme {}: {error}", path.display()))?;
1043 let value = parse_json_with_comments(&text)
1044 .map_err(|error| format!("invalid theme {}: {error}", path.display()))?;
1045 let mut theme = rpi_tui::Theme::default();
1046 let colors = value.get("colors").unwrap_or(&value);
1047 let target = &mut theme.colors;
1048 macro_rules! color {
1049 ($field:ident, $($key:literal),+ $(,)?) => {
1050 if let Some(value) = first_value(colors, &[$($key),+]) {
1051 if let Some(parsed) = parse_color(value) {
1052 target.$field = parsed;
1053 }
1054 }
1055 };
1056 }
1057 color!(text, "text");
1058 color!(muted, "muted");
1059 color!(dim, "dim");
1060 color!(accent, "accent");
1061 color!(error, "error");
1062 color!(success, "success");
1063 color!(warning, "warning");
1064 color!(info, "info");
1065 color!(background, "background", "bg");
1066 color!(surface, "surface", "userMessageBg");
1067 color!(border, "border");
1068 color!(border_accent, "borderAccent");
1069 color!(border_muted, "borderMuted");
1070 color!(selection, "selection", "selectedBg");
1071 color!(cursor, "cursor");
1072 color!(thinking_text, "thinkingText");
1073 color!(md_heading, "mdHeading");
1074 color!(md_link, "mdLink");
1075 color!(md_link_url, "mdLinkUrl");
1076 color!(md_code, "mdCode");
1077 color!(md_code_bg, "mdCodeBg");
1078 color!(md_code_block, "mdCodeBlock");
1079 color!(md_code_block_bg, "mdCodeBlockBg");
1080 color!(md_code_block_border, "mdCodeBlockBorder");
1081 color!(md_quote, "mdQuote");
1082 color!(md_quote_border, "mdQuoteBorder");
1083 color!(md_hr, "mdHr");
1084 color!(md_list_bullet, "mdListBullet");
1085 color!(tool_pending_bg, "toolPendingBg");
1086 color!(tool_success_bg, "toolSuccessBg");
1087 color!(tool_error_bg, "toolErrorBg");
1088 color!(tool_title, "toolTitle");
1089 color!(tool_output, "toolOutput");
1090 color!(bash_mode, "bashMode");
1091 color!(tool_diff_added, "toolDiffAdded");
1092 color!(tool_diff_removed, "toolDiffRemoved");
1093 color!(tool_diff_context, "toolDiffContext");
1094 if let Some(border) = value.get("borderStyle").and_then(Value::as_str) {
1095 theme.border_style = match border.to_ascii_lowercase().as_str() {
1096 "sharp" => rpi_tui::theme::BorderStyle::Sharp,
1097 "double" => rpi_tui::theme::BorderStyle::Double,
1098 "thick" => rpi_tui::theme::BorderStyle::Thick,
1099 "none" => rpi_tui::theme::BorderStyle::None,
1100 _ => rpi_tui::theme::BorderStyle::Rounded,
1101 };
1102 }
1103 if let Some(corner) = value.get("cornerStyle").and_then(Value::as_str) {
1104 theme.corner_style = if corner.eq_ignore_ascii_case("sharp") {
1105 rpi_tui::theme::CornerStyle::Sharp
1106 } else {
1107 rpi_tui::theme::CornerStyle::Rounded
1108 };
1109 }
1110 Ok(theme)
1111}
1112
1113fn first_value<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a Value> {
1114 keys.iter().find_map(|key| value.get(*key))
1115}
1116
1117fn parse_color(value: &Value) -> Option<rpi_tui::Color> {
1118 match value {
1119 Value::String(raw) => {
1120 let value = raw.trim();
1121 let hex = value.strip_prefix('#')?;
1122 if hex.len() == 6 {
1123 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
1124 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
1125 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
1126 Some(rpi_tui::Color::Rgb(r, g, b))
1127 } else if let Some(index) = value.strip_prefix("ansi256:") {
1128 Some(rpi_tui::Color::Ansi256(index.parse().ok()?))
1129 } else {
1130 None
1131 }
1132 }
1133 Value::Array(values) if values.len() == 3 => Some(rpi_tui::Color::Rgb(
1134 values[0].as_u64()?.try_into().ok()?,
1135 values[1].as_u64()?.try_into().ok()?,
1136 values[2].as_u64()?.try_into().ok()?,
1137 )),
1138 Value::Object(map) => {
1139 let r = map.get("r")?.as_u64()?.try_into().ok()?;
1140 let g = map.get("g")?.as_u64()?.try_into().ok()?;
1141 let b = map.get("b")?.as_u64()?.try_into().ok()?;
1142 Some(rpi_tui::Color::Rgb(r, g, b))
1143 }
1144 _ => None,
1145 }
1146}
1147
1148pub fn run_cli(args: &[String]) -> i32 {
1151 crate::args::normalize_offline_mode(args);
1152 let args = crate::args::without_offline_flag(args);
1153 let args = args.as_slice();
1154 let command = args.first().map(String::as_str).unwrap_or("list");
1155 let cwd = match std::env::current_dir() {
1156 Ok(path) => path,
1157 Err(error) => {
1158 eprintln!("error: could not determine current directory: {error}");
1159 return 1;
1160 }
1161 };
1162 match command {
1163 "list" => {
1164 let project_trusted = match package_command_project_trusted(&cwd, &args[1..]) {
1165 Ok(trusted) => trusted,
1166 Err(error) => {
1167 eprintln!("error: {error}");
1168 return 2;
1169 }
1170 };
1171 let resources = if project_trusted {
1172 discover_from_settings(&cwd)
1173 } else {
1174 discover_from_global_settings(&cwd)
1175 };
1176 let native = crate::install::installed_native_packages();
1177 if args.iter().any(|arg| arg == "--json") {
1178 let mut values: Vec<_> = resources
1179 .packages
1180 .iter()
1181 .map(|p| {
1182 serde_json::json!({
1183 "name": p.name,
1184 "version": p.version,
1185 "type": "ts",
1186 "root": p.root,
1187 "manifest": p.manifest,
1188 "skills": p.skill_dirs_for_display(),
1189 "prompts": p.prompt_dirs_for_display(),
1190 "themes": p.theme_files_for_display(),
1191 })
1192 })
1193 .collect();
1194 values.extend(native.iter().map(|package| {
1195 serde_json::json!({
1196 "name": package.name,
1197 "version": package.version,
1198 "type": "rust",
1199 "source": package.source,
1200 })
1201 }));
1202 println!(
1203 "{}",
1204 serde_json::to_string_pretty(&values).unwrap_or_else(|_| "[]".into())
1205 );
1206 } else if resources.packages.is_empty() && native.is_empty() {
1207 println!("no Pi packages enabled");
1208 } else {
1209 for package in &resources.packages {
1210 let version = package.version.as_deref().unwrap_or("-");
1211 println!("{}@{} {}", package.name, version, package.root.display());
1212 }
1213 for package in native {
1214 let source = package.source.as_deref().unwrap_or("crates.io");
1215 println!("{}@{} [rust] {}", package.name, package.version, source);
1216 }
1217 }
1218 for diagnostic in resources.diagnostics {
1219 eprintln!(
1220 "warning: package {}: {}",
1221 diagnostic.spec, diagnostic.message
1222 );
1223 }
1224 0
1225 }
1226 "add" => {
1227 let Some(spec) = args.get(1).filter(|s| !s.starts_with('-')) else {
1228 eprintln!("error: missing package path or name");
1229 print_help();
1230 return 2;
1231 };
1232 if let Err(error) = resolve_package(&cwd, spec) {
1233 eprintln!("error: {error}");
1234 return 1;
1235 }
1236 let mut settings = match crate::settings::load_settings() {
1237 Ok(settings) => settings,
1238 Err(error) => {
1239 eprintln!("error: refusing to change unreadable package settings: {error}");
1240 return 1;
1241 }
1242 };
1243 let packages = settings.packages.get_or_insert_with(Vec::new);
1244 if !packages.iter().any(|existing| existing.source() == spec) {
1245 packages.push(crate::settings::PackageSetting::from(spec.clone()));
1246 if let Err(error) = crate::settings::save_settings(&settings) {
1247 eprintln!("error: could not save package settings: {error}");
1248 return 1;
1249 }
1250 println!("enabled Pi package {spec}");
1251 } else {
1252 println!("Pi package already enabled: {spec}");
1253 }
1254 0
1255 }
1256 "remove" | "rm" => {
1257 let Some(spec) = args.get(1).filter(|s| !s.starts_with('-')) else {
1258 eprintln!("error: missing package path or name");
1259 print_help();
1260 return 2;
1261 };
1262 let mut settings = match crate::settings::load_settings() {
1263 Ok(settings) => settings,
1264 Err(error) => {
1265 eprintln!("error: refusing to change unreadable package settings: {error}");
1266 return 1;
1267 }
1268 };
1269 let Some(packages) = settings.packages.as_mut() else {
1270 println!("Pi package is not enabled: {spec}");
1271 return 0;
1272 };
1273 let before = packages.len();
1274 packages.retain(|existing| existing.source() != spec);
1275 if packages.len() == before {
1276 println!("Pi package is not enabled: {spec}");
1277 return 0;
1278 }
1279 if packages.is_empty() {
1280 settings.packages = None;
1281 }
1282 if let Err(error) = crate::settings::save_settings(&settings) {
1283 eprintln!("error: could not save package settings: {error}");
1284 return 1;
1285 }
1286 println!("disabled Pi package {spec}");
1287 0
1288 }
1289 "update" => {
1290 let project_trusted = match package_command_project_trusted(&cwd, &args[1..]) {
1291 Ok(trusted) => trusted,
1292 Err(error) => {
1293 eprintln!("error: {error}");
1294 return 2;
1295 }
1296 };
1297 update_packages(&cwd, project_trusted)
1298 }
1299 "help" | "--help" | "-h" => {
1300 print_help();
1301 0
1302 }
1303 other => {
1304 eprintln!("error: unknown package command `{other}`");
1305 print_help();
1306 2
1307 }
1308 }
1309}
1310
1311fn print_help() {
1312 println!(
1313 "Usage: rpi package <command>\n\nCommands:\n list [--json] [--approve|--no-approve]\n List enabled TS packages and installed Rust extensions\n add <path-or-name> Enable a local/package.json package\n remove <path-or-name>\n Disable a Pi package\n update [--approve|--no-approve] [--offline]\n Update TS npm/git packages and Rust crates.io extensions\n\nProject packages are read only when the project has a saved trust decision or --approve is supplied. TS package resources are loaded from skills/, prompts/, themes/, SYSTEM.md, APPEND_SYSTEM.md, and extensions. Rust-native extensions are installed with `rpi install`."
1314 );
1315}
1316
1317fn package_command_project_trusted(cwd: &Path, args: &[String]) -> Result<bool, String> {
1318 let mut override_value = None;
1319 for arg in args {
1320 let value = match arg.as_str() {
1321 "--approve" | "-a" => Some(true),
1322 "--no-approve" | "-na" => Some(false),
1323 "--json" => None,
1324 value => return Err(format!("unknown package option `{value}`")),
1325 };
1326 if let Some(value) = value {
1327 if override_value.replace(value).is_some() {
1328 return Err("--approve and --no-approve cannot be combined or repeated".to_string());
1329 }
1330 }
1331 }
1332 if let Some(value) = override_value {
1333 return Ok(value);
1334 }
1335 Ok(crate::config::project_trust_decision(cwd)
1336 .map_err(|error| error.to_string())?
1337 .unwrap_or(false))
1338}
1339
1340fn update_packages(cwd: &Path, project_trusted: bool) -> i32 {
1341 if crate::args::offline_env_enabled() {
1342 println!("package update skipped: offline mode is enabled");
1343 return 0;
1344 }
1345 let native = match crate::install::installed_native_packages_strict() {
1350 Ok(packages) => packages,
1351 Err(error) => {
1352 eprintln!(
1353 "error: refusing package update while native package metadata is invalid: {error}"
1354 );
1355 return 1;
1356 }
1357 };
1358 let (resources, preflight_npm_command) =
1363 match discover_from_settings_for_update(cwd, project_trusted) {
1364 Ok(result) => result,
1365 Err(error) => {
1366 eprintln!("error: refusing package update with unreadable settings: {error}");
1367 return 1;
1368 }
1369 };
1370 let blocked = resources
1371 .diagnostics
1372 .iter()
1373 .filter(|diagnostic| diagnostic.blocks_update)
1374 .count();
1375 for diagnostic in &resources.diagnostics {
1376 eprintln!(
1377 "warning: could not load package {}: {}",
1378 diagnostic.spec, diagnostic.message
1379 );
1380 }
1381 if blocked > 0 {
1382 eprintln!("error: refusing a partial package update after discovery failures");
1383 return 1;
1384 }
1385 let needs_package_command = resources.packages.iter().any(|package| {
1386 package.updateable_npm_source().is_some() || package.updateable_git_source()
1387 });
1388 let npm_command = if needs_package_command {
1389 match preflight_npm_command {
1390 Some(command) => Some(command),
1391 None => {
1392 eprintln!("error: package update command was not validated before discovery");
1393 return 1;
1394 }
1395 }
1396 } else {
1397 None
1398 };
1399 let mut failed = 0;
1400 if resources.packages.is_empty() && native.is_empty() {
1401 println!("no Pi packages enabled");
1402 return 0;
1403 }
1404 let mut updated = 0;
1405 let mut skipped = 0;
1406 for package in native {
1407 if package.source.is_some() {
1408 println!(
1409 "skipped local Rust package {} (no registry source)",
1410 package.name
1411 );
1412 skipped += 1;
1413 continue;
1414 }
1415 let args = vec![package.name.clone(), "--force".to_string()];
1416 if crate::install::run(&args) == 0 {
1417 updated += 1;
1418 } else {
1419 eprintln!("warning: could not update Rust package {}", package.name);
1420 failed += 1;
1421 }
1422 }
1423
1424 let mut standalone_npm = Vec::new();
1425 let mut npm_store_roots: BTreeMap<PathBuf, Vec<(String, String)>> = BTreeMap::new();
1426 let mut git_updates = Vec::new();
1427 for package in resources.packages {
1428 if let Some((name, source_spec)) = package.updateable_npm_source() {
1429 match package.npm_store_root_for_update(cwd, project_trusted) {
1430 Ok(Some(install_root)) => {
1431 npm_store_roots
1432 .entry(install_root)
1433 .or_default()
1434 .push((name.to_string(), source_spec.to_string()));
1435 }
1436 Ok(None) => {
1437 standalone_npm.push((
1438 package.root.clone(),
1439 package.name.clone(),
1440 name.to_string(),
1441 source_spec.to_string(),
1442 ));
1443 }
1444 Err(error) => {
1445 eprintln!(
1446 "warning: could not plan update for {}: {error}",
1447 package.name
1448 );
1449 failed += 1;
1450 }
1451 }
1452 } else if package.updateable_git_source() {
1453 git_updates.push(package);
1454 } else {
1455 println!(
1456 "skipped package {} (not an unpinned npm source)",
1457 package.name
1458 );
1459 skipped += 1;
1460 }
1461 }
1462
1463 let npm_update_count = standalone_npm.len()
1464 + npm_store_roots
1465 .values()
1466 .map(std::vec::Vec::len)
1467 .sum::<usize>();
1468 let git_update_count = git_updates.len();
1469 if npm_update_count > 0 || git_update_count > 0 {
1470 match npm_command.as_ref() {
1471 Some(npm_command) => {
1472 for (root, display_name, name, source_spec) in standalone_npm {
1473 match crate::install_pi::update_npm_package(
1474 &root,
1475 &name,
1476 &source_spec,
1477 &npm_command,
1478 ) {
1479 Ok(_) => {
1480 println!("updated npm package {display_name}");
1481 updated += 1;
1482 }
1483 Err(error) => {
1484 eprintln!("warning: could not update {display_name}: {error}");
1485 failed += 1;
1486 }
1487 }
1488 }
1489 for (root, packages) in npm_store_roots {
1490 match crate::install_pi::update_npm_store_root(
1491 &root,
1492 &packages,
1493 &npm_command,
1494 cwd,
1495 project_trusted,
1496 ) {
1497 Ok(()) => {
1498 for (name, _) in &packages {
1499 println!("updated npm package {name}");
1500 }
1501 updated += packages.len();
1502 }
1503 Err(error) => {
1504 let names = packages
1505 .iter()
1506 .map(|(name, _)| name.as_str())
1507 .collect::<Vec<_>>()
1508 .join(", ");
1509 eprintln!(
1510 "warning: could not update npm packages {names} in {}: {error}",
1511 root.display()
1512 );
1513 failed += packages.len();
1514 }
1515 }
1516 }
1517 for package in git_updates {
1518 if package.missing_install {
1519 match crate::install_pi::install_missing_git_package(
1520 cwd,
1521 package.scope == ResolveScope::User,
1522 &package.spec,
1523 &npm_command,
1524 ) {
1525 Ok(_) => {
1526 println!("updated git package {}", package.name);
1527 updated += 1;
1528 }
1529 Err(error) => {
1530 eprintln!("warning: could not update {}: {error}", package.name);
1531 failed += 1;
1532 }
1533 }
1534 continue;
1535 }
1536 let Some(store_root) = package.safe_git_store_root(cwd) else {
1537 eprintln!(
1538 "warning: refusing to update git package {} outside a managed git store",
1539 package.name
1540 );
1541 failed += 1;
1542 continue;
1543 };
1544 match crate::install_pi::update_git_package(
1545 &package.root,
1546 &store_root,
1547 &package.spec,
1548 &npm_command,
1549 ) {
1550 Ok(()) => {
1551 println!("updated git package {}", package.name);
1552 updated += 1;
1553 }
1554 Err(error) => {
1555 eprintln!("warning: could not update {}: {error}", package.name);
1556 failed += 1;
1557 }
1558 }
1559 }
1560 }
1561 None => unreachable!("package command was preflighted for update candidates"),
1562 }
1563 }
1564 println!("package update complete: {updated} updated, {skipped} skipped");
1565 i32::from(failed > 0)
1566}
1567
1568fn is_npm_store_package_path(path: &Path, cwd: &Path, scope: ResolveScope) -> bool {
1569 npm_install_root_for_path(path, cwd, scope).is_some()
1570}
1571
1572fn npm_install_root_for_path(path: &Path, cwd: &Path, scope: ResolveScope) -> Option<PathBuf> {
1573 if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
1574 if let Some(root) = package_manager_root_for_path(path, cwd, Path::new(".pi/npm")) {
1575 return Some(root);
1576 }
1577 }
1578 if matches!(scope, ResolveScope::Any | ResolveScope::User) {
1579 if let Ok(agent) = config::agent_dir() {
1580 if let Some(root) = package_manager_root_for_path(path, &agent, Path::new("npm")) {
1581 return Some(root);
1582 }
1583 }
1584 if let Some(home) = dirs::home_dir() {
1585 if let Some(root) =
1586 package_manager_root_for_path(path, &home, Path::new(".pi/agent/npm"))
1587 {
1588 return Some(root);
1589 }
1590 }
1591 }
1592 None
1593}
1594
1595fn git_store_roots(cwd: &Path, scope: ResolveScope) -> Vec<PathBuf> {
1596 let mut roots = Vec::new();
1597 if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
1598 roots.push(cwd.join(".rpi/git"));
1599 roots.push(cwd.join(".pi/git"));
1600 }
1601 if matches!(scope, ResolveScope::Any | ResolveScope::User) {
1602 if let Ok(agent) = config::agent_dir() {
1603 roots.push(agent.join("git"));
1604 }
1605 if let Some(home) = dirs::home_dir() {
1606 roots.push(home.join(".pi/agent/git"));
1607 }
1608 }
1609 roots
1610}
1611
1612fn native_git_target_for_spec(cwd: &Path, scope: ResolveScope, git: &GitSpec) -> Option<PathBuf> {
1617 let relative = Path::new(&git.host).join(&git.path);
1618 for lexical_root in git_store_roots(cwd, scope) {
1619 let Ok(canonical_root_raw) = std::fs::canonicalize(&lexical_root) else {
1620 continue;
1621 };
1622 let canonical_root = normalize_resource_path(canonical_root_raw);
1623 if canonical_root != lexical_root {
1624 continue;
1625 }
1626 let target = lexical_root.join(&relative);
1627 let Ok(canonical_target_raw) = std::fs::canonicalize(&target) else {
1628 continue;
1629 };
1630 let canonical_target = normalize_resource_path(canonical_target_raw);
1631 if canonical_target == target
1632 && canonical_target.starts_with(&canonical_root)
1633 && canonical_target
1634 .strip_prefix(&canonical_root)
1635 .ok()
1636 .is_some_and(|value| value.components().count() == relative.components().count())
1637 && is_real_git_metadata(&canonical_target.join(".git"))
1638 {
1639 return Some(canonical_target);
1640 }
1641 }
1642 None
1643}
1644
1645fn is_native_git_package_path(path: &Path, cwd: &Path, scope: ResolveScope) -> bool {
1646 let Ok(canonical_path) = std::fs::canonicalize(path) else {
1647 return false;
1648 };
1649 let canonical_path = normalize_resource_path(canonical_path);
1650 if canonical_path != path || !is_real_git_metadata(&canonical_path.join(".git")) {
1651 return false;
1652 }
1653 git_store_roots(cwd, scope).into_iter().any(|root| {
1654 let Ok(canonical_root_raw) = std::fs::canonicalize(&root) else {
1655 return false;
1656 };
1657 let canonical_root = normalize_resource_path(canonical_root_raw);
1658 canonical_root == root
1659 && canonical_path
1660 .strip_prefix(&canonical_root)
1661 .ok()
1662 .is_some_and(|relative| relative.components().count() >= 2)
1663 })
1664}
1665
1666fn native_git_store_root_for_path(path: &Path, cwd: &Path, scope: ResolveScope) -> Option<PathBuf> {
1667 let Ok(canonical_path_raw) = std::fs::canonicalize(path) else {
1668 return None;
1669 };
1670 let canonical_path = normalize_resource_path(canonical_path_raw);
1671 if canonical_path != path || !is_real_git_metadata(&canonical_path.join(".git")) {
1672 return None;
1673 }
1674 git_store_roots(cwd, scope).into_iter().find_map(|root| {
1675 let canonical_root = normalize_resource_path(std::fs::canonicalize(&root).ok()?);
1676 if canonical_root != root {
1677 return None;
1678 }
1679 let relative = canonical_path.strip_prefix(&canonical_root).ok()?;
1680 (relative.components().count() >= 2).then_some(canonical_root)
1681 })
1682}
1683
1684fn is_direct_managed_package_root(path: &Path, cwd: &Path, scope: ResolveScope) -> Option<PathBuf> {
1685 let canonical_path = normalize_resource_path(std::fs::canonicalize(path).ok()?);
1686 if canonical_path != path || !is_real_git_metadata(&canonical_path.join(".git")) {
1687 return None;
1688 }
1689 let mut stores = Vec::new();
1690 if let Ok(agent) = config::agent_dir() {
1691 stores.push(agent.join("packages"));
1692 }
1693 if let Some(home) = dirs::home_dir() {
1694 stores.push(home.join(".pi/agent/packages"));
1695 }
1696 if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
1697 stores.push(cwd.join(".rpi/packages"));
1698 stores.push(cwd.join(".pi/packages"));
1699 }
1700 let parent = canonical_path.parent()?;
1701 stores
1702 .iter()
1703 .find(|store| {
1704 std::fs::canonicalize(store)
1705 .ok()
1706 .map(normalize_resource_path)
1707 .is_some_and(|canonical| canonical == **store)
1708 && parent == store.as_path()
1709 })
1710 .cloned()
1711}
1712
1713fn is_real_git_metadata(path: &Path) -> bool {
1714 std::fs::symlink_metadata(path)
1715 .map(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
1720 .unwrap_or(false)
1721}
1722
1723fn package_manager_root_for_path(
1728 path: &Path,
1729 base: &Path,
1730 relative_install_root: &Path,
1731) -> Option<PathBuf> {
1732 let lexical_install_root = base.join(relative_install_root);
1733 let lexical_node_modules = lexical_install_root.join("node_modules");
1734 let relative = path.strip_prefix(&lexical_node_modules).ok()?;
1735 let parts = relative
1736 .components()
1737 .map(|component| match component {
1738 std::path::Component::Normal(part) => part.to_str(),
1739 _ => None,
1740 })
1741 .collect::<Option<Vec<_>>>()?;
1742 let valid_shape = match parts.as_slice() {
1743 [name] => !name.starts_with('@') && !name.is_empty(),
1744 [scope, name] => scope.starts_with('@') && scope.len() > 1 && !name.is_empty(),
1745 _ => false,
1746 };
1747 if !valid_shape {
1748 return None;
1749 }
1750
1751 let base = std::fs::canonicalize(base).ok()?;
1752 let install_root = base.join(relative_install_root);
1753 if normalize_resource_path(std::fs::canonicalize(&install_root).ok()?)
1754 != normalize_resource_path(install_root.clone())
1755 {
1756 return None;
1757 }
1758 let node_modules = install_root.join("node_modules");
1759 if normalize_resource_path(std::fs::canonicalize(&node_modules).ok()?)
1760 != normalize_resource_path(node_modules.clone())
1761 {
1762 return None;
1763 }
1764 let canonical_package = normalize_resource_path(std::fs::canonicalize(path).ok()?);
1765 let install_root_normalized = normalize_resource_path(install_root.clone());
1766 canonical_package
1767 .starts_with(&install_root_normalized)
1768 .then_some(install_root)
1769}
1770
1771fn is_package_below_store(path: &Path, base: &Path, relative_store: &Path) -> bool {
1772 let Ok(path) = std::fs::canonicalize(path) else {
1773 return false;
1774 };
1775 let Ok(base) = std::fs::canonicalize(base) else {
1776 return false;
1777 };
1778 let Ok(store) = std::fs::canonicalize(base.join(relative_store)) else {
1779 return false;
1780 };
1781 if store != base.join(relative_store) || !store.starts_with(&base) {
1782 return false;
1783 }
1784 path.strip_prefix(store)
1785 .ok()
1786 .is_some_and(|relative| relative.components().next().is_some())
1787}
1788
1789fn is_managed_package_path(path: &Path, cwd: &Path, scope: ResolveScope) -> bool {
1790 if let Ok(agent) = config::agent_dir() {
1791 if is_package_below_store(path, &agent, Path::new("packages")) {
1792 return true;
1793 }
1794 }
1795 if let Some(home) = dirs::home_dir() {
1796 if is_package_below_store(path, &home, Path::new(".pi/agent/packages")) {
1797 return true;
1798 }
1799 }
1800 (scope == ResolveScope::Any
1801 && (is_package_below_store(path, cwd, Path::new(".rpi/packages"))
1802 || is_package_below_store(path, cwd, Path::new(".pi/packages"))))
1803 || is_project_managed_package_path(path)
1804}
1805
1806fn is_project_managed_package_path(path: &Path) -> bool {
1811 let Ok(path) = std::fs::canonicalize(path) else {
1812 return false;
1813 };
1814 let Some(store) = path.parent() else {
1815 return false;
1816 };
1817 let Some(project_config) = store.parent() else {
1818 return false;
1819 };
1820 store.file_name().is_some_and(|name| name == "packages")
1821 && project_config
1822 .file_name()
1823 .is_some_and(|name| name == ".rpi" || name == ".pi")
1824}
1825
1826impl PackageRoot {
1827 fn npm_store_root_for_update(
1828 &self,
1829 cwd: &Path,
1830 project_trusted: bool,
1831 ) -> Result<Option<PathBuf>, String> {
1832 if let Some(root) = &self.npm_install_root {
1833 return Ok(Some(root.clone()));
1834 }
1835 if self.legacy_npm_root.is_none() {
1836 return Ok(None);
1837 }
1838 if !matches!(self.source, PackageSource::Npm { .. }) {
1839 return Err(
1840 "refusing legacy npm migration without verified npm provenance".to_string(),
1841 );
1842 }
1843
1844 let root = match self.scope {
1845 ResolveScope::Project if project_trusted => cwd.join(".pi/npm"),
1846 ResolveScope::Project => {
1847 return Err(
1848 "refusing to migrate a legacy npm package for an untrusted project".to_string(),
1849 )
1850 }
1851 ResolveScope::User | ResolveScope::Any => config::agent_dir()
1852 .map_err(|error| error.to_string())?
1853 .join("npm"),
1854 };
1855 if !root.is_absolute() || root.file_name().and_then(|name| name.to_str()) != Some("npm") {
1856 return Err(format!(
1857 "refusing legacy npm migration outside a managed npm root: {}",
1858 root.display()
1859 ));
1860 }
1861 Ok(Some(root))
1862 }
1863
1864 pub(crate) fn updateable_npm_source(&self) -> Option<(&str, &str)> {
1865 match &self.source {
1866 PackageSource::Npm {
1867 name, spec, pinned, ..
1868 } if self.missing_install || !*pinned => Some((name, spec)),
1869 _ => None,
1870 }
1871 }
1872
1873 fn updateable_git_source(&self) -> bool {
1874 matches!(self.source, PackageSource::Git)
1878 }
1879
1880 fn safe_git_store_root(&self, cwd: &Path) -> Option<PathBuf> {
1881 self.git_store_root.clone().or_else(|| {
1882 is_direct_managed_package_root(&self.root, cwd, self.scope)
1885 })
1886 }
1887
1888 #[cfg(test)]
1889 pub(crate) fn updateable_npm_name(&self) -> Option<&str> {
1890 self.updateable_npm_source().map(|(name, _)| name)
1891 }
1892
1893 fn skill_dirs_for_display(&self) -> Vec<PathBuf> {
1894 self.skills.clone()
1895 }
1896
1897 fn prompt_dirs_for_display(&self) -> Vec<PathBuf> {
1898 self.prompts.clone()
1899 }
1900
1901 fn theme_files_for_display(&self) -> Vec<PathBuf> {
1902 if self.themes.len() == 1 && self.themes[0].is_dir() {
1903 let mut files: Vec<PathBuf> = std::fs::read_dir(&self.themes[0])
1904 .ok()
1905 .into_iter()
1906 .flatten()
1907 .filter_map(Result::ok)
1908 .map(|entry| entry.path())
1909 .filter(|file| {
1910 file.is_file() && file.extension().and_then(|ext| ext.to_str()) == Some("json")
1911 })
1912 .collect();
1913 files.sort();
1914 files
1915 } else {
1916 self.themes.clone()
1917 }
1918 }
1919}
1920
1921#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1922enum ResolveScope {
1923 Any,
1924 Project,
1925 User,
1926}
1927
1928#[derive(Debug)]
1929struct ResolvedPackagePath {
1930 root: PathBuf,
1931 legacy_npm_root: Option<PathBuf>,
1934}
1935
1936fn resolve_spec(cwd: &Path, spec: &str, scope: ResolveScope) -> Option<PathBuf> {
1937 resolve_spec_with_legacy_lookup(cwd, spec, scope, |_| None).map(|resolved| resolved.root)
1938}
1939
1940fn resolve_spec_with_command(
1941 cwd: &Path,
1942 spec: &str,
1943 scope: ResolveScope,
1944 global_npm_command: Option<&crate::npm::NpmCommand>,
1945 legacy_npm_names: &[String],
1946 legacy_npm_paths: &mut Option<HashMap<String, PathBuf>>,
1947) -> Option<ResolvedPackagePath> {
1948 resolve_spec_with_legacy_lookup(cwd, spec, scope, |package_name| {
1949 let command = global_npm_command?;
1950 let paths = legacy_npm_paths.get_or_insert_with(|| {
1951 command
1952 .global_package_paths(legacy_npm_names)
1953 .unwrap_or_default()
1954 });
1955 paths.get(package_name).cloned()
1956 })
1957}
1958
1959fn resolve_spec_with_legacy_lookup(
1960 cwd: &Path,
1961 spec: &str,
1962 scope: ResolveScope,
1963 legacy_lookup: impl FnOnce(&str) -> Option<PathBuf>,
1964) -> Option<ResolvedPackagePath> {
1965 let file_spec = spec.strip_prefix("file:");
1966 let raw = file_spec.unwrap_or(spec);
1967 let npm_spec = raw.strip_prefix("npm:");
1971 let npm_name = npm_spec.unwrap_or(raw);
1972 let package_name = match npm_spec {
1973 Some(spec) => parse_npm_package_spec(spec)?.install_name,
1974 None => package_name_without_version(npm_name).to_string(),
1975 };
1976 let package_key = package_name
1977 .strip_prefix('@')
1978 .unwrap_or(&package_name)
1979 .replace('/', "__");
1980 let direct = PathBuf::from(npm_name);
1981 let mut candidates = Vec::new();
1982 if direct.is_absolute() {
1983 candidates.push(direct);
1984 } else {
1985 let explicit_relative_path =
1986 file_spec.is_some() || npm_name.starts_with('.') || npm_name.starts_with("./");
1987 if explicit_relative_path {
1988 if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
1989 candidates.push(cwd.join(".rpi").join(&direct));
1993 candidates.push(cwd.join(".pi").join(&direct));
1994 candidates.push(cwd.join(&direct));
1997 } else {
1998 if let Ok(agent) = config::agent_dir() {
1999 candidates.push(agent.join(&direct));
2000 }
2001 if let Some(home) = dirs::home_dir() {
2002 candidates.push(home.join(".pi/agent").join(&direct));
2003 }
2004 }
2005 }
2006 if let Some(git) = parse_git_source(spec) {
2007 if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
2008 for relative_root in [Path::new(".rpi/git"), Path::new(".pi/git")] {
2009 candidates.push(cwd.join(relative_root).join(&git.host).join(&git.path));
2010 }
2011 }
2012 if matches!(scope, ResolveScope::Any | ResolveScope::User) {
2013 if let Ok(agent) = config::agent_dir() {
2014 candidates.push(agent.join("git").join(&git.host).join(&git.path));
2015 }
2016 if let Some(home) = dirs::home_dir() {
2017 candidates.push(home.join(".pi/agent/git").join(&git.host).join(&git.path));
2018 }
2019 }
2020 }
2021 if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
2025 candidates.push(cwd.join(".rpi/packages").join(&package_name));
2026 if package_key != package_name {
2027 candidates.push(cwd.join(".rpi/packages").join(&package_key));
2028 }
2029 candidates.push(cwd.join(".pi/packages").join(&package_name));
2030 if package_key != package_name {
2031 candidates.push(cwd.join(".pi/packages").join(&package_key));
2032 }
2033 if npm_spec.is_some() {
2034 candidates.push(cwd.join(".pi/npm/node_modules").join(&package_name));
2035 } else if scope == ResolveScope::Any {
2036 for ancestor in cwd.ancestors() {
2037 candidates.push(ancestor.join("node_modules").join(&package_name));
2038 }
2039 }
2040 }
2041 if matches!(scope, ResolveScope::Any | ResolveScope::User) {
2042 if let Ok(agent) = config::agent_dir() {
2043 candidates.push(agent.join("packages").join(&package_name));
2044 if package_key != package_name {
2045 candidates.push(agent.join("packages").join(&package_key));
2046 }
2047 candidates.push(agent.join("npm/node_modules").join(&package_name));
2051 if package_key != package_name {
2052 candidates.push(agent.join("npm/node_modules").join(&package_key));
2053 }
2054 }
2055 if let Some(home) = dirs::home_dir() {
2056 candidates.push(home.join(".pi/agent/packages").join(&package_name));
2059 if package_key != package_name {
2060 candidates.push(home.join(".pi/agent/packages").join(&package_key));
2061 }
2062 candidates.push(home.join(".pi/agent/npm/node_modules").join(&package_name));
2063 if package_key != package_name {
2064 candidates.push(home.join(".pi/agent/npm/node_modules").join(&package_key));
2065 }
2066 }
2067 }
2068 if scope == ResolveScope::Any && npm_spec.is_none() && !explicit_relative_path {
2069 candidates.push(cwd.join(&package_name));
2070 }
2071 }
2072 for candidate in candidates {
2073 if candidate.is_file()
2074 && candidate.file_name().and_then(|s| s.to_str()) == Some("package.json")
2075 {
2076 return candidate.parent().map(|root| ResolvedPackagePath {
2077 root: root.to_path_buf(),
2078 legacy_npm_root: None,
2079 });
2080 }
2081 if candidate.is_dir() {
2082 return Some(ResolvedPackagePath {
2083 root: candidate,
2084 legacy_npm_root: None,
2085 });
2086 }
2087 }
2088
2089 if npm_spec.is_some() && matches!(scope, ResolveScope::Any | ResolveScope::User) {
2094 let reported = legacy_lookup(&package_name)?;
2095 let reported = std::fs::canonicalize(reported).ok()?;
2096 let install_root = global_node_modules_root(&reported)?;
2097 let root =
2101 crate::npm::NpmCommand::validate_global_package_path(&install_root, &package_name)?;
2102 if root != reported {
2103 return None;
2104 }
2105 return Some(ResolvedPackagePath {
2106 root,
2107 legacy_npm_root: Some(install_root),
2108 });
2109 }
2110 None
2111}
2112
2113fn global_node_modules_root(package: &Path) -> Option<PathBuf> {
2114 let mut current = package.parent()?;
2115 loop {
2116 if current.file_name().and_then(|name| name.to_str()) == Some("node_modules") {
2117 return std::fs::canonicalize(current).ok().filter(|root| {
2118 root.is_absolute()
2119 && root.file_name().and_then(|name| name.to_str()) == Some("node_modules")
2120 });
2121 }
2122 current = current.parent()?;
2123 }
2124}
2125
2126fn package_name_without_version(name: &str) -> &str {
2128 if let Some(rest) = name.strip_prefix('@') {
2129 rest.find('@')
2130 .map(|index| &name[..index + 1])
2131 .unwrap_or(name)
2132 } else {
2133 name.split('@').next().unwrap_or(name)
2134 }
2135}
2136
2137fn package_identity(package: &PackageRoot) -> String {
2143 match &package.source {
2144 PackageSource::Npm { name, .. } => format!("npm:{}", name.to_ascii_lowercase()),
2145 PackageSource::Git => parse_git_source(&package.spec)
2146 .map(|git| format!("git:{}/{}", git.host, git.path))
2147 .unwrap_or_else(|| format!("git:path:{}", normalize_key(&package.root))),
2148 PackageSource::Local => format!("local:{}", normalize_key(&package.root)),
2149 PackageSource::Unknown => format!("unknown:{}", normalize_key(&package.root)),
2150 }
2151}
2152
2153#[derive(Debug, Clone, PartialEq, Eq)]
2154pub(crate) struct GitSpec {
2155 pub(crate) host: String,
2156 pub(crate) path: String,
2157 pub(crate) revision: Option<String>,
2158 pub(crate) transport: GitTransport,
2159 pub(crate) port: Option<u16>,
2160 pub(crate) user_info: Option<String>,
2161}
2162
2163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2164pub(crate) enum GitTransport {
2165 Http,
2166 Https,
2167 Ssh,
2168 Git,
2169}
2170
2171impl GitTransport {
2172 pub(crate) fn default_port(self) -> u16 {
2173 match self {
2174 Self::Http => 80,
2175 Self::Https => 443,
2176 Self::Ssh => 22,
2177 Self::Git => 9418,
2178 }
2179 }
2180}
2181
2182pub(crate) fn parse_git_source(spec: &str) -> Option<GitSpec> {
2183 let trimmed = spec.trim();
2184 if trimmed.is_empty() {
2185 return None;
2186 }
2187
2188 let raw = match trimmed.strip_prefix("git:") {
2191 Some(rest) if !rest.starts_with("//") => rest.trim(),
2192 _ => trimmed,
2193 };
2194 if raw.is_empty() {
2195 return None;
2196 }
2197
2198 let (repo, revision) = split_git_ref(raw);
2202 let (mut host, mut path, transport, port, user_info) =
2203 if let Some(scheme_end) = repo.find("://") {
2204 let scheme = repo[..scheme_end].to_ascii_lowercase();
2205 let transport = match scheme.as_str() {
2206 "http" => GitTransport::Http,
2207 "https" => GitTransport::Https,
2208 "ssh" => GitTransport::Ssh,
2209 "git" => GitTransport::Git,
2210 _ => return None,
2211 };
2212 let authority_and_path = &repo[scheme_end + 3..];
2213 let (authority, path) = authority_and_path.split_once('/')?;
2214 let (host, port, user_info) = parse_git_authority(authority)?;
2215 (host, path.to_string(), transport, port, user_info)
2216 } else if let Some(rest) = repo.strip_prefix("git@") {
2217 let (host, path) = rest.split_once(':')?;
2218 (
2219 normalize_git_host(host)?,
2220 path.to_string(),
2221 GitTransport::Ssh,
2222 None,
2223 Some("git".to_string()),
2224 )
2225 } else {
2226 let (host, path) = repo.split_once('/')?;
2228 (
2229 normalize_git_host(host)?,
2230 path.to_string(),
2231 GitTransport::Https,
2232 None,
2233 None,
2234 )
2235 };
2236
2237 host.make_ascii_lowercase();
2238 while path.starts_with('/') {
2239 path.remove(0);
2240 }
2241 if path.ends_with(".git") {
2242 path.truncate(path.len() - 4);
2243 }
2244 let path = path.trim_matches('/').to_string();
2245
2246 if !safe_git_install_part(&host, false)
2247 || !safe_git_install_part(&path, true)
2248 || path.split('/').count() < 2
2249 {
2250 return None;
2251 }
2252 if revision
2253 .as_deref()
2254 .is_some_and(|value| !safe_git_revision(value))
2255 {
2256 return None;
2257 }
2258
2259 Some(GitSpec {
2260 host,
2261 path,
2262 revision,
2263 transport,
2264 port,
2265 user_info,
2266 })
2267}
2268
2269fn split_git_ref(raw: &str) -> (String, Option<String>) {
2272 let path_start = if raw.starts_with("git@") {
2273 raw.find(':').map(|index| index + 1)
2274 } else if let Some(scheme_end) = raw.find("://") {
2275 let authority_start = scheme_end + 3;
2276 raw[authority_start..]
2277 .find('/')
2278 .map(|index| authority_start + index + 1)
2279 } else {
2280 raw.find('/').map(|index| index + 1)
2281 };
2282 let Some(path_start) = path_start else {
2283 return (raw.to_string(), None);
2284 };
2285 let Some(offset) = raw[path_start..].find('@') else {
2286 return (raw.to_string(), None);
2287 };
2288 let separator = path_start + offset;
2289 let repo = &raw[..separator];
2290 let revision = &raw[separator + 1..];
2291 if repo.is_empty() || revision.is_empty() {
2292 return (raw.to_string(), None);
2293 }
2294 (repo.to_string(), Some(revision.to_string()))
2295}
2296
2297fn normalize_git_host(authority: &str) -> Option<String> {
2298 if authority != authority.trim() {
2299 return None;
2300 }
2301 let authority = authority.trim();
2302 if authority.is_empty() {
2303 return None;
2304 }
2305 let host = if authority.starts_with('[') {
2308 let end = authority.find(']')?;
2309 if !authority[end + 1..].is_empty() {
2310 let suffix = &authority[end + 1..];
2311 if !suffix.starts_with(':') || !suffix[1..].bytes().all(|byte| byte.is_ascii_digit()) {
2312 return None;
2313 }
2314 }
2315 &authority[1..end]
2316 } else {
2317 authority
2318 .rsplit_once(':')
2319 .filter(|(_, port)| !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit()))
2320 .map_or(authority, |(host, _)| host)
2321 };
2322 Some(host.to_string())
2323}
2324
2325fn parse_git_authority(authority: &str) -> Option<(String, Option<u16>, Option<String>)> {
2326 if authority.is_empty() || authority != authority.trim() {
2327 return None;
2328 }
2329 let authority = authority.trim();
2330 let (user_info, host_and_port) = match authority.rsplit_once('@') {
2331 Some((user_info, host_and_port)) => {
2332 if user_info.is_empty()
2333 || user_info.contains('\\')
2334 || user_info
2335 .chars()
2336 .any(|character| character.is_control() || character.is_whitespace())
2337 {
2338 return None;
2339 }
2340 (Some(user_info.to_string()), host_and_port)
2341 }
2342 None => (None, authority),
2343 };
2344 let (host, port) = if host_and_port.starts_with('[') {
2345 let end = host_and_port.find(']')?;
2346 let suffix = &host_and_port[end + 1..];
2347 let port = if suffix.is_empty() {
2348 None
2349 } else {
2350 suffix.strip_prefix(':')?.parse::<u16>().ok()
2351 };
2352 (&host_and_port[..=end], port)
2353 } else if let Some((host, port)) = host_and_port.rsplit_once(':') {
2354 if port.is_empty() || !port.bytes().all(|byte| byte.is_ascii_digit()) {
2355 return None;
2356 }
2357 (host, Some(port.parse::<u16>().ok()?))
2358 } else {
2359 (host_and_port, None)
2360 };
2361 Some((normalize_git_host(host)?, port, user_info))
2362}
2363
2364fn safe_git_install_part(value: &str, allow_slash: bool) -> bool {
2365 let Some(decoded) = percent_decode_for_validation(value) else {
2366 return false;
2367 };
2368 for candidate in [value, decoded.as_str()] {
2369 if candidate.is_empty()
2370 || candidate.contains('\0')
2371 || candidate.contains('\\')
2372 || candidate.starts_with('/')
2373 || candidate
2374 .chars()
2375 .any(|ch| ch.is_control() || ch.is_whitespace())
2376 || candidate
2377 .chars()
2378 .any(|ch| matches!(ch, ':' | '?' | '*' | '[' | ']' | '<' | '>' | '|' | '"'))
2379 {
2380 return false;
2381 }
2382 if !allow_slash && candidate.contains('/') {
2383 return false;
2384 }
2385 if candidate
2386 .split('/')
2387 .any(|part| part.is_empty() || part == "." || part == "..")
2388 {
2389 return false;
2390 }
2391 }
2392 true
2393}
2394
2395fn safe_git_revision(value: &str) -> bool {
2396 let Some(decoded) = percent_decode_for_validation(value) else {
2397 return false;
2398 };
2399 for candidate in [value, decoded.as_str()] {
2400 if candidate.is_empty()
2401 || candidate.starts_with('-')
2402 || candidate.starts_with('/')
2403 || candidate.ends_with('/')
2404 || candidate.contains('\0')
2405 || candidate.contains('\\')
2406 || candidate.contains("..")
2407 || candidate.contains("@{")
2408 || candidate.chars().any(|ch| {
2409 ch.is_control()
2410 || ch.is_whitespace()
2411 || matches!(ch, '~' | '^' | ':' | '?' | '*' | '[')
2412 })
2413 || candidate
2414 .split('/')
2415 .any(|part| part.is_empty() || part == "." || part == "..")
2416 {
2417 return false;
2418 }
2419 }
2420 true
2421}
2422
2423fn percent_decode_for_validation(value: &str) -> Option<String> {
2424 let bytes = value.as_bytes();
2425 let mut decoded = Vec::with_capacity(bytes.len());
2426 let mut index = 0;
2427 while index < bytes.len() {
2428 if bytes[index] == b'%' {
2429 if index + 2 >= bytes.len() {
2430 return None;
2431 }
2432 let high = hex_value(bytes[index + 1])?;
2433 let low = hex_value(bytes[index + 2])?;
2434 decoded.push((high << 4) | low);
2435 index += 3;
2436 } else {
2437 decoded.push(bytes[index]);
2438 index += 1;
2439 }
2440 }
2441 String::from_utf8(decoded).ok()
2442}
2443
2444fn hex_value(value: u8) -> Option<u8> {
2445 match value {
2446 b'0'..=b'9' => Some(value - b'0'),
2447 b'a'..=b'f' => Some(value - b'a' + 10),
2448 b'A'..=b'F' => Some(value - b'A' + 10),
2449 _ => None,
2450 }
2451}
2452
2453fn safe_resource_path(root: &Path, value: &str) -> Option<PathBuf> {
2454 safe_resource_path_from(root, root, value)
2455}
2456
2457fn safe_resource_path_from(boundary: &Path, base: &Path, value: &str) -> Option<PathBuf> {
2458 let relative = Path::new(value.trim());
2459 if relative.as_os_str().is_empty() || relative.is_absolute() {
2460 return None;
2461 }
2462 let base = normalize_resource_path(std::fs::canonicalize(base).ok()?);
2463 let candidate = normalize_resource_path(base.join(relative));
2464 validated_resource_path(boundary, &candidate).map(|_| candidate)
2465}
2466
2467fn validated_resource_path(boundary: &Path, candidate: &Path) -> Option<PathBuf> {
2468 let boundary = normalize_resource_path(std::fs::canonicalize(boundary).ok()?);
2469 let canonical = normalize_resource_path(std::fs::canonicalize(candidate).ok()?);
2470 resource_path_is_within(&canonical, &boundary).then_some(canonical)
2471}
2472
2473fn resource_path_is_within(path: &Path, root: &Path) -> bool {
2474 #[cfg(not(windows))]
2475 {
2476 path.starts_with(root)
2477 }
2478 #[cfg(windows)]
2479 {
2480 let path: Vec<String> = path
2481 .components()
2482 .map(|part| part.as_os_str().to_string_lossy().to_lowercase())
2483 .collect();
2484 let root: Vec<String> = root
2485 .components()
2486 .map(|part| part.as_os_str().to_string_lossy().to_lowercase())
2487 .collect();
2488 path.len() >= root.len() && path[..root.len()] == root
2489 }
2490}
2491
2492fn normalize_resource_path(path: PathBuf) -> PathBuf {
2493 #[cfg(windows)]
2494 {
2495 let text = path.to_string_lossy();
2496 if let Some(stripped) = text.strip_prefix(r"\\?\UNC\") {
2497 return PathBuf::from(format!(r"\\{stripped}"));
2498 }
2499 if let Some(stripped) = text.strip_prefix(r"\\?\") {
2500 return PathBuf::from(stripped);
2501 }
2502 }
2503 path
2504}
2505
2506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2507enum FilterResourceKind {
2508 Extensions,
2509 Skills,
2510 Prompts,
2511 Themes,
2512}
2513
2514fn apply_package_filter(
2515 root: &Path,
2516 extensions: &mut Vec<PathBuf>,
2517 skills: &mut Vec<PathBuf>,
2518 prompts: &mut Vec<PathBuf>,
2519 themes: &mut Vec<PathBuf>,
2520 filter: &crate::settings::PackageFilter,
2521) {
2522 *extensions = filter_paths(
2523 root,
2524 extensions,
2525 filter.extensions.as_deref(),
2526 filter.autoload,
2527 FilterResourceKind::Extensions,
2528 );
2529 *skills = filter_paths(
2530 root,
2531 skills,
2532 filter.skills.as_deref(),
2533 filter.autoload,
2534 FilterResourceKind::Skills,
2535 );
2536 *prompts = filter_paths(
2537 root,
2538 prompts,
2539 filter.prompts.as_deref(),
2540 filter.autoload,
2541 FilterResourceKind::Prompts,
2542 );
2543 *themes = filter_paths(
2544 root,
2545 themes,
2546 filter.themes.as_deref(),
2547 filter.autoload,
2548 FilterResourceKind::Themes,
2549 );
2550}
2551
2552fn filter_paths(
2553 root: &Path,
2554 defaults: &[PathBuf],
2555 patterns: Option<&[String]>,
2556 autoload: Option<bool>,
2557 kind: FilterResourceKind,
2558) -> Vec<PathBuf> {
2559 let Some(patterns) = patterns else {
2560 return if autoload == Some(false) {
2561 Vec::new()
2562 } else {
2563 defaults.to_vec()
2564 };
2565 };
2566 if patterns.is_empty() && autoload != Some(false) {
2567 return Vec::new();
2570 }
2571 let pattern_root =
2572 normalize_resource_path(std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()));
2573 let all = resource_inventory(&pattern_root, defaults, kind);
2574 if autoload == Some(false) {
2575 let mut enabled = HashSet::new();
2576 for pattern in patterns {
2577 let (mode, target) = pattern_mode(pattern);
2578 let exact = matches!(mode, PatternMode::ForceInclude | PatternMode::ForceExclude);
2579 for path in &all {
2580 if matches_resource_pattern(path, &pattern_root, target, exact, kind) {
2581 match mode {
2582 PatternMode::Exclude | PatternMode::ForceExclude => {
2583 enabled.remove(path);
2584 }
2585 PatternMode::Include | PatternMode::ForceInclude => {
2586 enabled.insert(path.clone());
2587 }
2588 }
2589 }
2590 }
2591 }
2592 return sorted_paths(enabled.into_iter().collect());
2593 }
2594 apply_resource_patterns(&all, patterns, &pattern_root, kind)
2595}
2596
2597fn apply_autoload_delta_to_package(
2598 package: &mut PackageRoot,
2599 filter: &crate::settings::PackageFilter,
2600) {
2601 if filter.autoload != Some(false) {
2602 return;
2603 }
2604 if let Some(patterns) = filter.extensions.as_deref() {
2605 package.extensions = apply_delta_paths(
2606 &package.root,
2607 &package.extensions,
2608 patterns,
2609 FilterResourceKind::Extensions,
2610 );
2611 }
2612 if let Some(patterns) = filter.skills.as_deref() {
2613 package.skills = apply_delta_paths(
2614 &package.root,
2615 &package.skills,
2616 patterns,
2617 FilterResourceKind::Skills,
2618 );
2619 }
2620 if let Some(patterns) = filter.prompts.as_deref() {
2621 package.prompts = apply_delta_paths(
2622 &package.root,
2623 &package.prompts,
2624 patterns,
2625 FilterResourceKind::Prompts,
2626 );
2627 }
2628 if let Some(patterns) = filter.themes.as_deref() {
2629 package.themes = apply_delta_paths(
2630 &package.root,
2631 &package.themes,
2632 patterns,
2633 FilterResourceKind::Themes,
2634 );
2635 }
2636}
2637
2638fn apply_delta_paths(
2639 root: &Path,
2640 current: &[PathBuf],
2641 patterns: &[String],
2642 kind: FilterResourceKind,
2643) -> Vec<PathBuf> {
2644 if patterns.is_empty() {
2645 return current.to_vec();
2646 }
2647 let pattern_root =
2648 normalize_resource_path(std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()));
2649 let all = resource_inventory(&pattern_root, current, kind);
2650 let mut selected: HashSet<PathBuf> = current
2651 .iter()
2652 .filter_map(|path| {
2653 if path.is_file() {
2654 Some(normalize_resource_path(path.clone()))
2655 } else {
2656 None
2657 }
2658 })
2659 .collect();
2660 if selected.is_empty() && current.iter().any(|path| path.is_dir()) {
2664 selected.extend(all.iter().cloned());
2665 }
2666 for pattern in patterns {
2667 let (mode, target) = pattern_mode(pattern);
2668 let exact = matches!(mode, PatternMode::ForceInclude | PatternMode::ForceExclude);
2669 for path in &all {
2670 if !matches_resource_pattern(path, &pattern_root, target, exact, kind) {
2671 continue;
2672 }
2673 match mode {
2674 PatternMode::Exclude | PatternMode::ForceExclude => {
2675 selected.remove(path);
2676 }
2677 PatternMode::Include | PatternMode::ForceInclude => {
2678 selected.insert(path.clone());
2679 }
2680 }
2681 }
2682 }
2683 sorted_paths(selected.into_iter().collect())
2684}
2685
2686#[derive(Debug, Clone, Copy)]
2687enum PatternMode {
2688 Include,
2689 Exclude,
2690 ForceInclude,
2691 ForceExclude,
2692}
2693
2694fn pattern_mode(pattern: &str) -> (PatternMode, &str) {
2695 if let Some(value) = pattern.strip_prefix('+') {
2696 (PatternMode::ForceInclude, value)
2697 } else if let Some(value) = pattern.strip_prefix('-') {
2698 (PatternMode::ForceExclude, value)
2699 } else if let Some(value) = pattern.strip_prefix('!') {
2700 (PatternMode::Exclude, value)
2701 } else {
2702 (PatternMode::Include, pattern)
2703 }
2704}
2705
2706fn resource_inventory(root: &Path, defaults: &[PathBuf], kind: FilterResourceKind) -> Vec<PathBuf> {
2707 let Ok(boundary) = std::fs::canonicalize(root).map(normalize_resource_path) else {
2708 return Vec::new();
2709 };
2710 let mut out = HashSet::new();
2711 let mut visited = HashSet::new();
2712 for path in defaults {
2713 collect_resource_files(&boundary, path, kind, &mut out, &mut visited);
2714 }
2715 sorted_paths(out.into_iter().collect())
2716}
2717
2718fn collect_resource_files(
2719 boundary: &Path,
2720 path: &Path,
2721 kind: FilterResourceKind,
2722 out: &mut HashSet<PathBuf>,
2723 visited: &mut HashSet<PathBuf>,
2724) {
2725 let Some(canonical) = validated_resource_path(boundary, path) else {
2726 return;
2727 };
2728 let Ok(metadata) = std::fs::metadata(path) else {
2729 return;
2730 };
2731 if metadata.is_file() {
2732 if valid_resource_file(path, kind) {
2733 out.insert(canonical);
2734 }
2735 return;
2736 }
2737 if !metadata.is_dir() || !visited.insert(canonical) {
2738 return;
2739 }
2740 match kind {
2741 FilterResourceKind::Extensions => collect_extension_directory(boundary, path, out, visited),
2742 FilterResourceKind::Skills => collect_skill_directory(boundary, path, path, out, visited),
2743 FilterResourceKind::Prompts | FilterResourceKind::Themes => {
2744 collect_recursive_resource_directory(boundary, path, kind, out, visited)
2745 }
2746 }
2747}
2748
2749fn valid_resource_file(path: &Path, kind: FilterResourceKind) -> bool {
2750 match kind {
2751 FilterResourceKind::Extensions => matches!(
2752 path.extension().and_then(|ext| ext.to_str()),
2753 Some("js" | "ts")
2754 ),
2755 FilterResourceKind::Skills | FilterResourceKind::Prompts => {
2756 path.extension().and_then(|ext| ext.to_str()) == Some("md")
2757 }
2758 FilterResourceKind::Themes => path.extension().and_then(|ext| ext.to_str()) == Some("json"),
2759 }
2760}
2761
2762fn collect_extension_directory(
2763 boundary: &Path,
2764 dir: &Path,
2765 out: &mut HashSet<PathBuf>,
2766 visited: &mut HashSet<PathBuf>,
2767) {
2768 if let Some(entries) = extension_manifest_entries(dir) {
2769 let resolved = resolve_manifest_resources(
2770 boundary,
2771 dir,
2772 &entries,
2773 FilterResourceKind::Extensions,
2774 visited,
2775 );
2776 if resolved.had_source {
2777 out.extend(resolved.paths);
2778 return;
2779 }
2780 }
2781
2782 for index in ["index.ts", "index.js"] {
2783 let path = dir.join(index);
2784 if let Some(canonical) = validated_resource_path(boundary, &path).filter(|_| path.is_file())
2785 {
2786 out.insert(canonical);
2787 return;
2788 }
2789 }
2790
2791 for entry in visible_directory_entries(dir) {
2792 let path = entry.path();
2793 let Ok(metadata) = std::fs::metadata(&path) else {
2794 continue;
2795 };
2796 if metadata.is_file() {
2797 if valid_resource_file(&path, FilterResourceKind::Extensions) {
2798 if let Some(canonical) = validated_resource_path(boundary, &path) {
2799 out.insert(canonical);
2800 }
2801 }
2802 } else if metadata.is_dir() {
2803 let Some(canonical) = validated_resource_path(boundary, &path) else {
2804 continue;
2805 };
2806 if !visited.insert(canonical) {
2807 continue;
2808 }
2809 collect_extension_entry_directory(boundary, &path, out, visited);
2810 }
2811 }
2812}
2813
2814fn collect_extension_entry_directory(
2815 boundary: &Path,
2816 dir: &Path,
2817 out: &mut HashSet<PathBuf>,
2818 visited: &mut HashSet<PathBuf>,
2819) {
2820 if let Some(entries) = extension_manifest_entries(dir) {
2821 let resolved = resolve_manifest_resources(
2822 boundary,
2823 dir,
2824 &entries,
2825 FilterResourceKind::Extensions,
2826 visited,
2827 );
2828 if resolved.had_source {
2829 out.extend(resolved.paths);
2830 return;
2831 }
2832 }
2833 for index in ["index.ts", "index.js"] {
2834 let path = dir.join(index);
2835 if let Some(canonical) = validated_resource_path(boundary, &path).filter(|_| path.is_file())
2836 {
2837 out.insert(canonical);
2838 return;
2839 }
2840 }
2841}
2842
2843fn extension_manifest_entries(dir: &Path) -> Option<Vec<String>> {
2844 let manifest = std::fs::read_to_string(dir.join("package.json")).ok()?;
2845 let manifest = parse_json_with_comments(&manifest).ok()?;
2846 let rpi = manifest.get("rpi").unwrap_or(&Value::Null);
2847 let pi = manifest.get("pi").unwrap_or(&Value::Null);
2848 let entries = rpi
2849 .get("extensions")
2850 .or_else(|| pi.get("extensions"))
2851 .or_else(|| manifest.get("extensions"))
2852 .map(string_values)?;
2853 (!entries.is_empty()).then_some(entries)
2854}
2855
2856fn collect_skill_directory(
2857 boundary: &Path,
2858 dir: &Path,
2859 discovery_root: &Path,
2860 out: &mut HashSet<PathBuf>,
2861 visited: &mut HashSet<PathBuf>,
2862) {
2863 let skill_file = dir.join("SKILL.md");
2864 if let Some(canonical) =
2865 validated_resource_path(boundary, &skill_file).filter(|_| skill_file.is_file())
2866 {
2867 out.insert(canonical);
2868 return;
2869 }
2870
2871 for entry in visible_directory_entries(dir) {
2872 let path = entry.path();
2873 let Ok(metadata) = std::fs::metadata(&path) else {
2874 continue;
2875 };
2876 if metadata.is_file() {
2877 if dir == discovery_root && valid_resource_file(&path, FilterResourceKind::Skills) {
2878 if let Some(canonical) = validated_resource_path(boundary, &path) {
2879 out.insert(canonical);
2880 }
2881 }
2882 continue;
2883 }
2884 if !metadata.is_dir() {
2885 continue;
2886 }
2887 let Some(canonical) = validated_resource_path(boundary, &path) else {
2888 continue;
2889 };
2890 if visited.insert(canonical) {
2891 collect_skill_directory(boundary, &path, discovery_root, out, visited);
2892 }
2893 }
2894}
2895
2896fn collect_recursive_resource_directory(
2897 boundary: &Path,
2898 dir: &Path,
2899 kind: FilterResourceKind,
2900 out: &mut HashSet<PathBuf>,
2901 visited: &mut HashSet<PathBuf>,
2902) {
2903 for entry in visible_directory_entries(dir) {
2904 collect_resource_files(boundary, &entry.path(), kind, out, visited);
2905 }
2906}
2907
2908fn visible_directory_entries(dir: &Path) -> Vec<std::fs::DirEntry> {
2909 let mut entries: Vec<_> = std::fs::read_dir(dir)
2910 .ok()
2911 .into_iter()
2912 .flatten()
2913 .filter_map(Result::ok)
2914 .filter(|entry| {
2915 entry
2916 .file_name()
2917 .to_str()
2918 .is_some_and(|name| !name.starts_with('.') && name != "node_modules")
2919 })
2920 .collect();
2921 entries.sort_by_key(std::fs::DirEntry::file_name);
2922 entries
2923}
2924
2925#[derive(Debug)]
2926struct ManifestResourceResolution {
2927 paths: Vec<PathBuf>,
2928 had_source: bool,
2929}
2930
2931fn resolve_manifest_resources(
2932 boundary: &Path,
2933 base: &Path,
2934 entries: &[String],
2935 kind: FilterResourceKind,
2936 visited: &mut HashSet<PathBuf>,
2937) -> ManifestResourceResolution {
2938 let mut discovered = HashSet::new();
2939 let mut had_source = false;
2940 for entry in entries.iter().filter(|entry| !is_override_pattern(entry)) {
2941 let sources = if has_glob_pattern(entry) {
2942 expand_resource_glob(boundary, base, entry)
2943 } else {
2944 safe_resource_path_from(boundary, base, entry)
2945 .into_iter()
2946 .collect()
2947 };
2948 had_source |= !sources.is_empty();
2949 for source in sources {
2950 collect_resource_files(boundary, &source, kind, &mut discovered, visited);
2951 }
2952 }
2953 let all = sorted_paths(discovered.into_iter().collect());
2954 let patterns: Vec<String> = entries
2955 .iter()
2956 .filter(|entry| is_override_pattern(entry))
2957 .cloned()
2958 .collect();
2959 let base =
2960 normalize_resource_path(std::fs::canonicalize(base).unwrap_or_else(|_| base.to_path_buf()));
2961 let paths = apply_resource_patterns(&all, &patterns, &base, kind);
2962 ManifestResourceResolution { paths, had_source }
2963}
2964
2965fn is_override_pattern(pattern: &str) -> bool {
2966 pattern.starts_with(['!', '+', '-'])
2967}
2968
2969fn has_glob_pattern(pattern: &str) -> bool {
2970 pattern.contains(['*', '?'])
2971}
2972
2973fn expand_resource_glob(boundary: &Path, base: &Path, pattern: &str) -> Vec<PathBuf> {
2974 let pattern = normalize_pattern(pattern);
2975 if pattern.is_empty()
2976 || Path::new(&pattern).is_absolute()
2977 || Path::new(&pattern)
2978 .components()
2979 .any(|part| matches!(part, std::path::Component::ParentDir))
2980 {
2981 return Vec::new();
2982 }
2983 let Some(matcher) = compile_resource_glob(&pattern) else {
2984 return Vec::new();
2985 };
2986 let Some(canonical_base) = validated_resource_path(boundary, base) else {
2987 return Vec::new();
2988 };
2989 if !canonical_base.is_dir() {
2990 return Vec::new();
2991 }
2992 let mut matches = Vec::new();
2993 let mut visited = HashSet::from([canonical_base]);
2994 walk_resource_glob(boundary, base, base, &matcher, &mut matches, &mut visited);
2995 sorted_paths(matches)
2996}
2997
2998fn walk_resource_glob(
2999 boundary: &Path,
3000 base: &Path,
3001 dir: &Path,
3002 matcher: &globset::GlobMatcher,
3003 out: &mut Vec<PathBuf>,
3004 visited: &mut HashSet<PathBuf>,
3005) {
3006 for entry in visible_directory_entries_including_node_modules(dir) {
3007 let path = normalize_resource_path(entry.path());
3008 let Some(canonical) = validated_resource_path(boundary, &path) else {
3009 continue;
3010 };
3011 let Ok(metadata) = std::fs::metadata(&path) else {
3012 continue;
3013 };
3014 let Some(relative) = path.strip_prefix(base).ok().map(path_to_pattern) else {
3015 continue;
3016 };
3017 if matcher.is_match(&relative)
3018 || (metadata.is_dir() && matcher.is_match(format!("{relative}/")))
3019 {
3020 out.push(path.clone());
3021 }
3022 if metadata.is_dir() && visited.insert(canonical) {
3023 walk_resource_glob(boundary, base, &path, matcher, out, visited);
3024 }
3025 }
3026}
3027
3028fn visible_directory_entries_including_node_modules(dir: &Path) -> Vec<std::fs::DirEntry> {
3029 let mut entries: Vec<_> = std::fs::read_dir(dir)
3030 .ok()
3031 .into_iter()
3032 .flatten()
3033 .filter_map(Result::ok)
3034 .filter(|entry| {
3035 entry
3036 .file_name()
3037 .to_str()
3038 .is_some_and(|name| !name.starts_with('.'))
3039 })
3040 .collect();
3041 entries.sort_by_key(std::fs::DirEntry::file_name);
3042 entries
3043}
3044
3045fn compile_resource_glob(pattern: &str) -> Option<globset::GlobMatcher> {
3046 let mut builder = globset::GlobBuilder::new(pattern);
3047 builder.literal_separator(true).backslash_escape(false);
3048 builder.build().ok().map(|glob| glob.compile_matcher())
3049}
3050
3051fn apply_resource_patterns(
3052 all: &[PathBuf],
3053 patterns: &[String],
3054 base: &Path,
3055 kind: FilterResourceKind,
3056) -> Vec<PathBuf> {
3057 let includes: Vec<&str> = patterns
3058 .iter()
3059 .filter(|pattern| !is_override_pattern(pattern))
3060 .map(String::as_str)
3061 .collect();
3062 let excludes: Vec<&str> = patterns
3063 .iter()
3064 .filter_map(|pattern| pattern.strip_prefix('!'))
3065 .collect();
3066 let force_includes: Vec<&str> = patterns
3067 .iter()
3068 .filter_map(|pattern| pattern.strip_prefix('+'))
3069 .collect();
3070 let force_excludes: Vec<&str> = patterns
3071 .iter()
3072 .filter_map(|pattern| pattern.strip_prefix('-'))
3073 .collect();
3074
3075 let mut selected: HashSet<PathBuf> = all
3076 .iter()
3077 .filter(|path| {
3078 includes.is_empty()
3079 || includes
3080 .iter()
3081 .any(|pattern| matches_resource_pattern(path, base, pattern, false, kind))
3082 })
3083 .cloned()
3084 .collect();
3085 if !excludes.is_empty() {
3086 selected.retain(|path| {
3087 !excludes
3088 .iter()
3089 .any(|pattern| matches_resource_pattern(path, base, pattern, false, kind))
3090 });
3091 }
3092 for path in all {
3093 if force_includes
3094 .iter()
3095 .any(|pattern| matches_resource_pattern(path, base, pattern, true, kind))
3096 {
3097 selected.insert(path.clone());
3098 }
3099 }
3100 if !force_excludes.is_empty() {
3101 selected.retain(|path| {
3102 !force_excludes
3103 .iter()
3104 .any(|pattern| matches_resource_pattern(path, base, pattern, true, kind))
3105 });
3106 }
3107 sorted_paths(selected.into_iter().collect())
3108}
3109
3110fn sorted_paths(mut paths: Vec<PathBuf>) -> Vec<PathBuf> {
3111 paths.sort();
3112 paths.dedup();
3113 paths
3114}
3115
3116fn matches_resource_pattern(
3117 path: &Path,
3118 root: &Path,
3119 pattern: &str,
3120 exact: bool,
3121 kind: FilterResourceKind,
3122) -> bool {
3123 let pattern = normalize_pattern(pattern);
3124 if pattern.is_empty() {
3125 return false;
3126 }
3127 let root =
3128 normalize_resource_path(std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()));
3129 let rel = path
3130 .strip_prefix(&root)
3131 .ok()
3132 .map(path_to_pattern)
3133 .unwrap_or_default();
3134 let name = path
3135 .file_name()
3136 .and_then(|value| value.to_str())
3137 .unwrap_or("");
3138 let absolute = path_to_pattern(path);
3139 let parent_rel = path
3140 .parent()
3141 .and_then(|parent| parent.strip_prefix(&root).ok())
3142 .map(path_to_pattern);
3143 let parent_absolute = path.parent().map(path_to_pattern);
3144 let exact_match =
3145 |candidate: &str| candidate == pattern || normalize_pattern(candidate) == pattern;
3146 if exact {
3147 return exact_match(&rel)
3148 || exact_match(&absolute)
3149 || (matches!(kind, FilterResourceKind::Skills)
3150 && (parent_rel.as_deref().is_some_and(exact_match)
3151 || parent_absolute.as_deref().is_some_and(exact_match)));
3152 }
3153 let matcher = compile_resource_glob(&pattern);
3154 let matches = |candidate: &str| {
3155 matcher
3156 .as_ref()
3157 .is_some_and(|matcher| matcher.is_match(candidate))
3158 || exact_match(candidate)
3159 };
3160 matches(&rel)
3161 || matches(name)
3162 || matches(&absolute)
3163 || (matches!(kind, FilterResourceKind::Skills)
3164 && (parent_rel.as_deref().is_some_and(matches)
3165 || parent_absolute.as_deref().is_some_and(matches)))
3166}
3167
3168fn normalize_pattern(pattern: &str) -> String {
3169 let normalized = pattern.trim().replace('\\', "/");
3170 normalized
3171 .strip_prefix("./")
3172 .unwrap_or(&normalized)
3173 .to_string()
3174}
3175
3176fn path_to_pattern(path: &Path) -> String {
3177 path.to_string_lossy().replace('\\', "/")
3178}
3179
3180fn load_package(
3181 root: PathBuf,
3182 spec: &str,
3183 cwd: &Path,
3184 scope: ResolveScope,
3185 filter: Option<&crate::settings::PackageFilter>,
3186) -> Result<PackageRoot, String> {
3187 load_package_with_legacy_root(root, spec, cwd, scope, filter, None)
3188}
3189
3190fn load_package_with_legacy_root(
3191 root: PathBuf,
3192 spec: &str,
3193 cwd: &Path,
3194 scope: ResolveScope,
3195 filter: Option<&crate::settings::PackageFilter>,
3196 legacy_npm_root: Option<PathBuf>,
3197) -> Result<PackageRoot, String> {
3198 let manifest_path = root.join("package.json");
3199 let raw =
3200 match std::fs::read_to_string(&manifest_path) {
3201 Ok(text) => Some(parse_json_with_comments(&text).map_err(|e| {
3202 format!("invalid package manifest {}: {e}", manifest_path.display())
3203 })?),
3204 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
3205 Err(e) => return Err(format!("could not read {}: {e}", manifest_path.display())),
3206 };
3207 let manifest_name = raw
3208 .as_ref()
3209 .and_then(|v| v.get("name"))
3210 .and_then(Value::as_str);
3211 let explicit_npm_source = if spec.trim_start().starts_with("npm:") {
3212 Some(
3213 npm_source_from_spec(spec)
3214 .ok_or_else(|| format!("invalid npm package source `{spec}`"))?,
3215 )
3216 } else {
3217 None
3218 };
3219 if legacy_npm_root.is_some() || explicit_npm_source.is_some() {
3220 let source = explicit_npm_source
3221 .as_ref()
3222 .ok_or_else(|| format!("legacy npm package has invalid source `{spec}`"))?;
3223 let expected = parse_npm_package_spec(spec)
3224 .map(|parsed| parsed.manifest_name)
3225 .ok_or_else(|| format!("invalid npm package source `{spec}`"))?;
3226 let actual = manifest_name.ok_or_else(|| {
3227 format!(
3228 "npm package manifest {} has no string package name; expected `{expected}`",
3229 manifest_path.display()
3230 )
3231 })?;
3232 if !npm_source_matches_manifest(source, actual) {
3233 return Err(format!(
3234 "npm package manifest name `{actual}` does not match configured package `{expected}`"
3235 ));
3236 }
3237 }
3238 let name = manifest_name
3239 .map(str::to_owned)
3240 .or_else(|| root.file_name().and_then(|s| s.to_str()).map(str::to_owned))
3241 .unwrap_or_else(|| spec.to_string());
3242 let version = raw
3243 .as_ref()
3244 .and_then(|v| v.get("version"))
3245 .and_then(Value::as_str)
3246 .map(str::to_owned);
3247 let manifest = raw.as_ref().map(|_| manifest_path);
3248 let source = if legacy_npm_root.is_some() {
3249 explicit_npm_source
3252 .clone()
3253 .expect("legacy npm sources were validated above")
3254 } else {
3255 classify_package_source(&root, spec, &name, cwd, scope)
3256 };
3257 if explicit_npm_source.is_some()
3258 && is_managed_package_path(&root, cwd, scope)
3259 && source == PackageSource::Unknown
3260 {
3261 return Err(format!(
3262 "managed npm package provenance does not match configured source `{spec}`"
3263 ));
3264 }
3265 let npm_install_root = matches!(source, PackageSource::Npm { .. })
3266 .then(|| npm_install_root_for_path(&root, cwd, scope))
3267 .flatten();
3268 let git_store_root = matches!(source, PackageSource::Git)
3269 .then(|| native_git_store_root_for_path(&root, cwd, scope))
3270 .flatten();
3271 let git_revision = matches!(source, PackageSource::Git)
3272 .then(|| parse_git_source(spec).and_then(|git| git.revision))
3273 .flatten();
3274 let rpi = raw
3277 .as_ref()
3278 .and_then(|v| v.get("rpi"))
3279 .unwrap_or(&Value::Null);
3280 let pi = raw
3281 .as_ref()
3282 .and_then(|v| v.get("pi"))
3283 .unwrap_or(&Value::Null);
3284
3285 let mut skills = resource_paths(
3286 &root,
3287 raw.as_ref(),
3288 rpi,
3289 pi,
3290 "skills",
3291 "skills",
3292 FilterResourceKind::Skills,
3293 );
3294 let mut prompts = resource_paths(
3295 &root,
3296 raw.as_ref(),
3297 rpi,
3298 pi,
3299 "prompts",
3300 "prompts",
3301 FilterResourceKind::Prompts,
3302 );
3303 let mut themes = resource_paths(
3304 &root,
3305 raw.as_ref(),
3306 rpi,
3307 pi,
3308 "themes",
3309 "themes",
3310 FilterResourceKind::Themes,
3311 );
3312 let mut extensions = resource_paths(
3313 &root,
3314 raw.as_ref(),
3315 rpi,
3316 pi,
3317 "extensions",
3318 "extensions",
3319 FilterResourceKind::Extensions,
3320 );
3321 let system_prompts = file_paths(
3322 &root,
3323 raw.as_ref(),
3324 rpi,
3325 pi,
3326 &["systemPrompt", "system_prompt", "system"],
3327 "SYSTEM.md",
3328 );
3329 let append_system_prompts = file_paths(
3330 &root,
3331 raw.as_ref(),
3332 rpi,
3333 pi,
3334 &["appendSystemPrompt", "append_system_prompt", "appendSystem"],
3335 "APPEND_SYSTEM.md",
3336 );
3337 let autoload_delta = filter.is_some_and(|filter| filter.autoload == Some(false));
3338 if let Some(filter) = filter {
3339 apply_package_filter(
3340 &root,
3341 &mut extensions,
3342 &mut skills,
3343 &mut prompts,
3344 &mut themes,
3345 filter,
3346 );
3347 }
3348
3349 Ok(PackageRoot {
3350 skills,
3351 prompts,
3352 themes,
3353 system_prompts,
3354 append_system_prompts,
3355 extensions,
3356 root,
3357 name,
3358 version,
3359 manifest,
3360 spec: spec.to_string(),
3361 source,
3362 npm_install_root,
3363 legacy_npm_root,
3364 autoload_delta,
3365 scope,
3366 git_store_root,
3367 git_revision,
3368 missing_install: false,
3369 filter: filter.cloned(),
3370 })
3371}
3372
3373#[derive(Debug, serde::Serialize, serde::Deserialize)]
3374struct PackageSourceMarker {
3375 kind: String,
3376 spec: String,
3377}
3378
3379pub(crate) fn write_npm_source_marker(root: &Path, spec: &str) -> Result<(), String> {
3380 let source = npm_source_from_spec(spec)
3381 .ok_or_else(|| format!("invalid npm package source marker spec `{spec}`"))?;
3382 let PackageSource::Npm { spec, .. } = source else {
3383 unreachable!();
3384 };
3385 let marker = PackageSourceMarker {
3386 kind: "npm".to_string(),
3387 spec,
3388 };
3389 let data = serde_json::to_vec_pretty(&marker).map_err(|error| error.to_string())?;
3390 std::fs::write(root.join(PACKAGE_SOURCE_MARKER), data)
3391 .map_err(|error| format!("could not write package source marker: {error}"))
3392}
3393
3394pub(crate) fn remove_package_source_marker(root: &Path) -> Result<(), String> {
3395 match std::fs::remove_file(root.join(PACKAGE_SOURCE_MARKER)) {
3396 Ok(()) => Ok(()),
3397 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3398 Err(error) => Err(format!("could not remove package source marker: {error}")),
3399 }
3400}
3401
3402fn read_npm_source_marker(root: &Path, manifest_name: &str) -> Option<PackageSource> {
3403 let marker: PackageSourceMarker =
3404 serde_json::from_str(&std::fs::read_to_string(root.join(PACKAGE_SOURCE_MARKER)).ok()?)
3405 .ok()?;
3406 if marker.kind != "npm" {
3407 return None;
3408 }
3409 let source = npm_source_from_spec(&marker.spec)?;
3410 npm_source_matches_manifest(&source, manifest_name).then_some(source)
3411}
3412
3413fn classify_package_source(
3414 root: &Path,
3415 spec: &str,
3416 manifest_name: &str,
3417 cwd: &Path,
3418 scope: ResolveScope,
3419) -> PackageSource {
3420 if let Some(raw) = spec.strip_prefix("npm:") {
3421 let Some(explicit_source) = npm_source_from_spec(&format!("npm:{raw}")) else {
3422 return PackageSource::Unknown;
3423 };
3424 if !npm_source_matches_manifest(&explicit_source, manifest_name) {
3425 return PackageSource::Unknown;
3426 }
3427 if is_npm_store_package_path(root, cwd, scope) {
3428 return explicit_source;
3429 }
3430 if is_managed_package_path(root, cwd, scope) {
3431 return read_npm_source_marker(root, manifest_name)
3432 .filter(|marker_source| marker_source == &explicit_source)
3433 .unwrap_or(PackageSource::Unknown);
3434 }
3435 return PackageSource::Unknown;
3436 }
3437 if let Some(git) = parse_git_source(spec) {
3438 return native_git_target_for_spec(cwd, scope, &git)
3439 .filter(|target| target == root)
3440 .map_or(PackageSource::Unknown, |_| PackageSource::Git);
3441 }
3442 if spec.starts_with("file:") || Path::new(spec).is_absolute() || spec.starts_with('.') {
3443 if is_native_git_package_path(root, cwd, scope) {
3444 return PackageSource::Git;
3445 }
3446 if is_direct_managed_package_root(root, cwd, scope).is_some() {
3447 return PackageSource::Git;
3448 }
3449 if is_managed_package_path(root, cwd, scope) || is_npm_store_package_path(root, cwd, scope)
3450 {
3451 if let Some(source) = read_npm_source_marker(root, manifest_name) {
3452 return source;
3453 }
3454 }
3455 if is_npm_store_package_path(root, cwd, scope) && valid_npm_name(manifest_name) {
3456 return PackageSource::Npm {
3457 name: manifest_name.to_string(),
3458 spec: format!("npm:{manifest_name}"),
3459 requested: None,
3460 pinned: false,
3461 };
3462 }
3463 return PackageSource::Local;
3464 }
3465 if is_npm_store_package_path(root, cwd, scope) && valid_npm_name(manifest_name) {
3466 return PackageSource::Npm {
3467 name: manifest_name.to_string(),
3468 spec: format!("npm:{manifest_name}"),
3469 requested: None,
3470 pinned: false,
3471 };
3472 }
3473 PackageSource::Unknown
3474}
3475
3476fn npm_source_from_spec(spec: &str) -> Option<PackageSource> {
3477 let raw = spec.strip_prefix("npm:")?;
3478 let parsed = parse_npm_package_spec(raw)?;
3479 let pinned = parsed
3480 .target_selector
3481 .as_deref()
3482 .is_some_and(is_exact_npm_version);
3483 Some(PackageSource::Npm {
3484 name: parsed.install_name,
3485 spec: format!("npm:{}", raw.trim()),
3486 requested: parsed.requested,
3487 pinned,
3488 })
3489}
3490
3491fn npm_source_matches_manifest(source: &PackageSource, manifest_name: &str) -> bool {
3492 let PackageSource::Npm { spec, .. } = source else {
3493 return false;
3494 };
3495 parse_npm_package_spec(spec).is_some_and(|parsed| parsed.manifest_name == manifest_name)
3496}
3497
3498fn is_exact_npm_version(value: &str) -> bool {
3499 let value = value.trim().strip_prefix('v').unwrap_or(value.trim());
3500 let mut build_parts = value.split('+');
3501 let core_and_pre = build_parts.next().unwrap_or_default();
3502 if build_parts
3503 .next()
3504 .is_some_and(|build| !valid_semver_identifiers(build, false))
3505 || build_parts.next().is_some()
3506 {
3507 return false;
3508 }
3509 let (core, prerelease) = core_and_pre
3510 .split_once('-')
3511 .map_or((core_and_pre, None), |(core, pre)| (core, Some(pre)));
3512 if prerelease.is_some_and(|pre| !valid_semver_identifiers(pre, true)) {
3513 return false;
3514 }
3515 let mut parts = core.split('.');
3516 let Some(major) = parts.next() else {
3517 return false;
3518 };
3519 let Some(minor) = parts.next() else {
3520 return false;
3521 };
3522 let Some(patch) = parts.next() else {
3523 return false;
3524 };
3525 parts.next().is_none()
3526 && [major, minor, patch].iter().all(|part| {
3527 !part.is_empty()
3528 && part.chars().all(|ch| ch.is_ascii_digit())
3529 && (*part == "0" || !part.starts_with('0'))
3530 })
3531}
3532
3533fn valid_semver_identifiers(value: &str, reject_numeric_leading_zero: bool) -> bool {
3534 !value.is_empty()
3535 && value.split('.').all(|identifier| {
3536 !identifier.is_empty()
3537 && identifier
3538 .chars()
3539 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
3540 && (!reject_numeric_leading_zero
3541 || !identifier.chars().all(|ch| ch.is_ascii_digit())
3542 || identifier == "0"
3543 || !identifier.starts_with('0'))
3544 })
3545}
3546
3547pub(crate) fn parse_npm_package_spec(spec: &str) -> Option<ParsedNpmPackageSpec> {
3548 let raw = spec.strip_prefix("npm:").unwrap_or(spec).trim();
3549 let (install_name, requested) = split_npm_name_and_selector(raw)?;
3550 if !valid_npm_name(install_name) {
3551 return None;
3552 }
3553
3554 let Some(requested) = requested else {
3555 return Some(ParsedNpmPackageSpec {
3556 install_name: install_name.to_string(),
3557 manifest_name: install_name.to_string(),
3558 requested: None,
3559 target_selector: None,
3560 is_alias: false,
3561 });
3562 };
3563 let requested = requested.trim();
3564 if !safe_npm_registry_selector(requested, true) {
3565 return None;
3566 }
3567
3568 let Some(alias_target) = requested.strip_prefix("npm:") else {
3569 return Some(ParsedNpmPackageSpec {
3570 install_name: install_name.to_string(),
3571 manifest_name: install_name.to_string(),
3572 requested: Some(requested.to_string()),
3573 target_selector: Some(requested.to_string()),
3574 is_alias: false,
3575 });
3576 };
3577 let (manifest_name, target_selector) = split_npm_name_and_selector(alias_target)?;
3578 if !valid_npm_name(manifest_name)
3579 || target_selector.is_some_and(|selector| !safe_npm_registry_selector(selector, false))
3580 {
3581 return None;
3582 }
3583 Some(ParsedNpmPackageSpec {
3584 install_name: install_name.to_string(),
3585 manifest_name: manifest_name.to_string(),
3586 requested: Some(requested.to_string()),
3587 target_selector: target_selector.map(str::to_string),
3588 is_alias: true,
3589 })
3590}
3591
3592fn split_npm_name_and_selector(spec: &str) -> Option<(&str, Option<&str>)> {
3593 let spec = spec.trim();
3594 if spec.is_empty() || spec.chars().any(char::is_control) {
3595 return None;
3596 }
3597 let separator = if spec.starts_with('@') {
3598 let slash = spec.find('/')?;
3599 spec[slash + 1..].find('@').map(|index| slash + 1 + index)
3600 } else {
3601 spec.find('@')
3602 };
3603 match separator {
3604 Some(index) => {
3605 let selector = &spec[index + 1..];
3606 (!selector.is_empty()).then_some((&spec[..index], Some(selector)))
3607 }
3608 None => Some((spec, None)),
3609 }
3610}
3611
3612fn safe_npm_registry_selector(selector: &str, allow_alias: bool) -> bool {
3613 let selector = selector.trim();
3614 if selector.is_empty()
3615 || selector.starts_with('-')
3616 || selector.starts_with('.')
3617 || selector.starts_with('/')
3618 || selector.contains('\\')
3619 || selector.chars().any(char::is_control)
3620 {
3621 return false;
3622 }
3623 if let Some(target) = selector.strip_prefix("npm:") {
3624 return allow_alias && !target.is_empty();
3625 }
3626 let lower = selector.to_ascii_lowercase();
3627 ![
3628 "file:",
3629 "link:",
3630 "workspace:",
3631 "git:",
3632 "git+",
3633 "http:",
3634 "https:",
3635 "ssh:",
3636 "github:",
3637 "gitlab:",
3638 "bitbucket:",
3639 ]
3640 .iter()
3641 .any(|prefix| lower.starts_with(prefix))
3642}
3643
3644fn valid_npm_name(name: &str) -> bool {
3645 if name.starts_with('-') {
3646 return false;
3647 }
3648 if let Some(scoped) = name.strip_prefix('@') {
3649 let mut parts = scoped.split('/');
3650 return parts.next().is_some_and(valid_npm_name_part)
3651 && parts.next().is_some_and(valid_npm_name_part)
3652 && parts.next().is_none();
3653 }
3654 valid_npm_name_part(name)
3655}
3656
3657fn valid_npm_name_part(part: &str) -> bool {
3658 !matches!(part, "" | "." | "..")
3659 && !part.starts_with('.')
3660 && !part.starts_with('-')
3661 && part
3662 .chars()
3663 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~'))
3664}
3665
3666fn parse_json_with_comments(text: &str) -> Result<Value, serde_json::Error> {
3667 match serde_json::from_str(text) {
3668 Ok(value) => Ok(value),
3669 Err(first) => serde_json::from_str(&config::strip_line_comments(text)).map_err(|_| first),
3670 }
3671}
3672
3673fn resource_paths(
3674 root: &Path,
3675 top: Option<&Value>,
3676 rpi: &Value,
3677 pi: &Value,
3678 key: &str,
3679 default_dir: &str,
3680 kind: FilterResourceKind,
3681) -> Vec<PathBuf> {
3682 let values = rpi
3683 .get(key)
3684 .or_else(|| pi.get(key))
3685 .or_else(|| top.and_then(|v| v.get(key)));
3686 if let Some(values) = values {
3687 return resolve_manifest_resources(
3688 root,
3689 root,
3690 &string_values(values),
3691 kind,
3692 &mut HashSet::new(),
3693 )
3694 .paths;
3695 }
3696 resource_inventory(root, &[root.join(default_dir)], kind)
3697}
3698
3699fn file_paths(
3700 root: &Path,
3701 top: Option<&Value>,
3702 rpi: &Value,
3703 pi: &Value,
3704 keys: &[&str],
3705 default_file: &str,
3706) -> Vec<PathBuf> {
3707 let value = keys.iter().find_map(|key| {
3708 rpi.get(*key)
3709 .or_else(|| pi.get(*key))
3710 .or_else(|| top.and_then(|v| v.get(*key)))
3711 });
3712 let mut paths = value
3713 .map(|v| {
3714 string_values(v)
3715 .into_iter()
3716 .filter_map(|path| safe_resource_path(root, &path))
3717 .collect()
3718 })
3719 .unwrap_or_else(|| vec![root.join(default_file)]);
3720 paths.retain(|p: &PathBuf| p.is_file());
3721 paths
3722}
3723
3724fn string_values(value: &Value) -> Vec<String> {
3725 match value {
3726 Value::String(s) => vec![s.clone()],
3727 Value::Array(values) => values
3728 .iter()
3729 .filter_map(Value::as_str)
3730 .map(str::to_owned)
3731 .collect(),
3732 _ => Vec::new(),
3733 }
3734}
3735
3736fn normalize_key(path: &Path) -> String {
3737 std::fs::canonicalize(path)
3738 .unwrap_or_else(|_| path.to_path_buf())
3739 .to_string_lossy()
3740 .to_ascii_lowercase()
3741}
3742
3743#[cfg(test)]
3744mod tests {
3745 use super::*;
3746
3747 struct RestoreEnv {
3748 name: &'static str,
3749 value: Option<std::ffi::OsString>,
3750 }
3751
3752 impl RestoreEnv {
3753 fn capture(name: &'static str) -> Self {
3754 Self {
3755 name,
3756 value: std::env::var_os(name),
3757 }
3758 }
3759 }
3760
3761 impl Drop for RestoreEnv {
3762 fn drop(&mut self) {
3763 match self.value.take() {
3764 Some(value) => std::env::set_var(self.name, value),
3765 None => std::env::remove_var(self.name),
3766 }
3767 }
3768 }
3769
3770 #[test]
3771 fn package_command_trust_overrides_are_explicit_and_conflict_safe() {
3772 let cwd = Path::new(".");
3773 assert!(package_command_project_trusted(cwd, &["--approve".into()]).unwrap());
3774 assert!(!package_command_project_trusted(cwd, &["--no-approve".into()]).unwrap());
3775 assert!(
3776 package_command_project_trusted(cwd, &["--approve".into(), "--no-approve".into()])
3777 .is_err()
3778 );
3779 assert!(package_command_project_trusted(cwd, &["--unexpected".into()]).is_err());
3780 }
3781
3782 #[test]
3783 fn package_update_accepts_offline_flag_and_skips_all_preflight() {
3784 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3785 let _restore_config = RestoreEnv::capture(config::CONFIG_DIR_ENV);
3786 let _restore_offline = RestoreEnv::capture(crate::args::PI_OFFLINE_ENV);
3787 let tmp = tempfile::tempdir().unwrap();
3788 let agent = tmp.path().join("agent");
3789 std::fs::create_dir_all(&agent).unwrap();
3790 std::fs::write(agent.join("native-packages.json"), "{ malformed").unwrap();
3791 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
3792 std::env::remove_var(crate::args::PI_OFFLINE_ENV);
3793
3794 assert_eq!(run_cli(&["update".into(), "--offline".into()]), 0);
3795 assert_eq!(
3796 std::env::var(crate::args::PI_OFFLINE_ENV).as_deref(),
3797 Ok("1")
3798 );
3799
3800 std::env::set_var(crate::args::PI_OFFLINE_ENV, "0");
3803 assert_eq!(update_packages(tmp.path(), false), 1);
3804 }
3805
3806 #[test]
3807 fn discovers_conventional_and_manifest_resources() {
3808 let tmp = tempfile::tempdir().unwrap();
3809 let root = tmp.path().join("pkg");
3810 std::fs::create_dir_all(root.join("custom-skills")).unwrap();
3811 std::fs::create_dir_all(root.join("rpi-skills")).unwrap();
3812 std::fs::create_dir_all(root.join("prompts")).unwrap();
3813 std::fs::create_dir_all(root.join("legacy-prompts")).unwrap();
3814 std::fs::create_dir_all(root.join("themes")).unwrap();
3815 std::fs::write(root.join("custom-skills/a.md"), "---\nname: a\n---\nbody").unwrap();
3816 std::fs::write(root.join("rpi-skills/rpi.md"), "---\nname: rpi\n---\nbody").unwrap();
3817 std::fs::write(root.join("prompts/explain.md"), "explain").unwrap();
3818 std::fs::write(root.join("legacy-prompts/legacy.md"), "legacy").unwrap();
3819 std::fs::write(root.join("themes/ocean.json"), "{}").unwrap();
3820 std::fs::write(
3821 root.join("package.json"),
3822 r#"{"name":"demo","version":"1.0.0","pi":{"skills":["custom-skills"],"prompts":["legacy-prompts"]},"rpi":{"skills":["rpi-skills"]}}"#,
3823 )
3824 .unwrap();
3825
3826 let resources = discover(tmp.path(), &[root.to_string_lossy().into_owned()]);
3827 assert_eq!(resources.packages.len(), 1);
3828 assert_eq!(resources.packages[0].name, "demo");
3829 assert_eq!(resources.skill_dirs(), vec![root.join("rpi-skills/rpi.md")]);
3830 assert_eq!(
3831 resources.prompt_dirs(),
3832 vec![root.join("legacy-prompts/legacy.md")]
3833 );
3834 assert_eq!(
3835 resources.theme_files(),
3836 vec![root.join("themes/ocean.json")]
3837 );
3838 }
3839
3840 #[test]
3841 fn manifest_globs_and_overrides_follow_native_precedence() {
3842 let tmp = tempfile::tempdir().unwrap();
3843 let root = tmp.path().join("pkg");
3844 for dir in [
3845 root.join("extensions"),
3846 root.join("plugins/one/skills/alpha"),
3847 root.join("plugins/two/skills/beta"),
3848 ] {
3849 std::fs::create_dir_all(dir).unwrap();
3850 }
3851 for path in ["extensions/a.ts", "extensions/z.ts"] {
3852 std::fs::write(root.join(path), "export default () => {};").unwrap();
3853 }
3854 for path in [
3855 "plugins/one/skills/alpha/SKILL.md",
3856 "plugins/two/skills/beta/SKILL.md",
3857 ] {
3858 std::fs::write(root.join(path), "---\nname: demo\n---\n").unwrap();
3859 }
3860 std::fs::write(
3861 root.join("package.json"),
3862 r#"{
3863 "name":"glob-demo",
3864 "pi":{
3865 "extensions":[
3866 "extensions/*.ts",
3867 "!**/*.ts",
3868 "+extensions/a.ts",
3869 "-extensions/z.ts",
3870 "+extensions/z.ts"
3871 ],
3872 "skills":["plugins/*/skills"]
3873 }
3874 }"#,
3875 )
3876 .unwrap();
3877
3878 let resources = discover(tmp.path(), &[root.to_string_lossy().into_owned()]);
3879 assert_eq!(
3880 resources.extension_paths(),
3881 vec![root.join("extensions/a.ts")]
3882 );
3883 assert_eq!(
3884 resources.skill_dirs(),
3885 vec![
3886 root.join("plugins/one/skills/alpha/SKILL.md"),
3887 root.join("plugins/two/skills/beta/SKILL.md"),
3888 ]
3889 );
3890 }
3891
3892 #[test]
3893 fn extension_directories_use_smart_entry_discovery() {
3894 let tmp = tempfile::tempdir().unwrap();
3895 let root = tmp.path().join("pkg");
3896 for dir in [
3897 root.join("extensions/group"),
3898 root.join("extensions/custom"),
3899 root.join("extensions/broken"),
3900 ] {
3901 std::fs::create_dir_all(dir).unwrap();
3902 }
3903 for (path, body) in [
3904 ("extensions/standalone.ts", "export default () => {};"),
3905 ("extensions/group/index.ts", "export default () => {};"),
3906 ("extensions/group/helper.ts", "export const helper = 1;"),
3907 ("extensions/custom/main.js", "export default () => {};"),
3908 ("extensions/custom/utils.js", "export const util = 1;"),
3909 ("extensions/broken/helper.ts", "export const helper = 1;"),
3910 ] {
3911 std::fs::write(root.join(path), body).unwrap();
3912 }
3913 std::fs::write(
3914 root.join("extensions/custom/package.json"),
3915 r#"{"pi":{"extensions":["main.js"]}}"#,
3916 )
3917 .unwrap();
3918 std::fs::write(
3919 root.join("package.json"),
3920 r#"{"name":"smart-demo","pi":{"extensions":["extensions"]}}"#,
3921 )
3922 .unwrap();
3923
3924 let package = load_package(
3925 root.clone(),
3926 &root.to_string_lossy(),
3927 tmp.path(),
3928 ResolveScope::Any,
3929 None,
3930 )
3931 .unwrap();
3932 assert_eq!(
3933 package.extensions,
3934 vec![
3935 root.join("extensions/custom/main.js"),
3936 root.join("extensions/group/index.ts"),
3937 root.join("extensions/standalone.ts"),
3938 ]
3939 );
3940
3941 std::fs::write(root.join("extensions/index.js"), "export default () => {};").unwrap();
3942 let package = load_package(
3943 root.clone(),
3944 &root.to_string_lossy(),
3945 tmp.path(),
3946 ResolveScope::Any,
3947 None,
3948 )
3949 .unwrap();
3950 assert_eq!(package.extensions, vec![root.join("extensions/index.js")]);
3951 }
3952
3953 #[test]
3954 fn skill_directory_discovery_ignores_nested_markdown_helpers() {
3955 let tmp = tempfile::tempdir().unwrap();
3956 let root = tmp.path().join("pkg");
3957 for dir in [
3958 root.join("skills/group/nested"),
3959 root.join("skills/docs"),
3960 root.join("skills/deep/alpha"),
3961 ] {
3962 std::fs::create_dir_all(dir).unwrap();
3963 }
3964 for path in [
3965 "skills/root.md",
3966 "skills/group/SKILL.md",
3967 "skills/group/README.md",
3968 "skills/group/nested/SKILL.md",
3969 "skills/docs/README.md",
3970 "skills/deep/alpha/SKILL.md",
3971 ] {
3972 std::fs::write(root.join(path), "---\nname: demo\n---\n").unwrap();
3973 }
3974 std::fs::write(root.join("package.json"), r#"{"name":"skill-demo"}"#).unwrap();
3975
3976 let package = load_package(
3977 root.clone(),
3978 &root.to_string_lossy(),
3979 tmp.path(),
3980 ResolveScope::Any,
3981 None,
3982 )
3983 .unwrap();
3984 assert_eq!(
3985 package.skills,
3986 vec![
3987 root.join("skills/deep/alpha/SKILL.md"),
3988 root.join("skills/group/SKILL.md"),
3989 root.join("skills/root.md"),
3990 ]
3991 );
3992 }
3993
3994 #[test]
3995 fn manifest_prompt_and_theme_directories_are_recursive() {
3996 let tmp = tempfile::tempdir().unwrap();
3997 let root = tmp.path().join("pkg");
3998 std::fs::create_dir_all(root.join("prompt-pack/nested")).unwrap();
3999 std::fs::create_dir_all(root.join("theme-pack/nested")).unwrap();
4000 std::fs::write(root.join("prompt-pack/root.md"), "root").unwrap();
4001 std::fs::write(root.join("prompt-pack/nested/deep.md"), "deep").unwrap();
4002 std::fs::write(root.join("theme-pack/root.json"), "{}").unwrap();
4003 std::fs::write(root.join("theme-pack/nested/deep.json"), "{}").unwrap();
4004 std::fs::write(root.join("theme-pack/nested/not-theme.md"), "ignored").unwrap();
4005 std::fs::write(
4006 root.join("package.json"),
4007 r#"{
4008 "name":"recursive-demo",
4009 "pi":{"prompts":["prompt-pack"],"themes":["theme-pack"]}
4010 }"#,
4011 )
4012 .unwrap();
4013
4014 let resources = discover(tmp.path(), &[root.to_string_lossy().into_owned()]);
4015 assert_eq!(
4016 resources.prompt_dirs(),
4017 vec![
4018 root.join("prompt-pack/nested/deep.md"),
4019 root.join("prompt-pack/root.md"),
4020 ]
4021 );
4022 assert_eq!(
4023 resources.theme_files(),
4024 vec![
4025 root.join("theme-pack/nested/deep.json"),
4026 root.join("theme-pack/root.json"),
4027 ]
4028 );
4029 }
4030
4031 #[test]
4032 fn manifest_resource_paths_cannot_escape_the_package() {
4033 let tmp = tempfile::tempdir().unwrap();
4034 let root = tmp.path().join("pkg");
4035 let outside = tmp.path().join("outside");
4036 std::fs::create_dir_all(&root).unwrap();
4037 std::fs::create_dir_all(&outside).unwrap();
4038 std::fs::write(outside.join("outside.ts"), "export default () => {};").unwrap();
4039 std::fs::write(
4040 root.join("package.json"),
4041 r#"{"name":"escape-demo","pi":{"extensions":["../outside/outside.ts","../outside/*.ts"]}}"#,
4042 )
4043 .unwrap();
4044
4045 let package = load_package(
4046 root.clone(),
4047 &root.to_string_lossy(),
4048 tmp.path(),
4049 ResolveScope::Any,
4050 None,
4051 )
4052 .unwrap();
4053 assert!(package.extensions.is_empty());
4054 }
4055
4056 #[test]
4057 fn manifest_resource_symlink_escape_is_rejected() {
4058 let tmp = tempfile::tempdir().unwrap();
4059 let root = tmp.path().join("pkg");
4060 let outside = tmp.path().join("outside.ts");
4061 std::fs::create_dir_all(&root).unwrap();
4062 std::fs::write(&outside, "export default () => {};").unwrap();
4063 let link = root.join("linked.ts");
4064 #[cfg(unix)]
4065 std::os::unix::fs::symlink(&outside, &link).unwrap();
4066 #[cfg(windows)]
4067 if std::os::windows::fs::symlink_file(&outside, &link).is_err() {
4068 return;
4069 }
4070 std::fs::write(
4071 root.join("package.json"),
4072 r#"{"name":"symlink-demo","pi":{"extensions":["linked.ts"]}}"#,
4073 )
4074 .unwrap();
4075
4076 let package = load_package(
4077 root.clone(),
4078 &root.to_string_lossy(),
4079 tmp.path(),
4080 ResolveScope::Any,
4081 None,
4082 )
4083 .unwrap();
4084 assert!(package.extensions.is_empty());
4085 }
4086
4087 #[test]
4088 fn resolves_package_json_spec_and_deduplicates() {
4089 let tmp = tempfile::tempdir().unwrap();
4090 let root = tmp.path().join("pkg");
4091 std::fs::create_dir_all(&root).unwrap();
4092 std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4093 let manifest = root.join("package.json").to_string_lossy().into_owned();
4094 let resources = discover(
4095 tmp.path(),
4096 &[manifest.clone(), root.to_string_lossy().into_owned()],
4097 );
4098 assert_eq!(resources.packages.len(), 1);
4099 assert!(resources.diagnostics.is_empty());
4100 }
4101
4102 #[test]
4103 fn bare_package_name_prefers_project_rpi_store_over_legacy_pi_store() {
4104 let tmp = tempfile::tempdir().unwrap();
4105 let rpi_root = tmp.path().join(".rpi/packages/demo");
4106 let pi_root = tmp.path().join(".pi/packages/demo");
4107 std::fs::create_dir_all(rpi_root.join("skills")).unwrap();
4108 std::fs::create_dir_all(pi_root.join("skills")).unwrap();
4109 std::fs::write(
4110 rpi_root.join("package.json"),
4111 r#"{"name":"rpi-demo","version":"rpi"}"#,
4112 )
4113 .unwrap();
4114 std::fs::write(
4115 pi_root.join("package.json"),
4116 r#"{"name":"pi-demo","version":"pi"}"#,
4117 )
4118 .unwrap();
4119
4120 let resources = discover(tmp.path(), &["demo".to_string()]);
4121 assert_eq!(resources.packages.len(), 1);
4122 assert_eq!(resources.packages[0].root, rpi_root);
4123 assert_eq!(resources.packages[0].version.as_deref(), Some("rpi"));
4124 }
4125
4126 #[test]
4127 fn npm_scoped_spec_resolves_installed_safe_name() {
4128 let tmp = tempfile::tempdir().unwrap();
4129 let root = tmp.path().join(".rpi/packages/narumitw__pi-btw");
4130 std::fs::create_dir_all(&root).unwrap();
4131 std::fs::write(
4132 root.join("package.json"),
4133 r#"{"name":"@narumitw/pi-btw","version":"0.58.1"}"#,
4134 )
4135 .unwrap();
4136 write_npm_source_marker(&root, "npm:@narumitw/pi-btw").unwrap();
4137
4138 let resources = discover(tmp.path(), &["npm:@narumitw/pi-btw".to_string()]);
4139 assert_eq!(resources.packages.len(), 1);
4140 assert!(resources.diagnostics.is_empty());
4141 assert_eq!(resources.packages[0].name, "@narumitw/pi-btw");
4142 }
4143
4144 #[test]
4145 fn npm_scoped_spec_resolves_project_store_and_versioned_spec() {
4146 let tmp = tempfile::tempdir().unwrap();
4147 let root = tmp.path().join(".pi/npm/node_modules/@scope/demo");
4148 std::fs::create_dir_all(&root).unwrap();
4149 std::fs::write(
4150 root.join("package.json"),
4151 r#"{"name":"@scope/demo","version":"1.2.3"}"#,
4152 )
4153 .unwrap();
4154
4155 for spec in ["npm:@scope/demo", "npm:@scope/demo@1.2.3"] {
4156 let resources = discover(tmp.path(), &[spec.to_string()]);
4157 assert!(resources.diagnostics.is_empty(), "spec={spec}");
4158 assert_eq!(resources.packages[0].root, root, "spec={spec}");
4159 }
4160 }
4161
4162 #[test]
4163 fn npm_store_detection_is_bounded_to_the_configured_agent_root() {
4164 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4165 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4166 let tmp = tempfile::tempdir().unwrap();
4167 let agent = tmp.path().join("real-agent");
4168 let package = agent.join("npm/node_modules/@scope/demo");
4169 let impostor = tmp
4170 .path()
4171 .join("workspace/agent/npm/node_modules/@scope/demo");
4172 std::fs::create_dir_all(&package).unwrap();
4173 std::fs::create_dir_all(&impostor).unwrap();
4174 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4175
4176 assert!(is_npm_store_package_path(
4177 &package,
4178 tmp.path(),
4179 ResolveScope::Any
4180 ));
4181 assert_eq!(
4182 npm_install_root_for_path(&package, tmp.path(), ResolveScope::Any),
4183 std::fs::canonicalize(agent.join("npm")).ok()
4184 );
4185 assert!(!is_npm_store_package_path(
4186 &impostor,
4187 tmp.path(),
4188 ResolveScope::Any
4189 ));
4190 assert!(!is_npm_store_package_path(
4191 &agent.join("npm/node_modules"),
4192 tmp.path(),
4193 ResolveScope::Any
4194 ));
4195 let nested = package.join("node_modules/dependency");
4196 std::fs::create_dir_all(&nested).unwrap();
4197 assert!(!is_npm_store_package_path(
4198 &nested,
4199 tmp.path(),
4200 ResolveScope::Any
4201 ));
4202
4203 match previous {
4204 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4205 None => std::env::remove_var(config::CONFIG_DIR_ENV),
4206 }
4207 }
4208
4209 #[test]
4210 fn legacy_global_npm_is_discovered_but_updates_only_in_managed_store() {
4211 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4212 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4213 let tmp = tempfile::tempdir().unwrap();
4214 let agent = tmp.path().join("agent");
4215 let cwd = tmp.path().join("project");
4216 let global_root = tmp.path().join("legacy-global/node_modules");
4217 let global_package = global_root.join("demo");
4218 std::fs::create_dir_all(&agent).unwrap();
4219 std::fs::create_dir_all(&cwd).unwrap();
4220 std::fs::create_dir_all(global_package.join("extensions")).unwrap();
4221 std::fs::write(
4222 global_package.join("package.json"),
4223 r#"{"name":"demo","version":"1.0.0"}"#,
4224 )
4225 .unwrap();
4226 std::fs::write(
4227 global_package.join("extensions/index.js"),
4228 "export default () => {};",
4229 )
4230 .unwrap();
4231 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4232
4233 let resolved =
4234 resolve_spec_with_legacy_lookup(&cwd, "npm:demo", ResolveScope::User, |name| {
4235 assert_eq!(name, "demo");
4236 std::fs::canonicalize(&global_package).ok()
4237 })
4238 .unwrap();
4239 let canonical_global_root = std::fs::canonicalize(&global_root).unwrap();
4240 assert_eq!(
4241 resolved.root,
4242 std::fs::canonicalize(&global_package).unwrap()
4243 );
4244 assert_eq!(
4245 resolved.legacy_npm_root.as_deref(),
4246 Some(canonical_global_root.as_path())
4247 );
4248
4249 let package = load_package_with_legacy_root(
4250 resolved.root,
4251 "npm:demo",
4252 &cwd,
4253 ResolveScope::User,
4254 None,
4255 resolved.legacy_npm_root,
4256 )
4257 .unwrap();
4258 assert_eq!(package.updateable_npm_name(), Some("demo"));
4259 assert!(package.npm_install_root.is_none());
4260 let update_root = package
4261 .npm_store_root_for_update(&cwd, false)
4262 .unwrap()
4263 .unwrap();
4264 assert_eq!(update_root, agent.join("npm"));
4265 assert_ne!(update_root, global_root);
4266 assert!(!is_managed_package_path(
4267 &global_package,
4268 &cwd,
4269 ResolveScope::User
4270 ));
4271
4272 match previous {
4273 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4274 None => std::env::remove_var(config::CONFIG_DIR_ENV),
4275 }
4276 }
4277
4278 #[test]
4279 fn static_managed_npm_precedes_legacy_lookup() {
4280 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4281 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4282 let tmp = tempfile::tempdir().unwrap();
4283 let agent = tmp.path().join("agent");
4284 let package = agent.join("npm/node_modules/demo");
4285 std::fs::create_dir_all(&package).unwrap();
4286 std::fs::write(package.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4287 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4288
4289 let resolved =
4290 resolve_spec_with_legacy_lookup(tmp.path(), "npm:demo", ResolveScope::User, |_| {
4291 panic!("legacy global lookup must not run for a managed package")
4292 })
4293 .unwrap();
4294 assert_eq!(resolved.root, package);
4295 assert!(resolved.legacy_npm_root.is_none());
4296
4297 match previous {
4298 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4299 None => std::env::remove_var(config::CONFIG_DIR_ENV),
4300 }
4301 }
4302
4303 #[test]
4304 fn legacy_global_lookup_is_never_used_for_project_scope() {
4305 let tmp = tempfile::tempdir().unwrap();
4306 let global_root = tmp.path().join("legacy/node_modules/demo");
4307 std::fs::create_dir_all(&global_root).unwrap();
4308 std::fs::write(global_root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4309 let resolved = resolve_spec_with_legacy_lookup(
4310 &tmp.path().join("project"),
4311 "npm:demo",
4312 ResolveScope::Project,
4313 |_| panic!("project scope must not consult a global package manager"),
4314 );
4315 assert!(resolved.is_none());
4316 }
4317
4318 #[test]
4319 fn legacy_manifest_name_mismatch_blocks_package_loading() {
4320 let tmp = tempfile::tempdir().unwrap();
4321 let global_root = tmp.path().join("legacy/node_modules");
4322 let package_root = global_root.join("demo");
4323 std::fs::create_dir_all(&package_root).unwrap();
4324 std::fs::write(
4325 package_root.join("package.json"),
4326 r#"{"name":"other","version":"1.0.0"}"#,
4327 )
4328 .unwrap();
4329 let error = load_package_with_legacy_root(
4330 std::fs::canonicalize(&package_root).unwrap(),
4331 "npm:demo",
4332 tmp.path(),
4333 ResolveScope::User,
4334 None,
4335 std::fs::canonicalize(&global_root).ok(),
4336 )
4337 .unwrap_err();
4338 assert!(error.contains("manifest name `other`"), "{error}");
4339 assert!(error.contains("configured package `demo`"), "{error}");
4340 }
4341
4342 #[test]
4343 fn legacy_npm_without_manifest_identity_blocks_package_loading() {
4344 let tmp = tempfile::tempdir().unwrap();
4345 let global_root = tmp.path().join("legacy/node_modules");
4346 let package_root = global_root.join("demo");
4347 std::fs::create_dir_all(&package_root).unwrap();
4348 std::fs::write(package_root.join("package.json"), r#"{"version":"1.0.0"}"#).unwrap();
4349
4350 let error = load_package_with_legacy_root(
4351 std::fs::canonicalize(&package_root).unwrap(),
4352 "npm:demo",
4353 tmp.path(),
4354 ResolveScope::User,
4355 None,
4356 std::fs::canonicalize(&global_root).ok(),
4357 )
4358 .unwrap_err();
4359
4360 assert!(error.contains("has no string package name"), "{error}");
4361 assert!(error.contains("expected `demo`"), "{error}");
4362 }
4363
4364 #[test]
4365 fn filtered_package_entries_apply_only_the_requested_resources() {
4366 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4367 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4368 let tmp = tempfile::tempdir().unwrap();
4369 let agent = tmp.path().join("agent");
4370 let root = agent.join("packages/demo");
4371 std::fs::create_dir_all(root.join("extensions")).unwrap();
4372 std::fs::create_dir_all(root.join("skills")).unwrap();
4373 std::fs::write(root.join("extensions/index.js"), "export default () => {};").unwrap();
4374 std::fs::write(root.join("skills/review.md"), "review").unwrap();
4375 std::fs::write(
4376 root.join("package.json"),
4377 r#"{"name":"demo","version":"1.0.0"}"#,
4378 )
4379 .unwrap();
4380 write_npm_source_marker(&root, "npm:demo@beta").unwrap();
4381 std::fs::write(
4382 agent.join("settings.json"),
4383 r#"{"packages":[{"source":"npm:demo@beta","autoload":false,"extensions":["+extensions/index.js"]}]}"#,
4384 )
4385 .unwrap();
4386 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4387
4388 let resources = discover_from_global_settings(tmp.path());
4389
4390 match previous {
4391 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4392 None => std::env::remove_var(config::CONFIG_DIR_ENV),
4393 }
4394 assert_eq!(resources.packages.len(), 1);
4395 assert_eq!(
4396 resources.packages[0].updateable_npm_source(),
4397 Some(("demo", "npm:demo@beta"))
4398 );
4399 assert_eq!(
4400 resources.extension_paths(),
4401 vec![root.join("extensions/index.js")]
4402 );
4403 assert!(resources.skill_dirs().is_empty());
4404 assert!(resources.diagnostics.is_empty());
4405 }
4406
4407 #[test]
4408 fn filtered_package_entries_do_not_disable_other_resource_kinds() {
4409 let tmp = tempfile::tempdir().unwrap();
4410 let root = tmp.path().join("package");
4411 std::fs::create_dir_all(root.join("extensions")).unwrap();
4412 std::fs::create_dir_all(root.join("skills")).unwrap();
4413 std::fs::create_dir_all(root.join("prompts")).unwrap();
4414 std::fs::create_dir_all(root.join("themes")).unwrap();
4415 for (path, body) in [
4416 ("extensions/a.js", "export default () => {};"),
4417 ("skills/keep.md", "keep"),
4418 ("skills/drop.md", "drop"),
4419 ("prompts/one.md", "one"),
4420 ("themes/one.json", "{}"),
4421 ] {
4422 std::fs::write(root.join(path), body).unwrap();
4423 }
4424 std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4425 let filter = crate::settings::PackageFilter {
4426 source: root.to_string_lossy().into_owned(),
4427 autoload: None,
4428 extensions: Some(Vec::new()),
4429 skills: Some(vec!["skills/keep.md".to_string()]),
4430 prompts: None,
4431 themes: None,
4432 unknown: serde_json::Map::new(),
4433 };
4434 let package = load_package(
4435 root.clone(),
4436 &filter.source,
4437 tmp.path(),
4438 ResolveScope::Any,
4439 Some(&filter),
4440 )
4441 .unwrap();
4442 assert!(package.extensions.is_empty());
4443 assert_eq!(package.skills, vec![root.join("skills/keep.md")]);
4444 assert_eq!(package.prompts, vec![root.join("prompts/one.md")]);
4445 assert_eq!(package.themes, vec![root.join("themes/one.json")]);
4446 }
4447
4448 #[test]
4449 fn project_autoload_delta_keeps_matching_global_package_resources() {
4450 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4451 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4452 let tmp = tempfile::tempdir().unwrap();
4453 let agent = tmp.path().join("agent");
4454 let cwd = tmp.path().join("project");
4455 let root = tmp.path().join("shared-package");
4456 std::fs::create_dir_all(root.join("extensions")).unwrap();
4457 std::fs::write(root.join("extensions/a.js"), "export default () => {}; ").unwrap();
4458 std::fs::write(root.join("extensions/b.js"), "export default () => {}; ").unwrap();
4459 std::fs::write(root.join("package.json"), r#"{"name":"shared"}"#).unwrap();
4460 let spec = format!("file:{}", root.display());
4461 std::fs::create_dir_all(&agent).unwrap();
4462 std::fs::write(
4463 agent.join("settings.json"),
4464 serde_json::to_vec(&serde_json::json!({"packages":[spec]})).unwrap(),
4465 )
4466 .unwrap();
4467 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
4468 std::fs::write(
4469 cwd.join(".rpi/settings.json"),
4470 serde_json::to_vec(&serde_json::json!({
4471 "packages":[{"source":spec,"autoload":false,"extensions":["+extensions/a.js"]}]
4472 }))
4473 .unwrap(),
4474 )
4475 .unwrap();
4476 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4477 let resources = discover_from_settings(&cwd);
4478 match previous {
4479 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4480 None => std::env::remove_var(config::CONFIG_DIR_ENV),
4481 }
4482 assert_eq!(resources.packages.len(), 1);
4483 assert!(!resources.packages[0].autoload_delta);
4484 assert_eq!(resources.extension_paths().len(), 2);
4485 }
4486
4487 #[test]
4488 fn configured_packages_with_same_manifest_name_keep_distinct_local_roots() {
4489 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4490 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4491 let tmp = tempfile::tempdir().unwrap();
4492 let agent = tmp.path().join("agent");
4493 let cwd = tmp.path().join("project");
4494 let first = tmp.path().join("first");
4495 let second = tmp.path().join("second");
4496 for root in [&first, &second] {
4497 std::fs::create_dir_all(root.join("skills")).unwrap();
4498 std::fs::write(root.join("skills/item.md"), "item").unwrap();
4499 std::fs::write(root.join("package.json"), r#"{"name":"same"}"#).unwrap();
4500 }
4501 std::fs::create_dir_all(&agent).unwrap();
4502 std::fs::write(
4503 agent.join("settings.json"),
4504 serde_json::to_vec(
4505 &serde_json::json!({"packages":[format!("file:{}", second.display())]}),
4506 )
4507 .unwrap(),
4508 )
4509 .unwrap();
4510 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
4511 std::fs::write(
4512 cwd.join(".rpi/settings.json"),
4513 serde_json::to_vec(
4514 &serde_json::json!({"packages":[format!("file:{}", first.display())]}),
4515 )
4516 .unwrap(),
4517 )
4518 .unwrap();
4519 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4520 let resources = discover_from_settings(&cwd);
4521 match previous {
4522 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4523 None => std::env::remove_var(config::CONFIG_DIR_ENV),
4524 }
4525 assert_eq!(resources.packages.len(), 2);
4526 assert_eq!(resources.packages[0].root, first);
4527 assert_eq!(resources.packages[1].root, second);
4528 }
4529
4530 #[test]
4531 fn project_relative_package_paths_resolve_from_pi_config_directory() {
4532 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4533 let tmp = tempfile::tempdir().unwrap();
4534 let previous_config = std::env::var_os(config::CONFIG_DIR_ENV);
4535 let isolated_agent = tmp.path().join("agent");
4536 std::fs::create_dir_all(&isolated_agent).unwrap();
4537 std::env::set_var(config::CONFIG_DIR_ENV, &isolated_agent);
4538 let cwd = tmp.path().join("project");
4539 let root = cwd.join(".pi/packages/demo");
4540 std::fs::create_dir_all(root.join("skills")).unwrap();
4541 std::fs::write(root.join("skills/item.md"), "item").unwrap();
4542 std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4543 std::fs::write(
4544 cwd.join(".pi/settings.json"),
4545 r#"{"packages":["./packages/demo"]}"#,
4546 )
4547 .unwrap();
4548 let resources = discover_from_settings(&cwd);
4549 match previous_config {
4550 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4551 None => std::env::remove_var(config::CONFIG_DIR_ENV),
4552 }
4553 assert_eq!(resources.packages.len(), 1);
4554 assert_eq!(resources.packages[0].root, root);
4555 }
4556
4557 #[test]
4558 fn native_git_sources_resolve_only_inside_pi_git_store() {
4559 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4560 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4561 let tmp = tempfile::tempdir().unwrap();
4562 let agent = tmp.path().join("agent");
4563 std::fs::create_dir_all(&agent).unwrap();
4564 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4565 let cwd = tmp.path().join("project");
4566 let root = cwd.join(".pi/git/github.com/example/repo");
4567 std::fs::create_dir_all(root.join(".git")).unwrap();
4568 std::fs::create_dir_all(root.join("skills")).unwrap();
4569 std::fs::write(root.join("skills/item.md"), "item").unwrap();
4570 std::fs::write(root.join("package.json"), r#"{"name":"repo"}"#).unwrap();
4571 std::fs::create_dir_all(cwd.join(".pi")).unwrap();
4572 std::fs::write(
4573 cwd.join(".pi/settings.json"),
4574 r#"{"packages":["git:https://github.com/example/repo.git@main"]}"#,
4575 )
4576 .unwrap();
4577 let resources = discover_from_settings(&cwd);
4578 match previous {
4579 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4580 None => std::env::remove_var(config::CONFIG_DIR_ENV),
4581 }
4582 assert_eq!(resources.packages.len(), 1);
4583 assert_eq!(resources.packages[0].source, PackageSource::Git);
4584 assert_eq!(resources.packages[0].git_revision.as_deref(), Some("main"));
4585 }
4586
4587 #[test]
4588 fn git_sources_preserve_slash_refs_across_supported_transports() {
4589 let cases = [
4590 (
4591 "git:github.com/example/repo@feature/branch",
4592 "github.com",
4593 "example/repo",
4594 Some("feature/branch"),
4595 ),
4596 (
4597 "https://github.com/example/repo.git@feature/branch",
4598 "github.com",
4599 "example/repo",
4600 Some("feature/branch"),
4601 ),
4602 (
4603 "ssh://git@github.com/example/repo@release/v2",
4604 "github.com",
4605 "example/repo",
4606 Some("release/v2"),
4607 ),
4608 (
4609 "git://github.com/example/repo.git@refs/heads/main",
4610 "github.com",
4611 "example/repo",
4612 Some("refs/heads/main"),
4613 ),
4614 (
4615 "git:git@github.com:example/repo@hotfix/security",
4616 "github.com",
4617 "example/repo",
4618 Some("hotfix/security"),
4619 ),
4620 ];
4621 for (spec, host, path, revision) in cases {
4622 let parsed = parse_git_source(spec).unwrap_or_else(|| panic!("spec={spec}"));
4623 assert_eq!(parsed.host, host, "spec={spec}");
4624 assert_eq!(parsed.path, path, "spec={spec}");
4625 assert_eq!(parsed.revision.as_deref(), revision, "spec={spec}");
4626 }
4627 }
4628
4629 #[test]
4630 fn git_source_parser_rejects_encoded_traversal_and_unsafe_refs() {
4631 for spec in [
4632 "git:git@evil.example:../../victim/repo",
4633 "https://evil.example/..%2F..%2Fvictim/repo",
4634 "git:github.com/example/repo@../escape",
4635 "git:github.com/example/repo@-upload-pack=evil",
4636 "git:github.com/example/repo@feature\\branch",
4637 "git:github.com/example/repo@feature%2F..%2Fescape",
4638 ] {
4639 assert!(parse_git_source(spec).is_none(), "spec={spec}");
4640 }
4641 }
4642
4643 #[test]
4644 fn git_source_parser_preserves_remote_transport_authority() {
4645 let shorthand = parse_git_source("git:github.com/example/repo").unwrap();
4646 assert_eq!(shorthand.transport, GitTransport::Https);
4647 assert_eq!(shorthand.port, None);
4648 assert_eq!(shorthand.user_info, None);
4649
4650 let https = parse_git_source("https://token@github.com:8443/example/repo.git").unwrap();
4651 assert_eq!(https.transport, GitTransport::Https);
4652 assert_eq!(https.port, Some(8443));
4653 assert_eq!(https.user_info.as_deref(), Some("token"));
4654
4655 let scp = parse_git_source("git:git@github.com:example/repo").unwrap();
4656 assert_eq!(scp.transport, GitTransport::Ssh);
4657 assert_eq!(scp.port, None);
4658 assert_eq!(scp.user_info.as_deref(), Some("git"));
4659
4660 for invalid in [
4661 "https://github.com:70000/example/repo",
4662 "https://user @github.com/example/repo",
4663 ] {
4664 assert!(parse_git_source(invalid).is_none(), "spec={invalid}");
4665 }
4666 }
4667
4668 #[test]
4669 fn pinned_git_packages_are_selected_for_manual_updates() {
4670 let tmp = tempfile::tempdir().unwrap();
4671 let root = tmp.path().join(".pi/git/github.com/example/repo");
4672 std::fs::create_dir_all(root.join(".git")).unwrap();
4673 std::fs::write(root.join("package.json"), r#"{"name":"repo"}"#).unwrap();
4674 let package = load_package(
4675 root,
4676 "git:github.com/example/repo@feature/branch",
4677 tmp.path(),
4678 ResolveScope::Any,
4679 None,
4680 )
4681 .unwrap();
4682 assert_eq!(package.source, PackageSource::Git);
4683 assert_eq!(package.git_revision.as_deref(), Some("feature/branch"));
4684 assert!(package.updateable_git_source());
4685 }
4686
4687 #[test]
4688 fn missing_npm_update_targets_use_native_managed_roots_and_keep_pins() {
4689 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4690 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4691 let tmp = tempfile::tempdir().unwrap();
4692 let cwd = tmp.path().join("project");
4693 let agent = tmp.path().join("agent");
4694 std::fs::create_dir_all(&cwd).unwrap();
4695 std::fs::create_dir_all(&agent).unwrap();
4696 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4697
4698 let project =
4699 missing_package_for_update(&cwd, "npm:@scope/demo@beta", ResolveScope::Project, None)
4700 .unwrap()
4701 .unwrap();
4702 assert!(project.missing_install);
4703 assert_eq!(project.name, "@scope/demo");
4704 assert_eq!(project.root, cwd.join(".pi/npm/node_modules/@scope/demo"));
4705 assert_eq!(project.npm_install_root, Some(cwd.join(".pi/npm")));
4706 assert_eq!(
4707 project.updateable_npm_source(),
4708 Some(("@scope/demo", "npm:@scope/demo@beta"))
4709 );
4710
4711 let user = missing_package_for_update(&cwd, "npm:demo@1.2.3", ResolveScope::User, None)
4712 .unwrap()
4713 .unwrap();
4714 assert_eq!(user.root, agent.join("npm/node_modules/demo"));
4715 assert_eq!(user.npm_install_root, Some(agent.join("npm")));
4716 assert_eq!(
4717 user.updateable_npm_source(),
4718 Some(("demo", "npm:demo@1.2.3"))
4719 );
4720
4721 match previous {
4722 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4723 None => std::env::remove_var(config::CONFIG_DIR_ENV),
4724 }
4725 }
4726
4727 #[test]
4728 fn missing_git_update_target_preserves_ref_and_scope() {
4729 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4730 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4731 let tmp = tempfile::tempdir().unwrap();
4732 let cwd = tmp.path().join("project");
4733 let agent = tmp.path().join("agent");
4734 std::fs::create_dir_all(&cwd).unwrap();
4735 std::fs::create_dir_all(&agent).unwrap();
4736 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4737
4738 let project = missing_package_for_update(
4739 &cwd,
4740 "git:github.com/example/repo@feature/branch",
4741 ResolveScope::Project,
4742 None,
4743 )
4744 .unwrap()
4745 .unwrap();
4746 assert!(project.missing_install);
4747 assert!(project.updateable_git_source());
4748 assert_eq!(project.name, "repo");
4749 assert_eq!(project.root, cwd.join(".pi/git/github.com/example/repo"));
4750 assert_eq!(project.git_store_root, Some(cwd.join(".pi/git")));
4751 assert_eq!(project.git_revision.as_deref(), Some("feature/branch"));
4752 assert_eq!(
4753 update_recovery_targets(
4754 &cwd,
4755 "git:github.com/example/repo@feature/branch",
4756 ResolveScope::Project,
4757 ),
4758 vec![
4759 cwd.join(".rpi/git/github.com/example/repo"),
4760 cwd.join(".pi/git/github.com/example/repo"),
4761 ]
4762 );
4763
4764 let user = missing_package_for_update(
4765 &cwd,
4766 "https://github.com/example/other.git@release/v2",
4767 ResolveScope::User,
4768 None,
4769 )
4770 .unwrap()
4771 .unwrap();
4772 assert_eq!(user.root, agent.join("git/github.com/example/other"));
4773 assert_eq!(user.git_store_root, Some(agent.join("git")));
4774 assert_eq!(user.git_revision.as_deref(), Some("release/v2"));
4775 let mut recovery_targets = vec![agent.join("git/github.com/example/other")];
4776 if let Some(home) = dirs::home_dir() {
4777 recovery_targets.push(home.join(".pi/agent/git/github.com/example/other"));
4778 }
4779 assert_eq!(
4780 update_recovery_targets(
4781 &cwd,
4782 "https://github.com/example/other.git@release/v2",
4783 ResolveScope::User,
4784 ),
4785 recovery_targets
4786 );
4787
4788 match previous {
4789 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4790 None => std::env::remove_var(config::CONFIG_DIR_ENV),
4791 }
4792 }
4793
4794 #[test]
4795 fn update_discovery_represents_missing_registry_and_git_sources_only() {
4796 let tmp = tempfile::tempdir().unwrap();
4797 let cwd = tmp.path().join("project");
4798 std::fs::create_dir_all(&cwd).unwrap();
4799 let entries = [
4800 "npm:demo@latest",
4801 "npm:fixed@1.2.3",
4802 "git:github.com/example/repo@main",
4803 "file:missing-local",
4804 ]
4805 .into_iter()
4806 .map(|source| crate::settings::PackageSetting::from(source.to_string()))
4807 .collect::<Vec<_>>();
4808
4809 let resources =
4810 discover_with_scope_and_command(&cwd, &entries, ResolveScope::Project, true, None);
4811 assert_eq!(resources.packages.len(), 3);
4812 assert!(resources
4813 .packages
4814 .iter()
4815 .all(|package| package.missing_install));
4816 assert!(resources.diagnostics.is_empty());
4817
4818 let ordinary =
4819 discover_with_scope_and_command(&cwd, &entries, ResolveScope::Project, false, None);
4820 assert!(ordinary.packages.is_empty());
4821 assert_eq!(ordinary.diagnostics.len(), entries.len());
4822 }
4823
4824 #[test]
4825 fn git_metadata_requires_a_real_directory() {
4826 let tmp = tempfile::tempdir().unwrap();
4827 let git_file = tmp.path().join(".git");
4828 std::fs::write(&git_file, "gitdir: ../outside/.git\n").unwrap();
4829 assert!(!is_real_git_metadata(&git_file));
4830 std::fs::remove_file(&git_file).unwrap();
4831 std::fs::create_dir(&git_file).unwrap();
4832 assert!(is_real_git_metadata(&git_file));
4833 }
4834
4835 #[test]
4836 fn marker_source_updates_ranges_and_tags_but_skips_exact_versions() {
4837 let tmp = tempfile::tempdir().unwrap();
4838 let root = tmp.path().join(".rpi/packages/demo");
4839 std::fs::create_dir_all(&root).unwrap();
4840 std::fs::write(
4841 root.join("package.json"),
4842 r#"{"name":"demo","version":"1.0.0"}"#,
4843 )
4844 .unwrap();
4845
4846 let file_spec = format!("file:{}", root.display());
4847 for (source_spec, updateable) in [
4848 ("npm:demo@1.0.0", false),
4849 ("npm:demo@1.0.0-beta.1", false),
4850 ("npm:demo@^1", true),
4851 ("npm:demo@latest", true),
4852 ("npm:demo@beta", true),
4853 ("npm:demo", true),
4854 ] {
4855 write_npm_source_marker(&root, source_spec).unwrap();
4856 let package = load_package(
4857 root.clone(),
4858 &file_spec,
4859 tmp.path(),
4860 ResolveScope::Any,
4861 None,
4862 )
4863 .unwrap();
4864 assert_eq!(
4865 package.updateable_npm_name().is_some(),
4866 updateable,
4867 "source_spec={source_spec}"
4868 );
4869 }
4870 }
4871
4872 #[test]
4873 fn explicit_npm_in_ordinary_node_modules_is_never_updateable() {
4874 let tmp = tempfile::tempdir().unwrap();
4875 let root = tmp.path().join("node_modules/demo");
4876 std::fs::create_dir_all(&root).unwrap();
4877 std::fs::write(
4878 root.join("package.json"),
4879 r#"{"name":"demo","version":"1.0.0"}"#,
4880 )
4881 .unwrap();
4882 write_npm_source_marker(&root, "npm:demo").unwrap();
4883 let package = load_package(root, "npm:demo", tmp.path(), ResolveScope::Any, None).unwrap();
4884 assert_eq!(package.source, PackageSource::Unknown);
4885 assert_eq!(package.updateable_npm_name(), None);
4886 }
4887
4888 #[test]
4889 fn managed_npm_marker_must_match_manifest_name() {
4890 let tmp = tempfile::tempdir().unwrap();
4891 let root = tmp.path().join(".rpi/packages/demo");
4892 std::fs::create_dir_all(&root).unwrap();
4893 std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4894 write_npm_source_marker(&root, "npm:other").unwrap();
4895 let spec = format!("file:{}", root.display());
4896 let package = load_package(root, &spec, tmp.path(), ResolveScope::Any, None).unwrap();
4897 assert_eq!(package.source, PackageSource::Local);
4898 }
4899
4900 #[test]
4901 fn explicit_npm_source_must_match_marker_and_manifest() {
4902 let tmp = tempfile::tempdir().unwrap();
4903 let root = tmp.path().join(".rpi/packages/demo");
4904 std::fs::create_dir_all(&root).unwrap();
4905 std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4906 write_npm_source_marker(&root, "npm:demo@beta").unwrap();
4907
4908 let matching = load_package(
4909 root.clone(),
4910 "npm:demo@beta",
4911 tmp.path(),
4912 ResolveScope::Any,
4913 None,
4914 )
4915 .unwrap();
4916 assert_eq!(
4917 matching.updateable_npm_source(),
4918 Some(("demo", "npm:demo@beta"))
4919 );
4920
4921 let wrong_name = load_package(
4922 root.clone(),
4923 "npm:other@beta",
4924 tmp.path(),
4925 ResolveScope::Any,
4926 None,
4927 )
4928 .unwrap_err();
4929 assert!(wrong_name.contains("manifest name `demo`"), "{wrong_name}");
4930
4931 let wrong_selector =
4932 load_package(root, "npm:demo@^1", tmp.path(), ResolveScope::Any, None).unwrap_err();
4933 assert!(wrong_selector.contains("provenance"), "{wrong_selector}");
4934 }
4935
4936 #[test]
4937 fn managed_npm_alias_marker_target_mismatch_blocks_loading() {
4938 let tmp = tempfile::tempdir().unwrap();
4939 let root = tmp.path().join(".rpi/packages/alias");
4940 std::fs::create_dir_all(&root).unwrap();
4941 std::fs::write(
4942 root.join("package.json"),
4943 r#"{"name":"real","version":"1.2.3"}"#,
4944 )
4945 .unwrap();
4946 write_npm_source_marker(&root, "npm:alias@npm:other@^1").unwrap();
4947
4948 let error = load_package(
4949 root,
4950 "npm:alias@npm:real@^1",
4951 tmp.path(),
4952 ResolveScope::Any,
4953 None,
4954 )
4955 .unwrap_err();
4956
4957 assert!(error.contains("provenance"), "{error}");
4958 }
4959
4960 #[test]
4961 fn explicit_native_npm_without_manifest_identity_is_not_loadable() {
4962 let tmp = tempfile::tempdir().unwrap();
4963 let cwd = tmp.path().join("project");
4964 let root = cwd.join(".pi/npm/node_modules/demo");
4965 std::fs::create_dir_all(root.join("extensions")).unwrap();
4966 std::fs::write(root.join("extensions/index.js"), "export default () => {};").unwrap();
4967 let entries = [crate::settings::PackageSetting::from(
4968 "npm:demo".to_string(),
4969 )];
4970
4971 for manifest in [None, Some(r#"{"version":"1.0.0"}"#)] {
4972 if let Some(manifest) = manifest {
4973 std::fs::write(root.join("package.json"), manifest).unwrap();
4974 }
4975 let resources =
4976 discover_with_scope_and_command(&cwd, &entries, ResolveScope::Project, false, None);
4977 assert!(resources.packages.is_empty(), "manifest={manifest:?}");
4978 assert!(
4979 resources.extension_paths().is_empty(),
4980 "manifest={manifest:?}"
4981 );
4982 assert_eq!(resources.diagnostics.len(), 1, "manifest={manifest:?}");
4983 assert!(
4984 resources.diagnostics[0]
4985 .message
4986 .contains("has no string package name"),
4987 "{}",
4988 resources.diagnostics[0].message
4989 );
4990 }
4991 }
4992
4993 #[test]
4994 fn managed_file_entry_remains_updateable_after_changing_cwd() {
4995 let tmp = tempfile::tempdir().unwrap();
4996 let root = tmp.path().join("project-a/.rpi/packages/demo");
4997 let other_cwd = tmp.path().join("project-b");
4998 std::fs::create_dir_all(&root).unwrap();
4999 std::fs::create_dir_all(&other_cwd).unwrap();
5000 std::fs::write(
5001 root.join("package.json"),
5002 r#"{"name":"demo","version":"1.0.0"}"#,
5003 )
5004 .unwrap();
5005 write_npm_source_marker(&root, "npm:demo@beta").unwrap();
5006 let spec = format!("file:{}", root.display());
5007
5008 let package = load_package(root, &spec, &other_cwd, ResolveScope::User, None).unwrap();
5009 assert_eq!(
5010 package.updateable_npm_source(),
5011 Some(("demo", "npm:demo@beta"))
5012 );
5013 }
5014
5015 #[test]
5016 fn legacy_file_entry_for_managed_git_clone_keeps_git_provenance() {
5017 let tmp = tempfile::tempdir().unwrap();
5018 let root = tmp.path().join(".rpi/packages/demo");
5019 std::fs::create_dir_all(root.join(".git")).unwrap();
5020 std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5021 let spec = format!("file:{}", root.display());
5022 let package = load_package(root, &spec, tmp.path(), ResolveScope::Any, None).unwrap();
5023 assert_eq!(package.source, PackageSource::Git);
5024 }
5025
5026 #[test]
5027 fn native_scoped_npm_root_has_registry_provenance() {
5028 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5029 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5030 let tmp = tempfile::tempdir().unwrap();
5031 let agent = tmp.path().join(".pi/agent");
5032 let root = agent.join("npm/node_modules/@scope/demo");
5033 std::fs::create_dir_all(&root).unwrap();
5034 std::fs::write(
5035 root.join("package.json"),
5036 r#"{"name":"@scope/demo","version":"1.0.0"}"#,
5037 )
5038 .unwrap();
5039 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5040 let package =
5041 load_package(root, "@scope/demo", tmp.path(), ResolveScope::Any, None).unwrap();
5042 match previous {
5043 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5044 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5045 }
5046 assert_eq!(package.updateable_npm_name(), Some("@scope/demo"));
5047 }
5048
5049 #[test]
5050 fn exact_semver_and_npm_name_validation_are_conservative() {
5051 for version in ["1.2.3", "v1.2.3", "1.2.3-beta.1", "1.2.3+build"] {
5052 assert!(is_exact_npm_version(version), "version={version}");
5053 }
5054 for version in ["1", "1.2", "^1.2.3", "latest", "1.2.3-", "1.02.3"] {
5055 assert!(!is_exact_npm_version(version), "version={version}");
5056 }
5057 for name in [
5058 "-rf",
5059 "--workspace",
5060 "@scope/..",
5061 "@scope/a\\b",
5062 "@scope/a?b",
5063 "a b",
5064 "a#b",
5065 ] {
5066 assert!(!valid_npm_name(name), "name={name}");
5067 }
5068 }
5069
5070 #[test]
5071 fn npm_alias_parser_separates_install_slot_from_manifest_name() {
5072 for (spec, install_name, manifest_name, requested, target_selector) in [
5073 ("alias@npm:real", "alias", "real", "npm:real", None),
5074 (
5075 "npm:@scope/alias@npm:real@^1",
5076 "@scope/alias",
5077 "real",
5078 "npm:real@^1",
5079 Some("^1"),
5080 ),
5081 (
5082 "alias@npm:@target/real@beta",
5083 "alias",
5084 "@target/real",
5085 "npm:@target/real@beta",
5086 Some("beta"),
5087 ),
5088 (
5089 "@scope/alias@npm:@target/real@1.2.3",
5090 "@scope/alias",
5091 "@target/real",
5092 "npm:@target/real@1.2.3",
5093 Some("1.2.3"),
5094 ),
5095 ] {
5096 let parsed = parse_npm_package_spec(spec).unwrap_or_else(|| panic!("spec={spec}"));
5097 assert_eq!(parsed.install_name, install_name, "spec={spec}");
5098 assert_eq!(parsed.manifest_name, manifest_name, "spec={spec}");
5099 assert_eq!(parsed.requested.as_deref(), Some(requested), "spec={spec}");
5100 assert_eq!(
5101 parsed.target_selector.as_deref(),
5102 target_selector,
5103 "spec={spec}"
5104 );
5105 assert!(parsed.is_alias, "spec={spec}");
5106 }
5107
5108 for spec in [
5109 "alias@npm:real@npm:other",
5110 "alias@file:../real",
5111 "alias@npm:real@file:../other",
5112 "alias@git:https://example.com/repo.git",
5113 "alias@npm:",
5114 "@scope/alias@npm:@target/real@npm:other",
5115 "alias@npm:real\nlatest",
5116 ] {
5117 assert!(parse_npm_package_spec(spec).is_none(), "spec={spec}");
5118 }
5119 }
5120
5121 #[test]
5122 fn managed_npm_alias_keeps_provenance_and_rejects_manifest_mismatch() {
5123 let tmp = tempfile::tempdir().unwrap();
5124 let root = tmp.path().join(".pi/npm/node_modules/@scope/alias");
5125 std::fs::create_dir_all(&root).unwrap();
5126 std::fs::write(
5127 root.join("package.json"),
5128 r#"{"name":"@target/real","version":"1.2.3"}"#,
5129 )
5130 .unwrap();
5131 let spec = "npm:@scope/alias@npm:@target/real@^1";
5132
5133 let package =
5134 load_package(root.clone(), spec, tmp.path(), ResolveScope::Project, None).unwrap();
5135 assert_eq!(package.name, "@target/real");
5136 assert_eq!(package_identity(&package), "npm:@scope/alias");
5137 assert_eq!(
5138 package.updateable_npm_source(),
5139 Some(("@scope/alias", spec))
5140 );
5141
5142 std::fs::write(
5143 root.join("package.json"),
5144 r#"{"name":"@target/wrong","version":"1.2.3"}"#,
5145 )
5146 .unwrap();
5147 let mismatched =
5148 load_package(root, spec, tmp.path(), ResolveScope::Project, None).unwrap_err();
5149 assert!(
5150 mismatched.contains("manifest name `@target/wrong`"),
5151 "{mismatched}"
5152 );
5153 }
5154
5155 #[test]
5156 fn runtime_npm_alias_matches_target_selector_and_pinning() {
5157 let tmp = tempfile::tempdir().unwrap();
5158 for (slot, target, version, selector, needs_install, pinned) in [
5159 (
5160 "exact-match",
5161 "real-exact-match",
5162 "1.2.3",
5163 "1.2.3",
5164 false,
5165 true,
5166 ),
5167 (
5168 "exact-stale",
5169 "real-exact-stale",
5170 "1.2.4",
5171 "1.2.3",
5172 true,
5173 true,
5174 ),
5175 (
5176 "range-match",
5177 "real-range-match",
5178 "1.9.0",
5179 "^1.2.3",
5180 false,
5181 false,
5182 ),
5183 (
5184 "range-stale",
5185 "real-range-stale",
5186 "2.0.0",
5187 "^1.2.3",
5188 true,
5189 false,
5190 ),
5191 ("tag", "real-tag", "1.0.0", "beta", false, false),
5192 ] {
5193 let root = tmp.path().join(".pi/npm/node_modules").join(slot);
5194 std::fs::create_dir_all(&root).unwrap();
5195 std::fs::write(
5196 root.join("package.json"),
5197 serde_json::to_vec(&serde_json::json!({
5198 "name": target,
5199 "version": version
5200 }))
5201 .unwrap(),
5202 )
5203 .unwrap();
5204 let spec = format!("npm:{slot}@npm:{target}@{selector}");
5205 let package =
5206 load_package(root, &spec, tmp.path(), ResolveScope::Project, None).unwrap();
5207
5208 assert_eq!(
5209 runtime_npm_needs_install(&package),
5210 needs_install,
5211 "spec={spec}"
5212 );
5213 assert_eq!(
5214 matches!(package.source, PackageSource::Npm { pinned: true, .. }),
5215 pinned,
5216 "spec={spec}"
5217 );
5218 assert_eq!(
5219 package.updateable_npm_source().is_some(),
5220 !pinned,
5221 "spec={spec}"
5222 );
5223 }
5224 }
5225
5226 #[test]
5227 fn runtime_npm_version_matching_covers_native_common_ranges() {
5228 for (installed, requested, expected) in [
5229 (Some("1.2.3"), "1.2.3", Some(true)),
5230 (Some("1.2.4"), "1.2.3", Some(false)),
5231 (Some("1.9.0"), "^1.2.3", Some(true)),
5232 (Some("2.0.0"), "^1.2.3", Some(false)),
5233 (Some("1.2.9"), "~1.2.3", Some(true)),
5234 (Some("1.3.0"), "~1.2.3", Some(false)),
5235 (Some("1.2.9"), "1.2", Some(true)),
5236 (Some("1.3.0"), "1.2", Some(false)),
5237 (Some("1.5.0"), ">=1.2.0 <2.0.0", Some(true)),
5238 (Some("1.9.9"), ">= 2.0.0", Some(false)),
5239 (Some("2.0.0"), ">= 2.0.0", Some(true)),
5240 (Some("2.5.0"), ">= 2.0.0 < 3.0.0", Some(true)),
5241 (Some("3.0.0"), ">= 2.0.0 < 3.0.0", Some(false)),
5242 (Some("2.1.0"), "^1 || ^2", Some(true)),
5243 (Some("3.0.0"), "^1 || ^2", Some(false)),
5244 (Some("1.3.9"), "1.2 - 1.3", Some(true)),
5245 (Some("1.4.0"), "1.2 - 1.3", Some(false)),
5246 (Some("2.9.0"), "1 - 2", Some(true)),
5247 (Some("3.0.0"), "1 - 2", Some(false)),
5248 (Some("1.0.0"), "latest", None),
5249 (None, "1.2.3", Some(false)),
5250 ] {
5251 assert_eq!(
5252 npm_version_matches_requirement(installed, requested),
5253 expected,
5254 "installed={installed:?}, requested={requested}"
5255 );
5256 }
5257 }
5258
5259 #[test]
5260 fn runtime_missing_exact_npm_fails_closed_for_invalid_command() {
5261 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5262 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5263 let tmp = tempfile::tempdir().unwrap();
5264 let agent = tmp.path().join("agent");
5265 let cwd = tmp.path().join("project");
5266 std::fs::create_dir_all(&agent).unwrap();
5267 std::fs::create_dir_all(&cwd).unwrap();
5268 std::fs::write(
5269 agent.join("settings.json"),
5270 r#"{"packages":["npm:demo@1.2.3"],"npmCommand":[""]}"#,
5271 )
5272 .unwrap();
5273 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5274
5275 let resources = resolve_from_global_settings(&cwd);
5276
5277 match previous {
5278 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5279 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5280 }
5281 assert!(resources.packages.is_empty());
5282 assert_eq!(resources.diagnostics.len(), 1);
5283 assert!(resources.diagnostics[0]
5284 .message
5285 .contains("invalid npmCommand"));
5286 assert!(!agent.join("npm/node_modules/demo").exists());
5287 }
5288
5289 #[test]
5290 fn offline_runtime_quarantines_mismatched_npm_but_keeps_matching_range() {
5291 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5292 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5293 let tmp = tempfile::tempdir().unwrap();
5294 let agent = tmp.path().join("agent");
5295 let cwd = tmp.path().join("project");
5296 for (name, version) in [("stale", "1.0.0"), ("matching", "1.5.0")] {
5297 let root = agent.join("npm/node_modules").join(name);
5298 std::fs::create_dir_all(root.join("extensions")).unwrap();
5299 std::fs::write(
5300 root.join("package.json"),
5301 serde_json::to_vec(&serde_json::json!({
5302 "name": name,
5303 "version": version
5304 }))
5305 .unwrap(),
5306 )
5307 .unwrap();
5308 std::fs::write(root.join("extensions/index.js"), "export default () => {};").unwrap();
5309 }
5310 std::fs::create_dir_all(&agent).unwrap();
5311 std::fs::create_dir_all(&cwd).unwrap();
5312 std::fs::write(
5313 agent.join("settings.json"),
5314 r#"{
5315 "npmCommand":[""],
5316 "packages":["npm:stale@2.0.0","npm:matching@^1.0.0"]
5317 }"#,
5318 )
5319 .unwrap();
5320 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5321
5322 let resources = resolve_offline_from_global_settings(&cwd);
5323
5324 match previous {
5325 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5326 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5327 }
5328 assert_eq!(resources.packages.len(), 1);
5329 assert_eq!(resources.packages[0].name, "matching");
5330 assert_eq!(resources.diagnostics.len(), 1);
5331 assert_eq!(resources.diagnostics[0].spec, "npm:stale@2.0.0");
5332 assert!(resources.diagnostics[0].message.contains("offline"));
5333 }
5334
5335 #[test]
5336 fn offline_runtime_never_invokes_configured_npm_for_legacy_lookup() {
5337 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5338 let _restore_config = RestoreEnv::capture(config::CONFIG_DIR_ENV);
5339 let tmp = tempfile::tempdir().unwrap();
5340 let agent = tmp.path().join("agent");
5341 let cwd = tmp.path().join("project");
5342 let marker = tmp.path().join("npm-command-ran");
5343 let script = tmp
5344 .path()
5345 .join(if cfg!(windows) { "npm.ps1" } else { "npm.sh" });
5346 let script_body = if cfg!(windows) {
5347 format!(
5348 "Set-Content -LiteralPath '{}' -Value invoked\nexit 0\n",
5349 marker.to_string_lossy().replace('\'', "''")
5350 )
5351 } else {
5352 format!(
5353 "printf invoked > '{}'\nexit 0\n",
5354 marker.to_string_lossy().replace('\'', "'\\''")
5355 )
5356 };
5357 std::fs::create_dir_all(&agent).unwrap();
5358 std::fs::create_dir_all(&cwd).unwrap();
5359 std::fs::write(&script, script_body).unwrap();
5360 let npm_command = if cfg!(windows) {
5361 vec![
5362 "powershell.exe".to_string(),
5363 "-NoProfile".to_string(),
5364 "-NonInteractive".to_string(),
5365 "-File".to_string(),
5366 script.to_string_lossy().into_owned(),
5367 ]
5368 } else {
5369 vec!["sh".to_string(), script.to_string_lossy().into_owned()]
5370 };
5371 std::fs::write(
5372 agent.join("settings.json"),
5373 serde_json::to_vec(&serde_json::json!({
5374 "npmCommand": npm_command,
5375 "packages": ["npm:missing-legacy-package"]
5376 }))
5377 .unwrap(),
5378 )
5379 .unwrap();
5380 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5381
5382 let resources = resolve_offline_from_global_settings(&cwd);
5383
5384 assert!(resources.packages.is_empty());
5385 assert_eq!(resources.diagnostics.len(), 1);
5386 assert!(
5387 !marker.exists(),
5388 "offline package discovery unexpectedly launched npmCommand"
5389 );
5390 }
5391
5392 #[test]
5393 fn runtime_rechecks_version_after_a_noop_package_manager_success() {
5394 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5395 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5396 let tmp = tempfile::tempdir().unwrap();
5397 let agent = tmp.path().join("agent");
5398 let cwd = tmp.path().join("project");
5399 let root = agent.join("npm/node_modules/demo");
5400 std::fs::create_dir_all(&root).unwrap();
5401 std::fs::create_dir_all(&cwd).unwrap();
5402 std::fs::write(
5403 root.join("package.json"),
5404 r#"{"name":"demo","version":"1.0.0"}"#,
5405 )
5406 .unwrap();
5407 let noop_script = tmp
5408 .path()
5409 .join(if cfg!(windows) { "noop.ps1" } else { "noop.sh" });
5410 std::fs::write(&noop_script, "exit 0\n").unwrap();
5411 let command = if cfg!(windows) {
5412 vec![
5413 "powershell.exe",
5414 "-NoProfile",
5415 "-NonInteractive",
5416 "-File",
5417 noop_script.to_str().unwrap(),
5418 ]
5419 } else {
5420 vec!["sh", noop_script.to_str().unwrap()]
5421 };
5422 std::fs::create_dir_all(&agent).unwrap();
5423 std::fs::write(
5424 agent.join("settings.json"),
5425 serde_json::to_vec(&serde_json::json!({
5426 "npmCommand": command,
5427 "packages": ["npm:demo@2.0.0"]
5428 }))
5429 .unwrap(),
5430 )
5431 .unwrap();
5432 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5433
5434 let resources = resolve_from_global_settings(&cwd);
5435
5436 match previous {
5437 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5438 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5439 }
5440 assert!(resources.packages.is_empty());
5441 assert_eq!(resources.diagnostics.len(), 1);
5442 assert!(resources.diagnostics[0]
5443 .message
5444 .contains("still does not satisfy"));
5445 assert_eq!(
5446 serde_json::from_str::<serde_json::Value>(
5447 &std::fs::read_to_string(root.join("package.json")).unwrap()
5448 )
5449 .unwrap()["version"],
5450 "1.0.0"
5451 );
5452 }
5453
5454 #[test]
5455 fn global_npm_spec_cannot_be_shadowed_by_project_store_or_node_modules() {
5456 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5457 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5458 let tmp = tempfile::tempdir().unwrap();
5459 let agent = tmp.path().join("user-agent");
5460 let cwd = tmp.path().join("workspace/project");
5461 let user = agent.join("npm/node_modules/demo");
5462 for root in [&user, &cwd.join(".rpi/packages/demo")] {
5463 std::fs::create_dir_all(root).unwrap();
5464 std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5465 }
5466 let workspace_package = tmp.path().join("workspace/node_modules/demo");
5467 std::fs::create_dir_all(&workspace_package).unwrap();
5468 std::fs::write(workspace_package.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5469 std::fs::create_dir_all(&agent).unwrap();
5470 std::fs::write(agent.join("settings.json"), r#"{"packages":["npm:demo"]}"#).unwrap();
5471 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5472
5473 let resources = discover_from_global_settings(&cwd);
5474 match previous {
5475 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5476 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5477 }
5478 assert_eq!(resources.packages.len(), 1);
5479 assert_eq!(resources.packages[0].root, user);
5480 }
5481
5482 #[test]
5483 fn configured_project_and_user_packages_resolve_in_separate_scopes() {
5484 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5485 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5486 let tmp = tempfile::tempdir().unwrap();
5487 let agent = tmp.path().join("user-agent");
5488 let cwd = tmp.path().join("project");
5489 let project_root = cwd.join(".rpi/packages/demo");
5490 let user_root = agent.join("packages/demo");
5491 for root in [&project_root, &user_root] {
5492 std::fs::create_dir_all(root).unwrap();
5493 std::fs::write(
5494 root.join("package.json"),
5495 r#"{"name":"demo","version":"1.0.0"}"#,
5496 )
5497 .unwrap();
5498 write_npm_source_marker(root, "npm:demo").unwrap();
5499 }
5500 std::fs::create_dir_all(&agent).unwrap();
5501 std::fs::write(agent.join("settings.json"), r#"{"packages":["npm:demo"]}"#).unwrap();
5502 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5503
5504 let user_only = discover_from_settings(&cwd);
5505 assert_eq!(user_only.packages.len(), 1);
5506 assert_eq!(user_only.packages[0].root, user_root);
5507
5508 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
5509 std::fs::write(
5510 cwd.join(".rpi/settings.json"),
5511 r#"{"packages":["npm:demo"]}"#,
5512 )
5513 .unwrap();
5514 let combined = discover_from_settings(&cwd);
5515 assert_eq!(combined.packages.len(), 1);
5516 assert_eq!(combined.packages[0].root, project_root);
5517
5518 match previous {
5519 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5520 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5521 }
5522 }
5523
5524 #[test]
5525 fn update_discovery_keeps_same_identity_in_both_scopes() {
5526 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5527 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5528 let tmp = tempfile::tempdir().unwrap();
5529 let agent = tmp.path().join("agent");
5530 let cwd = tmp.path().join("project");
5531 let project_root = cwd.join(".pi/npm/node_modules/demo");
5532 let user_root = agent.join("npm/node_modules/demo");
5533 for root in [&project_root, &user_root] {
5534 std::fs::create_dir_all(root).unwrap();
5535 std::fs::write(
5536 root.join("package.json"),
5537 r#"{"name":"demo","version":"1.0.0"}"#,
5538 )
5539 .unwrap();
5540 }
5541 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
5542 std::fs::write(
5543 cwd.join(".rpi/settings.json"),
5544 r#"{"packages":["npm:demo@latest"]}"#,
5545 )
5546 .unwrap();
5547 std::fs::create_dir_all(&agent).unwrap();
5548 std::fs::write(
5549 agent.join("settings.json"),
5550 r#"{"packages":["npm:demo@latest"]}"#,
5551 )
5552 .unwrap();
5553 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5554
5555 let resources = discover_from_settings_for_update(&cwd, true).unwrap().0;
5556
5557 match previous {
5558 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5559 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5560 }
5561 assert_eq!(resources.packages.len(), 2);
5562 assert!(resources
5563 .packages
5564 .iter()
5565 .any(|package| package.root == project_root));
5566 assert!(resources
5567 .packages
5568 .iter()
5569 .any(|package| package.root == user_root));
5570 }
5571
5572 #[test]
5573 fn update_discovery_ignores_untrusted_project_settings_but_keeps_user_packages() {
5574 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5575 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5576 let tmp = tempfile::tempdir().unwrap();
5577 let agent = tmp.path().join("agent");
5578 let cwd = tmp.path().join("project");
5579 let user_root = agent.join("packages/user-demo");
5580 let project_root = cwd.join(".rpi/packages/project-demo");
5581 for (root, name) in [(&user_root, "user-demo"), (&project_root, "project-demo")] {
5582 std::fs::create_dir_all(root).unwrap();
5583 std::fs::write(
5584 root.join("package.json"),
5585 serde_json::to_vec(&serde_json::json!({"name": name, "version": "1.0.0"})).unwrap(),
5586 )
5587 .unwrap();
5588 write_npm_source_marker(root, &format!("npm:{name}")).unwrap();
5589 }
5590 std::fs::write(
5591 agent.join("settings.json"),
5592 r#"{"packages":["npm:user-demo"]}"#,
5593 )
5594 .unwrap();
5595 std::fs::write(
5596 cwd.join(".rpi/settings.json"),
5597 r#"{"packages":["npm:project-demo"]}"#,
5598 )
5599 .unwrap();
5600 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5601
5602 let untrusted = discover_from_settings_for_update(&cwd, false).unwrap().0;
5603 let trusted = discover_from_settings_for_update(&cwd, true).unwrap().0;
5604
5605 match previous {
5606 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5607 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5608 }
5609 assert_eq!(untrusted.packages.len(), 1);
5610 assert_eq!(untrusted.packages[0].name, "user-demo");
5611 assert_eq!(trusted.packages.len(), 2);
5612 assert_eq!(trusted.packages[0].name, "project-demo");
5613 assert_eq!(trusted.packages[1].name, "user-demo");
5614 }
5615
5616 #[test]
5617 fn update_discovery_recovers_missing_configured_target_and_cleans_stale_backup() {
5618 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5619 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5620 let tmp = tempfile::tempdir().unwrap();
5621 let agent = tmp.path().join("user-agent");
5622 let cwd = tmp.path().join("project");
5623 let target = cwd.join(".rpi/packages/demo");
5624 let backup = cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000001");
5625 std::fs::create_dir_all(&agent).unwrap();
5626 std::fs::create_dir_all(&backup).unwrap();
5627 std::fs::write(
5628 backup.join("package.json"),
5629 r#"{"name":"demo","version":"1.0.0"}"#,
5630 )
5631 .unwrap();
5632 write_npm_source_marker(&backup, "npm:demo@beta").unwrap();
5633 let spec = format!("file:{}", target.display());
5634 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
5635 std::fs::write(
5636 cwd.join(".rpi/settings.json"),
5637 serde_json::to_vec(&serde_json::json!({ "packages": [spec] })).unwrap(),
5638 )
5639 .unwrap();
5640 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5641
5642 let ordinary = discover_from_settings(&cwd);
5643 assert!(ordinary.packages.is_empty());
5644 assert!(backup.is_dir());
5645 assert!(!target.exists());
5646
5647 let recovered = discover_from_settings_for_update(&cwd, true).unwrap().0;
5648 assert_eq!(recovered.packages.len(), 1);
5649 assert_eq!(recovered.packages[0].root, target);
5650 assert!(!backup.exists());
5651
5652 let stale = cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000002");
5653 std::fs::create_dir_all(&stale).unwrap();
5654 let visible = discover_from_settings_for_update(&cwd, true).unwrap().0;
5655 assert_eq!(visible.packages.len(), 1);
5656 assert!(!stale.exists());
5657
5658 let next_backup =
5659 cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000003");
5660 std::fs::rename(&target, &next_backup).unwrap();
5661 let recovered_again = discover_from_settings_for_update(&cwd, true).unwrap().0;
5662 assert_eq!(recovered_again.packages.len(), 1);
5663 assert!(target.is_dir());
5664 assert!(!next_backup.exists());
5665
5666 match previous {
5667 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5668 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5669 }
5670 }
5671
5672 #[test]
5673 fn update_discovery_recovers_native_project_and_user_npm_targets() {
5674 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5675 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5676 let tmp = tempfile::tempdir().unwrap();
5677 let agent = tmp.path().join("user-agent");
5678 let cwd = tmp.path().join("project");
5679 let project_target = cwd.join(".pi/npm/node_modules/demo");
5680 let project_backup =
5681 cwd.join(".pi/npm/node_modules/.demo.rpi-backup-00000000000000000000000000000011");
5682 let user_target = agent.join("npm/node_modules/@scope/demo");
5683 let user_backup =
5684 agent.join("npm/node_modules/@scope/.demo.rpi-backup-00000000000000000000000000000012");
5685 for (backup, name) in [(&project_backup, "demo"), (&user_backup, "@scope/demo")] {
5686 std::fs::create_dir_all(backup).unwrap();
5687 std::fs::write(
5688 backup.join("package.json"),
5689 serde_json::to_vec(&serde_json::json!({
5690 "name": name,
5691 "version": "1.0.0"
5692 }))
5693 .unwrap(),
5694 )
5695 .unwrap();
5696 }
5697 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
5698 std::fs::write(
5699 cwd.join(".rpi/settings.json"),
5700 r#"{"packages":["npm:demo"]}"#,
5701 )
5702 .unwrap();
5703 std::fs::create_dir_all(&agent).unwrap();
5704 std::fs::write(
5705 agent.join("settings.json"),
5706 r#"{"packages":["npm:@scope/demo"]}"#,
5707 )
5708 .unwrap();
5709 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5710
5711 let ordinary = discover_from_settings(&cwd);
5712 assert!(ordinary.packages.is_empty());
5713 assert!(project_backup.is_dir());
5714 assert!(user_backup.is_dir());
5715
5716 let recovered = discover_from_settings_for_update(&cwd, true).unwrap().0;
5717 assert_eq!(recovered.packages.len(), 2);
5718 assert!(recovered
5719 .packages
5720 .iter()
5721 .any(|package| package.root == project_target));
5722 assert!(recovered
5723 .packages
5724 .iter()
5725 .any(|package| package.root == user_target));
5726 assert!(!project_backup.exists());
5727 assert!(!user_backup.exists());
5728
5729 match previous {
5730 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5731 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5732 }
5733 }
5734
5735 #[test]
5736 fn update_returns_failure_when_recovery_is_ambiguous() {
5737 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5738 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5739 let tmp = tempfile::tempdir().unwrap();
5740 let agent = tmp.path().join("user-agent");
5741 let cwd = tmp.path().join("project");
5742 let target = cwd.join(".rpi/packages/demo");
5743 for suffix in [1_u8, 2] {
5744 let backup = cwd.join(format!(".rpi/packages/.demo.rpi-backup-{suffix:032x}"));
5745 std::fs::create_dir_all(&backup).unwrap();
5746 std::fs::write(backup.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5747 }
5748 let spec = format!("file:{}", target.display());
5749 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
5750 std::fs::write(
5751 cwd.join(".rpi/settings.json"),
5752 serde_json::to_vec(&serde_json::json!({ "packages": [spec] })).unwrap(),
5753 )
5754 .unwrap();
5755 std::fs::create_dir_all(&agent).unwrap();
5756 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5757
5758 assert_eq!(update_packages(&cwd, true), 1);
5759 assert!(!target.exists());
5760
5761 match previous {
5762 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5763 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5764 }
5765 }
5766
5767 #[test]
5768 fn update_with_malformed_project_settings_performs_no_recovery() {
5769 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5770 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5771 let tmp = tempfile::tempdir().unwrap();
5772 let agent = tmp.path().join("agent");
5773 let cwd = tmp.path().join("project");
5774 let target = cwd.join(".rpi/packages/demo");
5775 let backup = cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000031");
5776 std::fs::create_dir_all(&agent).unwrap();
5777 std::fs::create_dir_all(&backup).unwrap();
5778 std::fs::write(backup.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5779 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
5780 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5781
5782 assert_eq!(update_packages(&cwd, true), 1);
5783
5784 match previous {
5785 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5786 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5787 }
5788 assert!(backup.is_dir());
5789 assert!(!target.exists());
5790 }
5791
5792 #[test]
5793 fn update_with_corrupt_native_registry_performs_no_ts_recovery() {
5794 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5795 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5796 let tmp = tempfile::tempdir().unwrap();
5797 let agent = tmp.path().join("agent");
5798 let cwd = tmp.path().join("project");
5799 let target = cwd.join(".rpi/packages/demo");
5800 let backup = cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000034");
5801 let metadata = agent.join("native-packages.json");
5802 let original = b"[{broken native metadata";
5803 std::fs::create_dir_all(&agent).unwrap();
5804 std::fs::write(&metadata, original).unwrap();
5805 std::fs::create_dir_all(&backup).unwrap();
5806 std::fs::write(backup.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5807 let spec = format!("file:{}", target.display());
5808 std::fs::write(
5809 cwd.join(".rpi/settings.json"),
5810 serde_json::to_vec(&serde_json::json!({ "packages": [spec] })).unwrap(),
5811 )
5812 .unwrap();
5813 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5814
5815 assert_eq!(update_packages(&cwd, true), 1);
5816
5817 match previous {
5818 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5819 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5820 }
5821 assert_eq!(std::fs::read(metadata).unwrap(), original);
5822 assert!(backup.is_dir());
5823 assert!(!target.exists());
5824 }
5825
5826 #[test]
5827 fn update_with_malformed_global_settings_performs_no_project_recovery() {
5828 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5829 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5830 let tmp = tempfile::tempdir().unwrap();
5831 let agent = tmp.path().join("agent");
5832 let cwd = tmp.path().join("project");
5833 let target = cwd.join(".rpi/packages/demo");
5834 let backup = cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000032");
5835 std::fs::create_dir_all(&agent).unwrap();
5836 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
5837 std::fs::create_dir_all(&backup).unwrap();
5838 std::fs::write(backup.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5839 std::fs::write(
5840 cwd.join(".rpi/settings.json"),
5841 serde_json::to_vec(&serde_json::json!({
5842 "packages": [format!("file:{}", target.display())]
5843 }))
5844 .unwrap(),
5845 )
5846 .unwrap();
5847 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5848
5849 assert_eq!(update_packages(&cwd, true), 1);
5850
5851 match previous {
5852 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5853 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5854 }
5855 assert!(backup.is_dir());
5856 assert!(!target.exists());
5857 }
5858
5859 #[test]
5860 fn update_with_invalid_npm_command_performs_no_recovery() {
5861 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5862 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5863 let tmp = tempfile::tempdir().unwrap();
5864 let agent = tmp.path().join("agent");
5865 let cwd = tmp.path().join("project");
5866 let target = cwd.join(".rpi/packages/demo");
5867 let backup = cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000033");
5868 std::fs::create_dir_all(&agent).unwrap();
5869 std::fs::create_dir_all(&backup).unwrap();
5870 std::fs::write(backup.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5871 std::fs::write(
5872 cwd.join(".rpi/settings.json"),
5873 serde_json::to_vec(&serde_json::json!({
5874 "npmCommand": [""],
5875 "packages": [format!("file:{}", target.display())]
5876 }))
5877 .unwrap(),
5878 )
5879 .unwrap();
5880 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5881
5882 assert_eq!(update_packages(&cwd, true), 1);
5883
5884 match previous {
5885 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5886 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5887 }
5888 assert!(backup.is_dir());
5889 assert!(!target.exists());
5890 }
5891}