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 if args
1291 .iter()
1292 .any(|arg| matches!(arg.as_str(), "--help" | "-h"))
1293 {
1294 print_update_help();
1295 return 0;
1296 }
1297 let project_trusted = match package_command_project_trusted(&cwd, &args[1..]) {
1298 Ok(trusted) => trusted,
1299 Err(error) => {
1300 eprintln!("error: {error}");
1301 return 2;
1302 }
1303 };
1304 update_packages(&cwd, project_trusted)
1305 }
1306 "help" | "--help" | "-h" => {
1307 print_help();
1308 0
1309 }
1310 other => {
1311 eprintln!("error: unknown package command `{other}`");
1312 print_help();
1313 2
1314 }
1315 }
1316}
1317
1318fn print_help() {
1319 println!(
1320 "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`."
1321 );
1322}
1323
1324fn print_update_help() {
1325 println!(
1326 "Usage: rpi update [--approve|--no-approve] [--offline]\n\nUpdate installed Rust-native and npm/Git Pi packages.\n\nThe legacy `rpi package update` spelling remains supported."
1327 );
1328}
1329
1330fn package_command_project_trusted(cwd: &Path, args: &[String]) -> Result<bool, String> {
1331 let mut override_value = None;
1332 for arg in args {
1333 let value = match arg.as_str() {
1334 "--approve" | "-a" => Some(true),
1335 "--no-approve" | "-na" => Some(false),
1336 "--json" => None,
1337 value => return Err(format!("unknown package option `{value}`")),
1338 };
1339 if let Some(value) = value {
1340 if override_value.replace(value).is_some() {
1341 return Err("--approve and --no-approve cannot be combined or repeated".to_string());
1342 }
1343 }
1344 }
1345 if let Some(value) = override_value {
1346 return Ok(value);
1347 }
1348 Ok(crate::config::project_trust_decision(cwd)
1349 .map_err(|error| error.to_string())?
1350 .unwrap_or(false))
1351}
1352
1353fn update_packages(cwd: &Path, project_trusted: bool) -> i32 {
1354 if crate::args::offline_env_enabled() {
1355 println!("package update skipped: offline mode is enabled");
1356 return 0;
1357 }
1358 let native = match crate::install::installed_native_packages_strict() {
1363 Ok(packages) => packages,
1364 Err(error) => {
1365 eprintln!(
1366 "error: refusing package update while native package metadata is invalid: {error}"
1367 );
1368 return 1;
1369 }
1370 };
1371 let (resources, preflight_npm_command) =
1376 match discover_from_settings_for_update(cwd, project_trusted) {
1377 Ok(result) => result,
1378 Err(error) => {
1379 eprintln!("error: refusing package update with unreadable settings: {error}");
1380 return 1;
1381 }
1382 };
1383 let blocked = resources
1384 .diagnostics
1385 .iter()
1386 .filter(|diagnostic| diagnostic.blocks_update)
1387 .count();
1388 for diagnostic in &resources.diagnostics {
1389 eprintln!(
1390 "warning: could not load package {}: {}",
1391 diagnostic.spec, diagnostic.message
1392 );
1393 }
1394 if blocked > 0 {
1395 eprintln!("error: refusing a partial package update after discovery failures");
1396 return 1;
1397 }
1398 let needs_package_command = resources.packages.iter().any(|package| {
1399 package.updateable_npm_source().is_some() || package.updateable_git_source()
1400 });
1401 let npm_command = if needs_package_command {
1402 match preflight_npm_command {
1403 Some(command) => Some(command),
1404 None => {
1405 eprintln!("error: package update command was not validated before discovery");
1406 return 1;
1407 }
1408 }
1409 } else {
1410 None
1411 };
1412 let mut failed = 0;
1413 if resources.packages.is_empty() && native.is_empty() {
1414 println!("no Pi packages enabled");
1415 return 0;
1416 }
1417 let mut updated = 0;
1418 let mut skipped = 0;
1419 for package in native {
1420 if package.source.is_some() {
1421 println!(
1422 "skipped local Rust package {} (no registry source)",
1423 package.name
1424 );
1425 skipped += 1;
1426 continue;
1427 }
1428 let args = vec![package.name.clone(), "--force".to_string()];
1429 if crate::install::run(&args) == 0 {
1430 updated += 1;
1431 } else {
1432 eprintln!("warning: could not update Rust package {}", package.name);
1433 failed += 1;
1434 }
1435 }
1436
1437 let mut standalone_npm = Vec::new();
1438 let mut npm_store_roots: BTreeMap<PathBuf, Vec<(String, String)>> = BTreeMap::new();
1439 let mut git_updates = Vec::new();
1440 for package in resources.packages {
1441 if let Some((name, source_spec)) = package.updateable_npm_source() {
1442 match package.npm_store_root_for_update(cwd, project_trusted) {
1443 Ok(Some(install_root)) => {
1444 npm_store_roots
1445 .entry(install_root)
1446 .or_default()
1447 .push((name.to_string(), source_spec.to_string()));
1448 }
1449 Ok(None) => {
1450 standalone_npm.push((
1451 package.root.clone(),
1452 package.name.clone(),
1453 name.to_string(),
1454 source_spec.to_string(),
1455 ));
1456 }
1457 Err(error) => {
1458 eprintln!(
1459 "warning: could not plan update for {}: {error}",
1460 package.name
1461 );
1462 failed += 1;
1463 }
1464 }
1465 } else if package.updateable_git_source() {
1466 git_updates.push(package);
1467 } else {
1468 println!(
1469 "skipped package {} (not an unpinned npm source)",
1470 package.name
1471 );
1472 skipped += 1;
1473 }
1474 }
1475
1476 let npm_update_count = standalone_npm.len()
1477 + npm_store_roots
1478 .values()
1479 .map(std::vec::Vec::len)
1480 .sum::<usize>();
1481 let git_update_count = git_updates.len();
1482 if npm_update_count > 0 || git_update_count > 0 {
1483 match npm_command.as_ref() {
1484 Some(npm_command) => {
1485 for (root, display_name, name, source_spec) in standalone_npm {
1486 match crate::install_pi::update_npm_package(
1487 &root,
1488 &name,
1489 &source_spec,
1490 &npm_command,
1491 ) {
1492 Ok(_) => {
1493 println!("updated npm package {display_name}");
1494 updated += 1;
1495 }
1496 Err(error) => {
1497 eprintln!("warning: could not update {display_name}: {error}");
1498 failed += 1;
1499 }
1500 }
1501 }
1502 for (root, packages) in npm_store_roots {
1503 match crate::install_pi::update_npm_store_root(
1504 &root,
1505 &packages,
1506 &npm_command,
1507 cwd,
1508 project_trusted,
1509 ) {
1510 Ok(()) => {
1511 for (name, _) in &packages {
1512 println!("updated npm package {name}");
1513 }
1514 updated += packages.len();
1515 }
1516 Err(error) => {
1517 let names = packages
1518 .iter()
1519 .map(|(name, _)| name.as_str())
1520 .collect::<Vec<_>>()
1521 .join(", ");
1522 eprintln!(
1523 "warning: could not update npm packages {names} in {}: {error}",
1524 root.display()
1525 );
1526 failed += packages.len();
1527 }
1528 }
1529 }
1530 for package in git_updates {
1531 if package.missing_install {
1532 match crate::install_pi::install_missing_git_package(
1533 cwd,
1534 package.scope == ResolveScope::User,
1535 &package.spec,
1536 &npm_command,
1537 ) {
1538 Ok(_) => {
1539 println!("updated git package {}", package.name);
1540 updated += 1;
1541 }
1542 Err(error) => {
1543 eprintln!("warning: could not update {}: {error}", package.name);
1544 failed += 1;
1545 }
1546 }
1547 continue;
1548 }
1549 let Some(store_root) = package.safe_git_store_root(cwd) else {
1550 eprintln!(
1551 "warning: refusing to update git package {} outside a managed git store",
1552 package.name
1553 );
1554 failed += 1;
1555 continue;
1556 };
1557 match crate::install_pi::update_git_package(
1558 &package.root,
1559 &store_root,
1560 &package.spec,
1561 &npm_command,
1562 ) {
1563 Ok(()) => {
1564 println!("updated git package {}", package.name);
1565 updated += 1;
1566 }
1567 Err(error) => {
1568 eprintln!("warning: could not update {}: {error}", package.name);
1569 failed += 1;
1570 }
1571 }
1572 }
1573 }
1574 None => unreachable!("package command was preflighted for update candidates"),
1575 }
1576 }
1577 println!("package update complete: {updated} updated, {skipped} skipped");
1578 i32::from(failed > 0)
1579}
1580
1581fn is_npm_store_package_path(path: &Path, cwd: &Path, scope: ResolveScope) -> bool {
1582 npm_install_root_for_path(path, cwd, scope).is_some()
1583}
1584
1585fn npm_install_root_for_path(path: &Path, cwd: &Path, scope: ResolveScope) -> Option<PathBuf> {
1586 if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
1587 if let Some(root) = package_manager_root_for_path(path, cwd, Path::new(".pi/npm")) {
1588 return Some(root);
1589 }
1590 }
1591 if matches!(scope, ResolveScope::Any | ResolveScope::User) {
1592 if let Ok(agent) = config::agent_dir() {
1593 if let Some(root) = package_manager_root_for_path(path, &agent, Path::new("npm")) {
1594 return Some(root);
1595 }
1596 }
1597 if let Some(home) = dirs::home_dir() {
1598 if let Some(root) =
1599 package_manager_root_for_path(path, &home, Path::new(".pi/agent/npm"))
1600 {
1601 return Some(root);
1602 }
1603 }
1604 }
1605 None
1606}
1607
1608fn git_store_roots(cwd: &Path, scope: ResolveScope) -> Vec<PathBuf> {
1609 let mut roots = Vec::new();
1610 if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
1611 roots.push(cwd.join(".rpi/git"));
1612 roots.push(cwd.join(".pi/git"));
1613 }
1614 if matches!(scope, ResolveScope::Any | ResolveScope::User) {
1615 if let Ok(agent) = config::agent_dir() {
1616 roots.push(agent.join("git"));
1617 }
1618 if let Some(home) = dirs::home_dir() {
1619 roots.push(home.join(".pi/agent/git"));
1620 }
1621 }
1622 roots
1623}
1624
1625fn native_git_target_for_spec(cwd: &Path, scope: ResolveScope, git: &GitSpec) -> Option<PathBuf> {
1630 let relative = Path::new(&git.host).join(&git.path);
1631 for lexical_root in git_store_roots(cwd, scope) {
1632 let Ok(canonical_root_raw) = std::fs::canonicalize(&lexical_root) else {
1633 continue;
1634 };
1635 let canonical_root = normalize_resource_path(canonical_root_raw);
1636 if canonical_root != lexical_root {
1637 continue;
1638 }
1639 let target = lexical_root.join(&relative);
1640 let Ok(canonical_target_raw) = std::fs::canonicalize(&target) else {
1641 continue;
1642 };
1643 let canonical_target = normalize_resource_path(canonical_target_raw);
1644 if canonical_target == target
1645 && canonical_target.starts_with(&canonical_root)
1646 && canonical_target
1647 .strip_prefix(&canonical_root)
1648 .ok()
1649 .is_some_and(|value| value.components().count() == relative.components().count())
1650 && is_real_git_metadata(&canonical_target.join(".git"))
1651 {
1652 return Some(canonical_target);
1653 }
1654 }
1655 None
1656}
1657
1658fn is_native_git_package_path(path: &Path, cwd: &Path, scope: ResolveScope) -> bool {
1659 let Ok(canonical_path) = std::fs::canonicalize(path) else {
1660 return false;
1661 };
1662 let canonical_path = normalize_resource_path(canonical_path);
1663 if canonical_path != path || !is_real_git_metadata(&canonical_path.join(".git")) {
1664 return false;
1665 }
1666 git_store_roots(cwd, scope).into_iter().any(|root| {
1667 let Ok(canonical_root_raw) = std::fs::canonicalize(&root) else {
1668 return false;
1669 };
1670 let canonical_root = normalize_resource_path(canonical_root_raw);
1671 canonical_root == root
1672 && canonical_path
1673 .strip_prefix(&canonical_root)
1674 .ok()
1675 .is_some_and(|relative| relative.components().count() >= 2)
1676 })
1677}
1678
1679fn native_git_store_root_for_path(path: &Path, cwd: &Path, scope: ResolveScope) -> Option<PathBuf> {
1680 let Ok(canonical_path_raw) = std::fs::canonicalize(path) else {
1681 return None;
1682 };
1683 let canonical_path = normalize_resource_path(canonical_path_raw);
1684 if canonical_path != path || !is_real_git_metadata(&canonical_path.join(".git")) {
1685 return None;
1686 }
1687 git_store_roots(cwd, scope).into_iter().find_map(|root| {
1688 let canonical_root = normalize_resource_path(std::fs::canonicalize(&root).ok()?);
1689 if canonical_root != root {
1690 return None;
1691 }
1692 let relative = canonical_path.strip_prefix(&canonical_root).ok()?;
1693 (relative.components().count() >= 2).then_some(canonical_root)
1694 })
1695}
1696
1697fn is_direct_managed_package_root(path: &Path, cwd: &Path, scope: ResolveScope) -> Option<PathBuf> {
1698 let canonical_path = normalize_resource_path(std::fs::canonicalize(path).ok()?);
1699 if canonical_path != path || !is_real_git_metadata(&canonical_path.join(".git")) {
1700 return None;
1701 }
1702 let mut stores = Vec::new();
1703 if let Ok(agent) = config::agent_dir() {
1704 stores.push(agent.join("packages"));
1705 }
1706 if let Some(home) = dirs::home_dir() {
1707 stores.push(home.join(".pi/agent/packages"));
1708 }
1709 if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
1710 stores.push(cwd.join(".rpi/packages"));
1711 stores.push(cwd.join(".pi/packages"));
1712 }
1713 let parent = canonical_path.parent()?;
1714 stores
1715 .iter()
1716 .find(|store| {
1717 std::fs::canonicalize(store)
1718 .ok()
1719 .map(normalize_resource_path)
1720 .is_some_and(|canonical| canonical == **store)
1721 && parent == store.as_path()
1722 })
1723 .cloned()
1724}
1725
1726fn is_real_git_metadata(path: &Path) -> bool {
1727 std::fs::symlink_metadata(path)
1728 .map(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
1733 .unwrap_or(false)
1734}
1735
1736fn package_manager_root_for_path(
1741 path: &Path,
1742 base: &Path,
1743 relative_install_root: &Path,
1744) -> Option<PathBuf> {
1745 let lexical_install_root = base.join(relative_install_root);
1746 let lexical_node_modules = lexical_install_root.join("node_modules");
1747 let relative = path.strip_prefix(&lexical_node_modules).ok()?;
1748 let parts = relative
1749 .components()
1750 .map(|component| match component {
1751 std::path::Component::Normal(part) => part.to_str(),
1752 _ => None,
1753 })
1754 .collect::<Option<Vec<_>>>()?;
1755 let valid_shape = match parts.as_slice() {
1756 [name] => !name.starts_with('@') && !name.is_empty(),
1757 [scope, name] => scope.starts_with('@') && scope.len() > 1 && !name.is_empty(),
1758 _ => false,
1759 };
1760 if !valid_shape {
1761 return None;
1762 }
1763
1764 let base = std::fs::canonicalize(base).ok()?;
1765 let install_root = base.join(relative_install_root);
1766 if normalize_resource_path(std::fs::canonicalize(&install_root).ok()?)
1767 != normalize_resource_path(install_root.clone())
1768 {
1769 return None;
1770 }
1771 let node_modules = install_root.join("node_modules");
1772 if normalize_resource_path(std::fs::canonicalize(&node_modules).ok()?)
1773 != normalize_resource_path(node_modules.clone())
1774 {
1775 return None;
1776 }
1777 let canonical_package = normalize_resource_path(std::fs::canonicalize(path).ok()?);
1778 let install_root_normalized = normalize_resource_path(install_root.clone());
1779 canonical_package
1780 .starts_with(&install_root_normalized)
1781 .then_some(install_root)
1782}
1783
1784fn is_package_below_store(path: &Path, base: &Path, relative_store: &Path) -> bool {
1785 let Ok(path) = std::fs::canonicalize(path) else {
1786 return false;
1787 };
1788 let Ok(base) = std::fs::canonicalize(base) else {
1789 return false;
1790 };
1791 let Ok(store) = std::fs::canonicalize(base.join(relative_store)) else {
1792 return false;
1793 };
1794 if store != base.join(relative_store) || !store.starts_with(&base) {
1795 return false;
1796 }
1797 path.strip_prefix(store)
1798 .ok()
1799 .is_some_and(|relative| relative.components().next().is_some())
1800}
1801
1802fn is_managed_package_path(path: &Path, cwd: &Path, scope: ResolveScope) -> bool {
1803 if let Ok(agent) = config::agent_dir() {
1804 if is_package_below_store(path, &agent, Path::new("packages")) {
1805 return true;
1806 }
1807 }
1808 if let Some(home) = dirs::home_dir() {
1809 if is_package_below_store(path, &home, Path::new(".pi/agent/packages")) {
1810 return true;
1811 }
1812 }
1813 (scope == ResolveScope::Any
1814 && (is_package_below_store(path, cwd, Path::new(".rpi/packages"))
1815 || is_package_below_store(path, cwd, Path::new(".pi/packages"))))
1816 || is_project_managed_package_path(path)
1817}
1818
1819fn is_project_managed_package_path(path: &Path) -> bool {
1824 let Ok(path) = std::fs::canonicalize(path) else {
1825 return false;
1826 };
1827 let Some(store) = path.parent() else {
1828 return false;
1829 };
1830 let Some(project_config) = store.parent() else {
1831 return false;
1832 };
1833 store.file_name().is_some_and(|name| name == "packages")
1834 && project_config
1835 .file_name()
1836 .is_some_and(|name| name == ".rpi" || name == ".pi")
1837}
1838
1839impl PackageRoot {
1840 fn npm_store_root_for_update(
1841 &self,
1842 cwd: &Path,
1843 project_trusted: bool,
1844 ) -> Result<Option<PathBuf>, String> {
1845 if let Some(root) = &self.npm_install_root {
1846 return Ok(Some(root.clone()));
1847 }
1848 if self.legacy_npm_root.is_none() {
1849 return Ok(None);
1850 }
1851 if !matches!(self.source, PackageSource::Npm { .. }) {
1852 return Err(
1853 "refusing legacy npm migration without verified npm provenance".to_string(),
1854 );
1855 }
1856
1857 let root = match self.scope {
1858 ResolveScope::Project if project_trusted => cwd.join(".pi/npm"),
1859 ResolveScope::Project => {
1860 return Err(
1861 "refusing to migrate a legacy npm package for an untrusted project".to_string(),
1862 )
1863 }
1864 ResolveScope::User | ResolveScope::Any => config::agent_dir()
1865 .map_err(|error| error.to_string())?
1866 .join("npm"),
1867 };
1868 if !root.is_absolute() || root.file_name().and_then(|name| name.to_str()) != Some("npm") {
1869 return Err(format!(
1870 "refusing legacy npm migration outside a managed npm root: {}",
1871 root.display()
1872 ));
1873 }
1874 Ok(Some(root))
1875 }
1876
1877 pub(crate) fn updateable_npm_source(&self) -> Option<(&str, &str)> {
1878 match &self.source {
1879 PackageSource::Npm {
1880 name, spec, pinned, ..
1881 } if self.missing_install || !*pinned => Some((name, spec)),
1882 _ => None,
1883 }
1884 }
1885
1886 fn updateable_git_source(&self) -> bool {
1887 matches!(self.source, PackageSource::Git)
1891 }
1892
1893 fn safe_git_store_root(&self, cwd: &Path) -> Option<PathBuf> {
1894 self.git_store_root.clone().or_else(|| {
1895 is_direct_managed_package_root(&self.root, cwd, self.scope)
1898 })
1899 }
1900
1901 #[cfg(test)]
1902 pub(crate) fn updateable_npm_name(&self) -> Option<&str> {
1903 self.updateable_npm_source().map(|(name, _)| name)
1904 }
1905
1906 fn skill_dirs_for_display(&self) -> Vec<PathBuf> {
1907 self.skills.clone()
1908 }
1909
1910 fn prompt_dirs_for_display(&self) -> Vec<PathBuf> {
1911 self.prompts.clone()
1912 }
1913
1914 fn theme_files_for_display(&self) -> Vec<PathBuf> {
1915 if self.themes.len() == 1 && self.themes[0].is_dir() {
1916 let mut files: Vec<PathBuf> = std::fs::read_dir(&self.themes[0])
1917 .ok()
1918 .into_iter()
1919 .flatten()
1920 .filter_map(Result::ok)
1921 .map(|entry| entry.path())
1922 .filter(|file| {
1923 file.is_file() && file.extension().and_then(|ext| ext.to_str()) == Some("json")
1924 })
1925 .collect();
1926 files.sort();
1927 files
1928 } else {
1929 self.themes.clone()
1930 }
1931 }
1932}
1933
1934#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1935enum ResolveScope {
1936 Any,
1937 Project,
1938 User,
1939}
1940
1941#[derive(Debug)]
1942struct ResolvedPackagePath {
1943 root: PathBuf,
1944 legacy_npm_root: Option<PathBuf>,
1947}
1948
1949fn resolve_spec(cwd: &Path, spec: &str, scope: ResolveScope) -> Option<PathBuf> {
1950 resolve_spec_with_legacy_lookup(cwd, spec, scope, |_| None).map(|resolved| resolved.root)
1951}
1952
1953fn resolve_spec_with_command(
1954 cwd: &Path,
1955 spec: &str,
1956 scope: ResolveScope,
1957 global_npm_command: Option<&crate::npm::NpmCommand>,
1958 legacy_npm_names: &[String],
1959 legacy_npm_paths: &mut Option<HashMap<String, PathBuf>>,
1960) -> Option<ResolvedPackagePath> {
1961 resolve_spec_with_legacy_lookup(cwd, spec, scope, |package_name| {
1962 let command = global_npm_command?;
1963 let paths = legacy_npm_paths.get_or_insert_with(|| {
1964 command
1965 .global_package_paths(legacy_npm_names)
1966 .unwrap_or_default()
1967 });
1968 paths.get(package_name).cloned()
1969 })
1970}
1971
1972fn resolve_spec_with_legacy_lookup(
1973 cwd: &Path,
1974 spec: &str,
1975 scope: ResolveScope,
1976 legacy_lookup: impl FnOnce(&str) -> Option<PathBuf>,
1977) -> Option<ResolvedPackagePath> {
1978 let file_spec = spec.strip_prefix("file:");
1979 let raw = file_spec.unwrap_or(spec);
1980 let npm_spec = raw.strip_prefix("npm:");
1984 let npm_name = npm_spec.unwrap_or(raw);
1985 let package_name = match npm_spec {
1986 Some(spec) => parse_npm_package_spec(spec)?.install_name,
1987 None => package_name_without_version(npm_name).to_string(),
1988 };
1989 let package_key = package_name
1990 .strip_prefix('@')
1991 .unwrap_or(&package_name)
1992 .replace('/', "__");
1993 let direct = PathBuf::from(npm_name);
1994 let mut candidates = Vec::new();
1995 if direct.is_absolute() {
1996 candidates.push(direct);
1997 } else {
1998 let explicit_relative_path =
1999 file_spec.is_some() || npm_name.starts_with('.') || npm_name.starts_with("./");
2000 if explicit_relative_path {
2001 if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
2002 candidates.push(cwd.join(".rpi").join(&direct));
2006 candidates.push(cwd.join(".pi").join(&direct));
2007 candidates.push(cwd.join(&direct));
2010 } else {
2011 if let Ok(agent) = config::agent_dir() {
2012 candidates.push(agent.join(&direct));
2013 }
2014 if let Some(home) = dirs::home_dir() {
2015 candidates.push(home.join(".pi/agent").join(&direct));
2016 }
2017 }
2018 }
2019 if let Some(git) = parse_git_source(spec) {
2020 if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
2021 for relative_root in [Path::new(".rpi/git"), Path::new(".pi/git")] {
2022 candidates.push(cwd.join(relative_root).join(&git.host).join(&git.path));
2023 }
2024 }
2025 if matches!(scope, ResolveScope::Any | ResolveScope::User) {
2026 if let Ok(agent) = config::agent_dir() {
2027 candidates.push(agent.join("git").join(&git.host).join(&git.path));
2028 }
2029 if let Some(home) = dirs::home_dir() {
2030 candidates.push(home.join(".pi/agent/git").join(&git.host).join(&git.path));
2031 }
2032 }
2033 }
2034 if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
2038 candidates.push(cwd.join(".rpi/packages").join(&package_name));
2039 if package_key != package_name {
2040 candidates.push(cwd.join(".rpi/packages").join(&package_key));
2041 }
2042 candidates.push(cwd.join(".pi/packages").join(&package_name));
2043 if package_key != package_name {
2044 candidates.push(cwd.join(".pi/packages").join(&package_key));
2045 }
2046 if npm_spec.is_some() {
2047 candidates.push(cwd.join(".pi/npm/node_modules").join(&package_name));
2048 } else if scope == ResolveScope::Any {
2049 for ancestor in cwd.ancestors() {
2050 candidates.push(ancestor.join("node_modules").join(&package_name));
2051 }
2052 }
2053 }
2054 if matches!(scope, ResolveScope::Any | ResolveScope::User) {
2055 if let Ok(agent) = config::agent_dir() {
2056 candidates.push(agent.join("packages").join(&package_name));
2057 if package_key != package_name {
2058 candidates.push(agent.join("packages").join(&package_key));
2059 }
2060 candidates.push(agent.join("npm/node_modules").join(&package_name));
2064 if package_key != package_name {
2065 candidates.push(agent.join("npm/node_modules").join(&package_key));
2066 }
2067 }
2068 if let Some(home) = dirs::home_dir() {
2069 candidates.push(home.join(".pi/agent/packages").join(&package_name));
2072 if package_key != package_name {
2073 candidates.push(home.join(".pi/agent/packages").join(&package_key));
2074 }
2075 candidates.push(home.join(".pi/agent/npm/node_modules").join(&package_name));
2076 if package_key != package_name {
2077 candidates.push(home.join(".pi/agent/npm/node_modules").join(&package_key));
2078 }
2079 }
2080 }
2081 if scope == ResolveScope::Any && npm_spec.is_none() && !explicit_relative_path {
2082 candidates.push(cwd.join(&package_name));
2083 }
2084 }
2085 for candidate in candidates {
2086 if candidate.is_file()
2087 && candidate.file_name().and_then(|s| s.to_str()) == Some("package.json")
2088 {
2089 return candidate.parent().map(|root| ResolvedPackagePath {
2090 root: root.to_path_buf(),
2091 legacy_npm_root: None,
2092 });
2093 }
2094 if candidate.is_dir() {
2095 return Some(ResolvedPackagePath {
2096 root: candidate,
2097 legacy_npm_root: None,
2098 });
2099 }
2100 }
2101
2102 if npm_spec.is_some() && matches!(scope, ResolveScope::Any | ResolveScope::User) {
2107 let reported = legacy_lookup(&package_name)?;
2108 let reported = std::fs::canonicalize(reported).ok()?;
2109 let install_root = global_node_modules_root(&reported)?;
2110 let root =
2114 crate::npm::NpmCommand::validate_global_package_path(&install_root, &package_name)?;
2115 if root != reported {
2116 return None;
2117 }
2118 return Some(ResolvedPackagePath {
2119 root,
2120 legacy_npm_root: Some(install_root),
2121 });
2122 }
2123 None
2124}
2125
2126fn global_node_modules_root(package: &Path) -> Option<PathBuf> {
2127 let mut current = package.parent()?;
2128 loop {
2129 if current.file_name().and_then(|name| name.to_str()) == Some("node_modules") {
2130 return std::fs::canonicalize(current).ok().filter(|root| {
2131 root.is_absolute()
2132 && root.file_name().and_then(|name| name.to_str()) == Some("node_modules")
2133 });
2134 }
2135 current = current.parent()?;
2136 }
2137}
2138
2139fn package_name_without_version(name: &str) -> &str {
2141 if let Some(rest) = name.strip_prefix('@') {
2142 rest.find('@')
2143 .map(|index| &name[..index + 1])
2144 .unwrap_or(name)
2145 } else {
2146 name.split('@').next().unwrap_or(name)
2147 }
2148}
2149
2150fn package_identity(package: &PackageRoot) -> String {
2156 match &package.source {
2157 PackageSource::Npm { name, .. } => format!("npm:{}", name.to_ascii_lowercase()),
2158 PackageSource::Git => parse_git_source(&package.spec)
2159 .map(|git| format!("git:{}/{}", git.host, git.path))
2160 .unwrap_or_else(|| format!("git:path:{}", normalize_key(&package.root))),
2161 PackageSource::Local => format!("local:{}", normalize_key(&package.root)),
2162 PackageSource::Unknown => format!("unknown:{}", normalize_key(&package.root)),
2163 }
2164}
2165
2166#[derive(Debug, Clone, PartialEq, Eq)]
2167pub(crate) struct GitSpec {
2168 pub(crate) host: String,
2169 pub(crate) path: String,
2170 pub(crate) revision: Option<String>,
2171 pub(crate) transport: GitTransport,
2172 pub(crate) port: Option<u16>,
2173 pub(crate) user_info: Option<String>,
2174}
2175
2176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2177pub(crate) enum GitTransport {
2178 Http,
2179 Https,
2180 Ssh,
2181 Git,
2182}
2183
2184impl GitTransport {
2185 pub(crate) fn default_port(self) -> u16 {
2186 match self {
2187 Self::Http => 80,
2188 Self::Https => 443,
2189 Self::Ssh => 22,
2190 Self::Git => 9418,
2191 }
2192 }
2193}
2194
2195pub(crate) fn parse_git_source(spec: &str) -> Option<GitSpec> {
2196 let trimmed = spec.trim();
2197 if trimmed.is_empty() {
2198 return None;
2199 }
2200
2201 let raw = match trimmed.strip_prefix("git:") {
2204 Some(rest) if !rest.starts_with("//") => rest.trim(),
2205 _ => trimmed,
2206 };
2207 if raw.is_empty() {
2208 return None;
2209 }
2210
2211 let (repo, revision) = split_git_ref(raw);
2215 let (mut host, mut path, transport, port, user_info) =
2216 if let Some(scheme_end) = repo.find("://") {
2217 let scheme = repo[..scheme_end].to_ascii_lowercase();
2218 let transport = match scheme.as_str() {
2219 "http" => GitTransport::Http,
2220 "https" => GitTransport::Https,
2221 "ssh" => GitTransport::Ssh,
2222 "git" => GitTransport::Git,
2223 _ => return None,
2224 };
2225 let authority_and_path = &repo[scheme_end + 3..];
2226 let (authority, path) = authority_and_path.split_once('/')?;
2227 let (host, port, user_info) = parse_git_authority(authority)?;
2228 (host, path.to_string(), transport, port, user_info)
2229 } else if let Some(rest) = repo.strip_prefix("git@") {
2230 let (host, path) = rest.split_once(':')?;
2231 (
2232 normalize_git_host(host)?,
2233 path.to_string(),
2234 GitTransport::Ssh,
2235 None,
2236 Some("git".to_string()),
2237 )
2238 } else {
2239 let (host, path) = repo.split_once('/')?;
2241 (
2242 normalize_git_host(host)?,
2243 path.to_string(),
2244 GitTransport::Https,
2245 None,
2246 None,
2247 )
2248 };
2249
2250 host.make_ascii_lowercase();
2251 while path.starts_with('/') {
2252 path.remove(0);
2253 }
2254 if path.ends_with(".git") {
2255 path.truncate(path.len() - 4);
2256 }
2257 let path = path.trim_matches('/').to_string();
2258
2259 if !safe_git_install_part(&host, false)
2260 || !safe_git_install_part(&path, true)
2261 || path.split('/').count() < 2
2262 {
2263 return None;
2264 }
2265 if revision
2266 .as_deref()
2267 .is_some_and(|value| !safe_git_revision(value))
2268 {
2269 return None;
2270 }
2271
2272 Some(GitSpec {
2273 host,
2274 path,
2275 revision,
2276 transport,
2277 port,
2278 user_info,
2279 })
2280}
2281
2282fn split_git_ref(raw: &str) -> (String, Option<String>) {
2285 let path_start = if raw.starts_with("git@") {
2286 raw.find(':').map(|index| index + 1)
2287 } else if let Some(scheme_end) = raw.find("://") {
2288 let authority_start = scheme_end + 3;
2289 raw[authority_start..]
2290 .find('/')
2291 .map(|index| authority_start + index + 1)
2292 } else {
2293 raw.find('/').map(|index| index + 1)
2294 };
2295 let Some(path_start) = path_start else {
2296 return (raw.to_string(), None);
2297 };
2298 let Some(offset) = raw[path_start..].find('@') else {
2299 return (raw.to_string(), None);
2300 };
2301 let separator = path_start + offset;
2302 let repo = &raw[..separator];
2303 let revision = &raw[separator + 1..];
2304 if repo.is_empty() || revision.is_empty() {
2305 return (raw.to_string(), None);
2306 }
2307 (repo.to_string(), Some(revision.to_string()))
2308}
2309
2310fn normalize_git_host(authority: &str) -> Option<String> {
2311 if authority != authority.trim() {
2312 return None;
2313 }
2314 let authority = authority.trim();
2315 if authority.is_empty() {
2316 return None;
2317 }
2318 let host = if authority.starts_with('[') {
2321 let end = authority.find(']')?;
2322 if !authority[end + 1..].is_empty() {
2323 let suffix = &authority[end + 1..];
2324 if !suffix.starts_with(':') || !suffix[1..].bytes().all(|byte| byte.is_ascii_digit()) {
2325 return None;
2326 }
2327 }
2328 &authority[1..end]
2329 } else {
2330 authority
2331 .rsplit_once(':')
2332 .filter(|(_, port)| !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit()))
2333 .map_or(authority, |(host, _)| host)
2334 };
2335 Some(host.to_string())
2336}
2337
2338fn parse_git_authority(authority: &str) -> Option<(String, Option<u16>, Option<String>)> {
2339 if authority.is_empty() || authority != authority.trim() {
2340 return None;
2341 }
2342 let authority = authority.trim();
2343 let (user_info, host_and_port) = match authority.rsplit_once('@') {
2344 Some((user_info, host_and_port)) => {
2345 if user_info.is_empty()
2346 || user_info.contains('\\')
2347 || user_info
2348 .chars()
2349 .any(|character| character.is_control() || character.is_whitespace())
2350 {
2351 return None;
2352 }
2353 (Some(user_info.to_string()), host_and_port)
2354 }
2355 None => (None, authority),
2356 };
2357 let (host, port) = if host_and_port.starts_with('[') {
2358 let end = host_and_port.find(']')?;
2359 let suffix = &host_and_port[end + 1..];
2360 let port = if suffix.is_empty() {
2361 None
2362 } else {
2363 suffix.strip_prefix(':')?.parse::<u16>().ok()
2364 };
2365 (&host_and_port[..=end], port)
2366 } else if let Some((host, port)) = host_and_port.rsplit_once(':') {
2367 if port.is_empty() || !port.bytes().all(|byte| byte.is_ascii_digit()) {
2368 return None;
2369 }
2370 (host, Some(port.parse::<u16>().ok()?))
2371 } else {
2372 (host_and_port, None)
2373 };
2374 Some((normalize_git_host(host)?, port, user_info))
2375}
2376
2377fn safe_git_install_part(value: &str, allow_slash: bool) -> bool {
2378 let Some(decoded) = percent_decode_for_validation(value) else {
2379 return false;
2380 };
2381 for candidate in [value, decoded.as_str()] {
2382 if candidate.is_empty()
2383 || candidate.contains('\0')
2384 || candidate.contains('\\')
2385 || candidate.starts_with('/')
2386 || candidate
2387 .chars()
2388 .any(|ch| ch.is_control() || ch.is_whitespace())
2389 || candidate
2390 .chars()
2391 .any(|ch| matches!(ch, ':' | '?' | '*' | '[' | ']' | '<' | '>' | '|' | '"'))
2392 {
2393 return false;
2394 }
2395 if !allow_slash && candidate.contains('/') {
2396 return false;
2397 }
2398 if candidate
2399 .split('/')
2400 .any(|part| part.is_empty() || part == "." || part == "..")
2401 {
2402 return false;
2403 }
2404 }
2405 true
2406}
2407
2408fn safe_git_revision(value: &str) -> bool {
2409 let Some(decoded) = percent_decode_for_validation(value) else {
2410 return false;
2411 };
2412 for candidate in [value, decoded.as_str()] {
2413 if candidate.is_empty()
2414 || candidate.starts_with('-')
2415 || candidate.starts_with('/')
2416 || candidate.ends_with('/')
2417 || candidate.contains('\0')
2418 || candidate.contains('\\')
2419 || candidate.contains("..")
2420 || candidate.contains("@{")
2421 || candidate.chars().any(|ch| {
2422 ch.is_control()
2423 || ch.is_whitespace()
2424 || matches!(ch, '~' | '^' | ':' | '?' | '*' | '[')
2425 })
2426 || candidate
2427 .split('/')
2428 .any(|part| part.is_empty() || part == "." || part == "..")
2429 {
2430 return false;
2431 }
2432 }
2433 true
2434}
2435
2436fn percent_decode_for_validation(value: &str) -> Option<String> {
2437 let bytes = value.as_bytes();
2438 let mut decoded = Vec::with_capacity(bytes.len());
2439 let mut index = 0;
2440 while index < bytes.len() {
2441 if bytes[index] == b'%' {
2442 if index + 2 >= bytes.len() {
2443 return None;
2444 }
2445 let high = hex_value(bytes[index + 1])?;
2446 let low = hex_value(bytes[index + 2])?;
2447 decoded.push((high << 4) | low);
2448 index += 3;
2449 } else {
2450 decoded.push(bytes[index]);
2451 index += 1;
2452 }
2453 }
2454 String::from_utf8(decoded).ok()
2455}
2456
2457fn hex_value(value: u8) -> Option<u8> {
2458 match value {
2459 b'0'..=b'9' => Some(value - b'0'),
2460 b'a'..=b'f' => Some(value - b'a' + 10),
2461 b'A'..=b'F' => Some(value - b'A' + 10),
2462 _ => None,
2463 }
2464}
2465
2466fn safe_resource_path(root: &Path, value: &str) -> Option<PathBuf> {
2467 safe_resource_path_from(root, root, value)
2468}
2469
2470fn safe_resource_path_from(boundary: &Path, base: &Path, value: &str) -> Option<PathBuf> {
2471 let relative = Path::new(value.trim());
2472 if relative.as_os_str().is_empty() || relative.is_absolute() {
2473 return None;
2474 }
2475 let base = normalize_resource_path(std::fs::canonicalize(base).ok()?);
2476 let candidate = normalize_resource_path(base.join(relative));
2477 validated_resource_path(boundary, &candidate).map(|_| candidate)
2478}
2479
2480fn validated_resource_path(boundary: &Path, candidate: &Path) -> Option<PathBuf> {
2481 let boundary = normalize_resource_path(std::fs::canonicalize(boundary).ok()?);
2482 let canonical = normalize_resource_path(std::fs::canonicalize(candidate).ok()?);
2483 resource_path_is_within(&canonical, &boundary).then_some(canonical)
2484}
2485
2486fn resource_path_is_within(path: &Path, root: &Path) -> bool {
2487 #[cfg(not(windows))]
2488 {
2489 path.starts_with(root)
2490 }
2491 #[cfg(windows)]
2492 {
2493 let path: Vec<String> = path
2494 .components()
2495 .map(|part| part.as_os_str().to_string_lossy().to_lowercase())
2496 .collect();
2497 let root: Vec<String> = root
2498 .components()
2499 .map(|part| part.as_os_str().to_string_lossy().to_lowercase())
2500 .collect();
2501 path.len() >= root.len() && path[..root.len()] == root
2502 }
2503}
2504
2505fn normalize_resource_path(path: PathBuf) -> PathBuf {
2506 #[cfg(windows)]
2507 {
2508 let text = path.to_string_lossy();
2509 if let Some(stripped) = text.strip_prefix(r"\\?\UNC\") {
2510 return PathBuf::from(format!(r"\\{stripped}"));
2511 }
2512 if let Some(stripped) = text.strip_prefix(r"\\?\") {
2513 return PathBuf::from(stripped);
2514 }
2515 }
2516 path
2517}
2518
2519#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2520enum FilterResourceKind {
2521 Extensions,
2522 Skills,
2523 Prompts,
2524 Themes,
2525}
2526
2527fn apply_package_filter(
2528 root: &Path,
2529 extensions: &mut Vec<PathBuf>,
2530 skills: &mut Vec<PathBuf>,
2531 prompts: &mut Vec<PathBuf>,
2532 themes: &mut Vec<PathBuf>,
2533 filter: &crate::settings::PackageFilter,
2534) {
2535 *extensions = filter_paths(
2536 root,
2537 extensions,
2538 filter.extensions.as_deref(),
2539 filter.autoload,
2540 FilterResourceKind::Extensions,
2541 );
2542 *skills = filter_paths(
2543 root,
2544 skills,
2545 filter.skills.as_deref(),
2546 filter.autoload,
2547 FilterResourceKind::Skills,
2548 );
2549 *prompts = filter_paths(
2550 root,
2551 prompts,
2552 filter.prompts.as_deref(),
2553 filter.autoload,
2554 FilterResourceKind::Prompts,
2555 );
2556 *themes = filter_paths(
2557 root,
2558 themes,
2559 filter.themes.as_deref(),
2560 filter.autoload,
2561 FilterResourceKind::Themes,
2562 );
2563}
2564
2565fn filter_paths(
2566 root: &Path,
2567 defaults: &[PathBuf],
2568 patterns: Option<&[String]>,
2569 autoload: Option<bool>,
2570 kind: FilterResourceKind,
2571) -> Vec<PathBuf> {
2572 let Some(patterns) = patterns else {
2573 return if autoload == Some(false) {
2574 Vec::new()
2575 } else {
2576 defaults.to_vec()
2577 };
2578 };
2579 if patterns.is_empty() && autoload != Some(false) {
2580 return Vec::new();
2583 }
2584 let pattern_root =
2585 normalize_resource_path(std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()));
2586 let all = resource_inventory(&pattern_root, defaults, kind);
2587 if autoload == Some(false) {
2588 let mut enabled = HashSet::new();
2589 for pattern in patterns {
2590 let (mode, target) = pattern_mode(pattern);
2591 let exact = matches!(mode, PatternMode::ForceInclude | PatternMode::ForceExclude);
2592 for path in &all {
2593 if matches_resource_pattern(path, &pattern_root, target, exact, kind) {
2594 match mode {
2595 PatternMode::Exclude | PatternMode::ForceExclude => {
2596 enabled.remove(path);
2597 }
2598 PatternMode::Include | PatternMode::ForceInclude => {
2599 enabled.insert(path.clone());
2600 }
2601 }
2602 }
2603 }
2604 }
2605 return sorted_paths(enabled.into_iter().collect());
2606 }
2607 apply_resource_patterns(&all, patterns, &pattern_root, kind)
2608}
2609
2610fn apply_autoload_delta_to_package(
2611 package: &mut PackageRoot,
2612 filter: &crate::settings::PackageFilter,
2613) {
2614 if filter.autoload != Some(false) {
2615 return;
2616 }
2617 if let Some(patterns) = filter.extensions.as_deref() {
2618 package.extensions = apply_delta_paths(
2619 &package.root,
2620 &package.extensions,
2621 patterns,
2622 FilterResourceKind::Extensions,
2623 );
2624 }
2625 if let Some(patterns) = filter.skills.as_deref() {
2626 package.skills = apply_delta_paths(
2627 &package.root,
2628 &package.skills,
2629 patterns,
2630 FilterResourceKind::Skills,
2631 );
2632 }
2633 if let Some(patterns) = filter.prompts.as_deref() {
2634 package.prompts = apply_delta_paths(
2635 &package.root,
2636 &package.prompts,
2637 patterns,
2638 FilterResourceKind::Prompts,
2639 );
2640 }
2641 if let Some(patterns) = filter.themes.as_deref() {
2642 package.themes = apply_delta_paths(
2643 &package.root,
2644 &package.themes,
2645 patterns,
2646 FilterResourceKind::Themes,
2647 );
2648 }
2649}
2650
2651fn apply_delta_paths(
2652 root: &Path,
2653 current: &[PathBuf],
2654 patterns: &[String],
2655 kind: FilterResourceKind,
2656) -> Vec<PathBuf> {
2657 if patterns.is_empty() {
2658 return current.to_vec();
2659 }
2660 let pattern_root =
2661 normalize_resource_path(std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()));
2662 let all = resource_inventory(&pattern_root, current, kind);
2663 let mut selected: HashSet<PathBuf> = current
2664 .iter()
2665 .filter_map(|path| {
2666 if path.is_file() {
2667 Some(normalize_resource_path(path.clone()))
2668 } else {
2669 None
2670 }
2671 })
2672 .collect();
2673 if selected.is_empty() && current.iter().any(|path| path.is_dir()) {
2677 selected.extend(all.iter().cloned());
2678 }
2679 for pattern in patterns {
2680 let (mode, target) = pattern_mode(pattern);
2681 let exact = matches!(mode, PatternMode::ForceInclude | PatternMode::ForceExclude);
2682 for path in &all {
2683 if !matches_resource_pattern(path, &pattern_root, target, exact, kind) {
2684 continue;
2685 }
2686 match mode {
2687 PatternMode::Exclude | PatternMode::ForceExclude => {
2688 selected.remove(path);
2689 }
2690 PatternMode::Include | PatternMode::ForceInclude => {
2691 selected.insert(path.clone());
2692 }
2693 }
2694 }
2695 }
2696 sorted_paths(selected.into_iter().collect())
2697}
2698
2699#[derive(Debug, Clone, Copy)]
2700enum PatternMode {
2701 Include,
2702 Exclude,
2703 ForceInclude,
2704 ForceExclude,
2705}
2706
2707fn pattern_mode(pattern: &str) -> (PatternMode, &str) {
2708 if let Some(value) = pattern.strip_prefix('+') {
2709 (PatternMode::ForceInclude, value)
2710 } else if let Some(value) = pattern.strip_prefix('-') {
2711 (PatternMode::ForceExclude, value)
2712 } else if let Some(value) = pattern.strip_prefix('!') {
2713 (PatternMode::Exclude, value)
2714 } else {
2715 (PatternMode::Include, pattern)
2716 }
2717}
2718
2719fn resource_inventory(root: &Path, defaults: &[PathBuf], kind: FilterResourceKind) -> Vec<PathBuf> {
2720 let Ok(boundary) = std::fs::canonicalize(root).map(normalize_resource_path) else {
2721 return Vec::new();
2722 };
2723 let mut out = HashSet::new();
2724 let mut visited = HashSet::new();
2725 for path in defaults {
2726 collect_resource_files(&boundary, path, kind, &mut out, &mut visited);
2727 }
2728 sorted_paths(out.into_iter().collect())
2729}
2730
2731fn collect_resource_files(
2732 boundary: &Path,
2733 path: &Path,
2734 kind: FilterResourceKind,
2735 out: &mut HashSet<PathBuf>,
2736 visited: &mut HashSet<PathBuf>,
2737) {
2738 let Some(canonical) = validated_resource_path(boundary, path) else {
2739 return;
2740 };
2741 let Ok(metadata) = std::fs::metadata(path) else {
2742 return;
2743 };
2744 if metadata.is_file() {
2745 if valid_resource_file(path, kind) {
2746 out.insert(canonical);
2747 }
2748 return;
2749 }
2750 if !metadata.is_dir() || !visited.insert(canonical) {
2751 return;
2752 }
2753 match kind {
2754 FilterResourceKind::Extensions => collect_extension_directory(boundary, path, out, visited),
2755 FilterResourceKind::Skills => collect_skill_directory(boundary, path, path, out, visited),
2756 FilterResourceKind::Prompts | FilterResourceKind::Themes => {
2757 collect_recursive_resource_directory(boundary, path, kind, out, visited)
2758 }
2759 }
2760}
2761
2762fn valid_resource_file(path: &Path, kind: FilterResourceKind) -> bool {
2763 match kind {
2764 FilterResourceKind::Extensions => matches!(
2765 path.extension().and_then(|ext| ext.to_str()),
2766 Some("js" | "ts")
2767 ),
2768 FilterResourceKind::Skills | FilterResourceKind::Prompts => {
2769 path.extension().and_then(|ext| ext.to_str()) == Some("md")
2770 }
2771 FilterResourceKind::Themes => path.extension().and_then(|ext| ext.to_str()) == Some("json"),
2772 }
2773}
2774
2775fn collect_extension_directory(
2776 boundary: &Path,
2777 dir: &Path,
2778 out: &mut HashSet<PathBuf>,
2779 visited: &mut HashSet<PathBuf>,
2780) {
2781 if let Some(entries) = extension_manifest_entries(dir) {
2782 let resolved = resolve_manifest_resources(
2783 boundary,
2784 dir,
2785 &entries,
2786 FilterResourceKind::Extensions,
2787 visited,
2788 );
2789 if resolved.had_source {
2790 out.extend(resolved.paths);
2791 return;
2792 }
2793 }
2794
2795 for index in ["index.ts", "index.js"] {
2796 let path = dir.join(index);
2797 if let Some(canonical) = validated_resource_path(boundary, &path).filter(|_| path.is_file())
2798 {
2799 out.insert(canonical);
2800 return;
2801 }
2802 }
2803
2804 for entry in visible_directory_entries(dir) {
2805 let path = entry.path();
2806 let Ok(metadata) = std::fs::metadata(&path) else {
2807 continue;
2808 };
2809 if metadata.is_file() {
2810 if valid_resource_file(&path, FilterResourceKind::Extensions) {
2811 if let Some(canonical) = validated_resource_path(boundary, &path) {
2812 out.insert(canonical);
2813 }
2814 }
2815 } else if metadata.is_dir() {
2816 let Some(canonical) = validated_resource_path(boundary, &path) else {
2817 continue;
2818 };
2819 if !visited.insert(canonical) {
2820 continue;
2821 }
2822 collect_extension_entry_directory(boundary, &path, out, visited);
2823 }
2824 }
2825}
2826
2827fn collect_extension_entry_directory(
2828 boundary: &Path,
2829 dir: &Path,
2830 out: &mut HashSet<PathBuf>,
2831 visited: &mut HashSet<PathBuf>,
2832) {
2833 if let Some(entries) = extension_manifest_entries(dir) {
2834 let resolved = resolve_manifest_resources(
2835 boundary,
2836 dir,
2837 &entries,
2838 FilterResourceKind::Extensions,
2839 visited,
2840 );
2841 if resolved.had_source {
2842 out.extend(resolved.paths);
2843 return;
2844 }
2845 }
2846 for index in ["index.ts", "index.js"] {
2847 let path = dir.join(index);
2848 if let Some(canonical) = validated_resource_path(boundary, &path).filter(|_| path.is_file())
2849 {
2850 out.insert(canonical);
2851 return;
2852 }
2853 }
2854}
2855
2856fn extension_manifest_entries(dir: &Path) -> Option<Vec<String>> {
2857 let manifest = std::fs::read_to_string(dir.join("package.json")).ok()?;
2858 let manifest = parse_json_with_comments(&manifest).ok()?;
2859 let rpi = manifest.get("rpi").unwrap_or(&Value::Null);
2860 let pi = manifest.get("pi").unwrap_or(&Value::Null);
2861 let entries = rpi
2862 .get("extensions")
2863 .or_else(|| pi.get("extensions"))
2864 .or_else(|| manifest.get("extensions"))
2865 .map(string_values)?;
2866 (!entries.is_empty()).then_some(entries)
2867}
2868
2869fn collect_skill_directory(
2870 boundary: &Path,
2871 dir: &Path,
2872 discovery_root: &Path,
2873 out: &mut HashSet<PathBuf>,
2874 visited: &mut HashSet<PathBuf>,
2875) {
2876 let skill_file = dir.join("SKILL.md");
2877 if let Some(canonical) =
2878 validated_resource_path(boundary, &skill_file).filter(|_| skill_file.is_file())
2879 {
2880 out.insert(canonical);
2881 return;
2882 }
2883
2884 for entry in visible_directory_entries(dir) {
2885 let path = entry.path();
2886 let Ok(metadata) = std::fs::metadata(&path) else {
2887 continue;
2888 };
2889 if metadata.is_file() {
2890 if dir == discovery_root && valid_resource_file(&path, FilterResourceKind::Skills) {
2891 if let Some(canonical) = validated_resource_path(boundary, &path) {
2892 out.insert(canonical);
2893 }
2894 }
2895 continue;
2896 }
2897 if !metadata.is_dir() {
2898 continue;
2899 }
2900 let Some(canonical) = validated_resource_path(boundary, &path) else {
2901 continue;
2902 };
2903 if visited.insert(canonical) {
2904 collect_skill_directory(boundary, &path, discovery_root, out, visited);
2905 }
2906 }
2907}
2908
2909fn collect_recursive_resource_directory(
2910 boundary: &Path,
2911 dir: &Path,
2912 kind: FilterResourceKind,
2913 out: &mut HashSet<PathBuf>,
2914 visited: &mut HashSet<PathBuf>,
2915) {
2916 for entry in visible_directory_entries(dir) {
2917 collect_resource_files(boundary, &entry.path(), kind, out, visited);
2918 }
2919}
2920
2921fn visible_directory_entries(dir: &Path) -> Vec<std::fs::DirEntry> {
2922 let mut entries: Vec<_> = std::fs::read_dir(dir)
2923 .ok()
2924 .into_iter()
2925 .flatten()
2926 .filter_map(Result::ok)
2927 .filter(|entry| {
2928 entry
2929 .file_name()
2930 .to_str()
2931 .is_some_and(|name| !name.starts_with('.') && name != "node_modules")
2932 })
2933 .collect();
2934 entries.sort_by_key(std::fs::DirEntry::file_name);
2935 entries
2936}
2937
2938#[derive(Debug)]
2939struct ManifestResourceResolution {
2940 paths: Vec<PathBuf>,
2941 had_source: bool,
2942}
2943
2944fn resolve_manifest_resources(
2945 boundary: &Path,
2946 base: &Path,
2947 entries: &[String],
2948 kind: FilterResourceKind,
2949 visited: &mut HashSet<PathBuf>,
2950) -> ManifestResourceResolution {
2951 let mut discovered = HashSet::new();
2952 let mut had_source = false;
2953 for entry in entries.iter().filter(|entry| !is_override_pattern(entry)) {
2954 let sources = if has_glob_pattern(entry) {
2955 expand_resource_glob(boundary, base, entry)
2956 } else {
2957 safe_resource_path_from(boundary, base, entry)
2958 .into_iter()
2959 .collect()
2960 };
2961 had_source |= !sources.is_empty();
2962 for source in sources {
2963 collect_resource_files(boundary, &source, kind, &mut discovered, visited);
2964 }
2965 }
2966 let all = sorted_paths(discovered.into_iter().collect());
2967 let patterns: Vec<String> = entries
2968 .iter()
2969 .filter(|entry| is_override_pattern(entry))
2970 .cloned()
2971 .collect();
2972 let base =
2973 normalize_resource_path(std::fs::canonicalize(base).unwrap_or_else(|_| base.to_path_buf()));
2974 let paths = apply_resource_patterns(&all, &patterns, &base, kind);
2975 ManifestResourceResolution { paths, had_source }
2976}
2977
2978fn is_override_pattern(pattern: &str) -> bool {
2979 pattern.starts_with(['!', '+', '-'])
2980}
2981
2982fn has_glob_pattern(pattern: &str) -> bool {
2983 pattern.contains(['*', '?'])
2984}
2985
2986fn expand_resource_glob(boundary: &Path, base: &Path, pattern: &str) -> Vec<PathBuf> {
2987 let pattern = normalize_pattern(pattern);
2988 if pattern.is_empty()
2989 || Path::new(&pattern).is_absolute()
2990 || Path::new(&pattern)
2991 .components()
2992 .any(|part| matches!(part, std::path::Component::ParentDir))
2993 {
2994 return Vec::new();
2995 }
2996 let Some(matcher) = compile_resource_glob(&pattern) else {
2997 return Vec::new();
2998 };
2999 let Some(canonical_base) = validated_resource_path(boundary, base) else {
3000 return Vec::new();
3001 };
3002 if !canonical_base.is_dir() {
3003 return Vec::new();
3004 }
3005 let mut matches = Vec::new();
3006 let mut visited = HashSet::from([canonical_base]);
3007 walk_resource_glob(boundary, base, base, &matcher, &mut matches, &mut visited);
3008 sorted_paths(matches)
3009}
3010
3011fn walk_resource_glob(
3012 boundary: &Path,
3013 base: &Path,
3014 dir: &Path,
3015 matcher: &globset::GlobMatcher,
3016 out: &mut Vec<PathBuf>,
3017 visited: &mut HashSet<PathBuf>,
3018) {
3019 for entry in visible_directory_entries_including_node_modules(dir) {
3020 let path = normalize_resource_path(entry.path());
3021 let Some(canonical) = validated_resource_path(boundary, &path) else {
3022 continue;
3023 };
3024 let Ok(metadata) = std::fs::metadata(&path) else {
3025 continue;
3026 };
3027 let Some(relative) = path.strip_prefix(base).ok().map(path_to_pattern) else {
3028 continue;
3029 };
3030 if matcher.is_match(&relative)
3031 || (metadata.is_dir() && matcher.is_match(format!("{relative}/")))
3032 {
3033 out.push(path.clone());
3034 }
3035 if metadata.is_dir() && visited.insert(canonical) {
3036 walk_resource_glob(boundary, base, &path, matcher, out, visited);
3037 }
3038 }
3039}
3040
3041fn visible_directory_entries_including_node_modules(dir: &Path) -> Vec<std::fs::DirEntry> {
3042 let mut entries: Vec<_> = std::fs::read_dir(dir)
3043 .ok()
3044 .into_iter()
3045 .flatten()
3046 .filter_map(Result::ok)
3047 .filter(|entry| {
3048 entry
3049 .file_name()
3050 .to_str()
3051 .is_some_and(|name| !name.starts_with('.'))
3052 })
3053 .collect();
3054 entries.sort_by_key(std::fs::DirEntry::file_name);
3055 entries
3056}
3057
3058fn compile_resource_glob(pattern: &str) -> Option<globset::GlobMatcher> {
3059 let mut builder = globset::GlobBuilder::new(pattern);
3060 builder.literal_separator(true).backslash_escape(false);
3061 builder.build().ok().map(|glob| glob.compile_matcher())
3062}
3063
3064fn apply_resource_patterns(
3065 all: &[PathBuf],
3066 patterns: &[String],
3067 base: &Path,
3068 kind: FilterResourceKind,
3069) -> Vec<PathBuf> {
3070 let includes: Vec<&str> = patterns
3071 .iter()
3072 .filter(|pattern| !is_override_pattern(pattern))
3073 .map(String::as_str)
3074 .collect();
3075 let excludes: Vec<&str> = patterns
3076 .iter()
3077 .filter_map(|pattern| pattern.strip_prefix('!'))
3078 .collect();
3079 let force_includes: Vec<&str> = patterns
3080 .iter()
3081 .filter_map(|pattern| pattern.strip_prefix('+'))
3082 .collect();
3083 let force_excludes: Vec<&str> = patterns
3084 .iter()
3085 .filter_map(|pattern| pattern.strip_prefix('-'))
3086 .collect();
3087
3088 let mut selected: HashSet<PathBuf> = all
3089 .iter()
3090 .filter(|path| {
3091 includes.is_empty()
3092 || includes
3093 .iter()
3094 .any(|pattern| matches_resource_pattern(path, base, pattern, false, kind))
3095 })
3096 .cloned()
3097 .collect();
3098 if !excludes.is_empty() {
3099 selected.retain(|path| {
3100 !excludes
3101 .iter()
3102 .any(|pattern| matches_resource_pattern(path, base, pattern, false, kind))
3103 });
3104 }
3105 for path in all {
3106 if force_includes
3107 .iter()
3108 .any(|pattern| matches_resource_pattern(path, base, pattern, true, kind))
3109 {
3110 selected.insert(path.clone());
3111 }
3112 }
3113 if !force_excludes.is_empty() {
3114 selected.retain(|path| {
3115 !force_excludes
3116 .iter()
3117 .any(|pattern| matches_resource_pattern(path, base, pattern, true, kind))
3118 });
3119 }
3120 sorted_paths(selected.into_iter().collect())
3121}
3122
3123fn sorted_paths(mut paths: Vec<PathBuf>) -> Vec<PathBuf> {
3124 paths.sort();
3125 paths.dedup();
3126 paths
3127}
3128
3129fn matches_resource_pattern(
3130 path: &Path,
3131 root: &Path,
3132 pattern: &str,
3133 exact: bool,
3134 kind: FilterResourceKind,
3135) -> bool {
3136 let pattern = normalize_pattern(pattern);
3137 if pattern.is_empty() {
3138 return false;
3139 }
3140 let root =
3141 normalize_resource_path(std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()));
3142 let rel = path
3143 .strip_prefix(&root)
3144 .ok()
3145 .map(path_to_pattern)
3146 .unwrap_or_default();
3147 let name = path
3148 .file_name()
3149 .and_then(|value| value.to_str())
3150 .unwrap_or("");
3151 let absolute = path_to_pattern(path);
3152 let parent_rel = path
3153 .parent()
3154 .and_then(|parent| parent.strip_prefix(&root).ok())
3155 .map(path_to_pattern);
3156 let parent_absolute = path.parent().map(path_to_pattern);
3157 let exact_match =
3158 |candidate: &str| candidate == pattern || normalize_pattern(candidate) == pattern;
3159 if exact {
3160 return exact_match(&rel)
3161 || exact_match(&absolute)
3162 || (matches!(kind, FilterResourceKind::Skills)
3163 && (parent_rel.as_deref().is_some_and(exact_match)
3164 || parent_absolute.as_deref().is_some_and(exact_match)));
3165 }
3166 let matcher = compile_resource_glob(&pattern);
3167 let matches = |candidate: &str| {
3168 matcher
3169 .as_ref()
3170 .is_some_and(|matcher| matcher.is_match(candidate))
3171 || exact_match(candidate)
3172 };
3173 matches(&rel)
3174 || matches(name)
3175 || matches(&absolute)
3176 || (matches!(kind, FilterResourceKind::Skills)
3177 && (parent_rel.as_deref().is_some_and(matches)
3178 || parent_absolute.as_deref().is_some_and(matches)))
3179}
3180
3181fn normalize_pattern(pattern: &str) -> String {
3182 let normalized = pattern.trim().replace('\\', "/");
3183 normalized
3184 .strip_prefix("./")
3185 .unwrap_or(&normalized)
3186 .to_string()
3187}
3188
3189fn path_to_pattern(path: &Path) -> String {
3190 path.to_string_lossy().replace('\\', "/")
3191}
3192
3193fn load_package(
3194 root: PathBuf,
3195 spec: &str,
3196 cwd: &Path,
3197 scope: ResolveScope,
3198 filter: Option<&crate::settings::PackageFilter>,
3199) -> Result<PackageRoot, String> {
3200 load_package_with_legacy_root(root, spec, cwd, scope, filter, None)
3201}
3202
3203fn load_package_with_legacy_root(
3204 root: PathBuf,
3205 spec: &str,
3206 cwd: &Path,
3207 scope: ResolveScope,
3208 filter: Option<&crate::settings::PackageFilter>,
3209 legacy_npm_root: Option<PathBuf>,
3210) -> Result<PackageRoot, String> {
3211 let manifest_path = root.join("package.json");
3212 let raw =
3213 match std::fs::read_to_string(&manifest_path) {
3214 Ok(text) => Some(parse_json_with_comments(&text).map_err(|e| {
3215 format!("invalid package manifest {}: {e}", manifest_path.display())
3216 })?),
3217 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
3218 Err(e) => return Err(format!("could not read {}: {e}", manifest_path.display())),
3219 };
3220 let manifest_name = raw
3221 .as_ref()
3222 .and_then(|v| v.get("name"))
3223 .and_then(Value::as_str);
3224 let explicit_npm_source = if spec.trim_start().starts_with("npm:") {
3225 Some(
3226 npm_source_from_spec(spec)
3227 .ok_or_else(|| format!("invalid npm package source `{spec}`"))?,
3228 )
3229 } else {
3230 None
3231 };
3232 if legacy_npm_root.is_some() || explicit_npm_source.is_some() {
3233 let source = explicit_npm_source
3234 .as_ref()
3235 .ok_or_else(|| format!("legacy npm package has invalid source `{spec}`"))?;
3236 let expected = parse_npm_package_spec(spec)
3237 .map(|parsed| parsed.manifest_name)
3238 .ok_or_else(|| format!("invalid npm package source `{spec}`"))?;
3239 let actual = manifest_name.ok_or_else(|| {
3240 format!(
3241 "npm package manifest {} has no string package name; expected `{expected}`",
3242 manifest_path.display()
3243 )
3244 })?;
3245 if !npm_source_matches_manifest(source, actual) {
3246 return Err(format!(
3247 "npm package manifest name `{actual}` does not match configured package `{expected}`"
3248 ));
3249 }
3250 }
3251 let name = manifest_name
3252 .map(str::to_owned)
3253 .or_else(|| root.file_name().and_then(|s| s.to_str()).map(str::to_owned))
3254 .unwrap_or_else(|| spec.to_string());
3255 let version = raw
3256 .as_ref()
3257 .and_then(|v| v.get("version"))
3258 .and_then(Value::as_str)
3259 .map(str::to_owned);
3260 let manifest = raw.as_ref().map(|_| manifest_path);
3261 let source = if legacy_npm_root.is_some() {
3262 explicit_npm_source
3265 .clone()
3266 .expect("legacy npm sources were validated above")
3267 } else {
3268 classify_package_source(&root, spec, &name, cwd, scope)
3269 };
3270 if explicit_npm_source.is_some()
3271 && is_managed_package_path(&root, cwd, scope)
3272 && source == PackageSource::Unknown
3273 {
3274 return Err(format!(
3275 "managed npm package provenance does not match configured source `{spec}`"
3276 ));
3277 }
3278 let npm_install_root = matches!(source, PackageSource::Npm { .. })
3279 .then(|| npm_install_root_for_path(&root, cwd, scope))
3280 .flatten();
3281 let git_store_root = matches!(source, PackageSource::Git)
3282 .then(|| native_git_store_root_for_path(&root, cwd, scope))
3283 .flatten();
3284 let git_revision = matches!(source, PackageSource::Git)
3285 .then(|| parse_git_source(spec).and_then(|git| git.revision))
3286 .flatten();
3287 let rpi = raw
3290 .as_ref()
3291 .and_then(|v| v.get("rpi"))
3292 .unwrap_or(&Value::Null);
3293 let pi = raw
3294 .as_ref()
3295 .and_then(|v| v.get("pi"))
3296 .unwrap_or(&Value::Null);
3297
3298 let mut skills = resource_paths(
3299 &root,
3300 raw.as_ref(),
3301 rpi,
3302 pi,
3303 "skills",
3304 "skills",
3305 FilterResourceKind::Skills,
3306 );
3307 let mut prompts = resource_paths(
3308 &root,
3309 raw.as_ref(),
3310 rpi,
3311 pi,
3312 "prompts",
3313 "prompts",
3314 FilterResourceKind::Prompts,
3315 );
3316 let mut themes = resource_paths(
3317 &root,
3318 raw.as_ref(),
3319 rpi,
3320 pi,
3321 "themes",
3322 "themes",
3323 FilterResourceKind::Themes,
3324 );
3325 let mut extensions = resource_paths(
3326 &root,
3327 raw.as_ref(),
3328 rpi,
3329 pi,
3330 "extensions",
3331 "extensions",
3332 FilterResourceKind::Extensions,
3333 );
3334 let system_prompts = file_paths(
3335 &root,
3336 raw.as_ref(),
3337 rpi,
3338 pi,
3339 &["systemPrompt", "system_prompt", "system"],
3340 "SYSTEM.md",
3341 );
3342 let append_system_prompts = file_paths(
3343 &root,
3344 raw.as_ref(),
3345 rpi,
3346 pi,
3347 &["appendSystemPrompt", "append_system_prompt", "appendSystem"],
3348 "APPEND_SYSTEM.md",
3349 );
3350 let autoload_delta = filter.is_some_and(|filter| filter.autoload == Some(false));
3351 if let Some(filter) = filter {
3352 apply_package_filter(
3353 &root,
3354 &mut extensions,
3355 &mut skills,
3356 &mut prompts,
3357 &mut themes,
3358 filter,
3359 );
3360 }
3361
3362 Ok(PackageRoot {
3363 skills,
3364 prompts,
3365 themes,
3366 system_prompts,
3367 append_system_prompts,
3368 extensions,
3369 root,
3370 name,
3371 version,
3372 manifest,
3373 spec: spec.to_string(),
3374 source,
3375 npm_install_root,
3376 legacy_npm_root,
3377 autoload_delta,
3378 scope,
3379 git_store_root,
3380 git_revision,
3381 missing_install: false,
3382 filter: filter.cloned(),
3383 })
3384}
3385
3386#[derive(Debug, serde::Serialize, serde::Deserialize)]
3387struct PackageSourceMarker {
3388 kind: String,
3389 spec: String,
3390}
3391
3392pub(crate) fn write_npm_source_marker(root: &Path, spec: &str) -> Result<(), String> {
3393 let source = npm_source_from_spec(spec)
3394 .ok_or_else(|| format!("invalid npm package source marker spec `{spec}`"))?;
3395 let PackageSource::Npm { spec, .. } = source else {
3396 unreachable!();
3397 };
3398 let marker = PackageSourceMarker {
3399 kind: "npm".to_string(),
3400 spec,
3401 };
3402 let data = serde_json::to_vec_pretty(&marker).map_err(|error| error.to_string())?;
3403 std::fs::write(root.join(PACKAGE_SOURCE_MARKER), data)
3404 .map_err(|error| format!("could not write package source marker: {error}"))
3405}
3406
3407pub(crate) fn remove_package_source_marker(root: &Path) -> Result<(), String> {
3408 match std::fs::remove_file(root.join(PACKAGE_SOURCE_MARKER)) {
3409 Ok(()) => Ok(()),
3410 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3411 Err(error) => Err(format!("could not remove package source marker: {error}")),
3412 }
3413}
3414
3415fn read_npm_source_marker(root: &Path, manifest_name: &str) -> Option<PackageSource> {
3416 let marker: PackageSourceMarker =
3417 serde_json::from_str(&std::fs::read_to_string(root.join(PACKAGE_SOURCE_MARKER)).ok()?)
3418 .ok()?;
3419 if marker.kind != "npm" {
3420 return None;
3421 }
3422 let source = npm_source_from_spec(&marker.spec)?;
3423 npm_source_matches_manifest(&source, manifest_name).then_some(source)
3424}
3425
3426fn classify_package_source(
3427 root: &Path,
3428 spec: &str,
3429 manifest_name: &str,
3430 cwd: &Path,
3431 scope: ResolveScope,
3432) -> PackageSource {
3433 if let Some(raw) = spec.strip_prefix("npm:") {
3434 let Some(explicit_source) = npm_source_from_spec(&format!("npm:{raw}")) else {
3435 return PackageSource::Unknown;
3436 };
3437 if !npm_source_matches_manifest(&explicit_source, manifest_name) {
3438 return PackageSource::Unknown;
3439 }
3440 if is_npm_store_package_path(root, cwd, scope) {
3441 return explicit_source;
3442 }
3443 if is_managed_package_path(root, cwd, scope) {
3444 return read_npm_source_marker(root, manifest_name)
3445 .filter(|marker_source| marker_source == &explicit_source)
3446 .unwrap_or(PackageSource::Unknown);
3447 }
3448 return PackageSource::Unknown;
3449 }
3450 if let Some(git) = parse_git_source(spec) {
3451 return native_git_target_for_spec(cwd, scope, &git)
3452 .filter(|target| target == root)
3453 .map_or(PackageSource::Unknown, |_| PackageSource::Git);
3454 }
3455 if spec.starts_with("file:") || Path::new(spec).is_absolute() || spec.starts_with('.') {
3456 if is_native_git_package_path(root, cwd, scope) {
3457 return PackageSource::Git;
3458 }
3459 if is_direct_managed_package_root(root, cwd, scope).is_some() {
3460 return PackageSource::Git;
3461 }
3462 if is_managed_package_path(root, cwd, scope) || is_npm_store_package_path(root, cwd, scope)
3463 {
3464 if let Some(source) = read_npm_source_marker(root, manifest_name) {
3465 return source;
3466 }
3467 }
3468 if is_npm_store_package_path(root, cwd, scope) && valid_npm_name(manifest_name) {
3469 return PackageSource::Npm {
3470 name: manifest_name.to_string(),
3471 spec: format!("npm:{manifest_name}"),
3472 requested: None,
3473 pinned: false,
3474 };
3475 }
3476 return PackageSource::Local;
3477 }
3478 if is_npm_store_package_path(root, cwd, scope) && valid_npm_name(manifest_name) {
3479 return PackageSource::Npm {
3480 name: manifest_name.to_string(),
3481 spec: format!("npm:{manifest_name}"),
3482 requested: None,
3483 pinned: false,
3484 };
3485 }
3486 PackageSource::Unknown
3487}
3488
3489fn npm_source_from_spec(spec: &str) -> Option<PackageSource> {
3490 let raw = spec.strip_prefix("npm:")?;
3491 let parsed = parse_npm_package_spec(raw)?;
3492 let pinned = parsed
3493 .target_selector
3494 .as_deref()
3495 .is_some_and(is_exact_npm_version);
3496 Some(PackageSource::Npm {
3497 name: parsed.install_name,
3498 spec: format!("npm:{}", raw.trim()),
3499 requested: parsed.requested,
3500 pinned,
3501 })
3502}
3503
3504fn npm_source_matches_manifest(source: &PackageSource, manifest_name: &str) -> bool {
3505 let PackageSource::Npm { spec, .. } = source else {
3506 return false;
3507 };
3508 parse_npm_package_spec(spec).is_some_and(|parsed| parsed.manifest_name == manifest_name)
3509}
3510
3511fn is_exact_npm_version(value: &str) -> bool {
3512 let value = value.trim().strip_prefix('v').unwrap_or(value.trim());
3513 let mut build_parts = value.split('+');
3514 let core_and_pre = build_parts.next().unwrap_or_default();
3515 if build_parts
3516 .next()
3517 .is_some_and(|build| !valid_semver_identifiers(build, false))
3518 || build_parts.next().is_some()
3519 {
3520 return false;
3521 }
3522 let (core, prerelease) = core_and_pre
3523 .split_once('-')
3524 .map_or((core_and_pre, None), |(core, pre)| (core, Some(pre)));
3525 if prerelease.is_some_and(|pre| !valid_semver_identifiers(pre, true)) {
3526 return false;
3527 }
3528 let mut parts = core.split('.');
3529 let Some(major) = parts.next() else {
3530 return false;
3531 };
3532 let Some(minor) = parts.next() else {
3533 return false;
3534 };
3535 let Some(patch) = parts.next() else {
3536 return false;
3537 };
3538 parts.next().is_none()
3539 && [major, minor, patch].iter().all(|part| {
3540 !part.is_empty()
3541 && part.chars().all(|ch| ch.is_ascii_digit())
3542 && (*part == "0" || !part.starts_with('0'))
3543 })
3544}
3545
3546fn valid_semver_identifiers(value: &str, reject_numeric_leading_zero: bool) -> bool {
3547 !value.is_empty()
3548 && value.split('.').all(|identifier| {
3549 !identifier.is_empty()
3550 && identifier
3551 .chars()
3552 .all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
3553 && (!reject_numeric_leading_zero
3554 || !identifier.chars().all(|ch| ch.is_ascii_digit())
3555 || identifier == "0"
3556 || !identifier.starts_with('0'))
3557 })
3558}
3559
3560pub(crate) fn parse_npm_package_spec(spec: &str) -> Option<ParsedNpmPackageSpec> {
3561 let raw = spec.strip_prefix("npm:").unwrap_or(spec).trim();
3562 let (install_name, requested) = split_npm_name_and_selector(raw)?;
3563 if !valid_npm_name(install_name) {
3564 return None;
3565 }
3566
3567 let Some(requested) = requested else {
3568 return Some(ParsedNpmPackageSpec {
3569 install_name: install_name.to_string(),
3570 manifest_name: install_name.to_string(),
3571 requested: None,
3572 target_selector: None,
3573 is_alias: false,
3574 });
3575 };
3576 let requested = requested.trim();
3577 if !safe_npm_registry_selector(requested, true) {
3578 return None;
3579 }
3580
3581 let Some(alias_target) = requested.strip_prefix("npm:") else {
3582 return Some(ParsedNpmPackageSpec {
3583 install_name: install_name.to_string(),
3584 manifest_name: install_name.to_string(),
3585 requested: Some(requested.to_string()),
3586 target_selector: Some(requested.to_string()),
3587 is_alias: false,
3588 });
3589 };
3590 let (manifest_name, target_selector) = split_npm_name_and_selector(alias_target)?;
3591 if !valid_npm_name(manifest_name)
3592 || target_selector.is_some_and(|selector| !safe_npm_registry_selector(selector, false))
3593 {
3594 return None;
3595 }
3596 Some(ParsedNpmPackageSpec {
3597 install_name: install_name.to_string(),
3598 manifest_name: manifest_name.to_string(),
3599 requested: Some(requested.to_string()),
3600 target_selector: target_selector.map(str::to_string),
3601 is_alias: true,
3602 })
3603}
3604
3605fn split_npm_name_and_selector(spec: &str) -> Option<(&str, Option<&str>)> {
3606 let spec = spec.trim();
3607 if spec.is_empty() || spec.chars().any(char::is_control) {
3608 return None;
3609 }
3610 let separator = if spec.starts_with('@') {
3611 let slash = spec.find('/')?;
3612 spec[slash + 1..].find('@').map(|index| slash + 1 + index)
3613 } else {
3614 spec.find('@')
3615 };
3616 match separator {
3617 Some(index) => {
3618 let selector = &spec[index + 1..];
3619 (!selector.is_empty()).then_some((&spec[..index], Some(selector)))
3620 }
3621 None => Some((spec, None)),
3622 }
3623}
3624
3625fn safe_npm_registry_selector(selector: &str, allow_alias: bool) -> bool {
3626 let selector = selector.trim();
3627 if selector.is_empty()
3628 || selector.starts_with('-')
3629 || selector.starts_with('.')
3630 || selector.starts_with('/')
3631 || selector.contains('\\')
3632 || selector.chars().any(char::is_control)
3633 {
3634 return false;
3635 }
3636 if let Some(target) = selector.strip_prefix("npm:") {
3637 return allow_alias && !target.is_empty();
3638 }
3639 let lower = selector.to_ascii_lowercase();
3640 ![
3641 "file:",
3642 "link:",
3643 "workspace:",
3644 "git:",
3645 "git+",
3646 "http:",
3647 "https:",
3648 "ssh:",
3649 "github:",
3650 "gitlab:",
3651 "bitbucket:",
3652 ]
3653 .iter()
3654 .any(|prefix| lower.starts_with(prefix))
3655}
3656
3657fn valid_npm_name(name: &str) -> bool {
3658 if name.starts_with('-') {
3659 return false;
3660 }
3661 if let Some(scoped) = name.strip_prefix('@') {
3662 let mut parts = scoped.split('/');
3663 return parts.next().is_some_and(valid_npm_name_part)
3664 && parts.next().is_some_and(valid_npm_name_part)
3665 && parts.next().is_none();
3666 }
3667 valid_npm_name_part(name)
3668}
3669
3670fn valid_npm_name_part(part: &str) -> bool {
3671 !matches!(part, "" | "." | "..")
3672 && !part.starts_with('.')
3673 && !part.starts_with('-')
3674 && part
3675 .chars()
3676 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~'))
3677}
3678
3679fn parse_json_with_comments(text: &str) -> Result<Value, serde_json::Error> {
3680 match serde_json::from_str(text) {
3681 Ok(value) => Ok(value),
3682 Err(first) => serde_json::from_str(&config::strip_line_comments(text)).map_err(|_| first),
3683 }
3684}
3685
3686fn resource_paths(
3687 root: &Path,
3688 top: Option<&Value>,
3689 rpi: &Value,
3690 pi: &Value,
3691 key: &str,
3692 default_dir: &str,
3693 kind: FilterResourceKind,
3694) -> Vec<PathBuf> {
3695 let values = rpi
3696 .get(key)
3697 .or_else(|| pi.get(key))
3698 .or_else(|| top.and_then(|v| v.get(key)));
3699 if let Some(values) = values {
3700 return resolve_manifest_resources(
3701 root,
3702 root,
3703 &string_values(values),
3704 kind,
3705 &mut HashSet::new(),
3706 )
3707 .paths;
3708 }
3709 resource_inventory(root, &[root.join(default_dir)], kind)
3710}
3711
3712fn file_paths(
3713 root: &Path,
3714 top: Option<&Value>,
3715 rpi: &Value,
3716 pi: &Value,
3717 keys: &[&str],
3718 default_file: &str,
3719) -> Vec<PathBuf> {
3720 let value = keys.iter().find_map(|key| {
3721 rpi.get(*key)
3722 .or_else(|| pi.get(*key))
3723 .or_else(|| top.and_then(|v| v.get(*key)))
3724 });
3725 let mut paths = value
3726 .map(|v| {
3727 string_values(v)
3728 .into_iter()
3729 .filter_map(|path| safe_resource_path(root, &path))
3730 .collect()
3731 })
3732 .unwrap_or_else(|| vec![root.join(default_file)]);
3733 paths.retain(|p: &PathBuf| p.is_file());
3734 paths
3735}
3736
3737fn string_values(value: &Value) -> Vec<String> {
3738 match value {
3739 Value::String(s) => vec![s.clone()],
3740 Value::Array(values) => values
3741 .iter()
3742 .filter_map(Value::as_str)
3743 .map(str::to_owned)
3744 .collect(),
3745 _ => Vec::new(),
3746 }
3747}
3748
3749fn normalize_key(path: &Path) -> String {
3750 std::fs::canonicalize(path)
3751 .unwrap_or_else(|_| path.to_path_buf())
3752 .to_string_lossy()
3753 .to_ascii_lowercase()
3754}
3755
3756#[cfg(test)]
3757mod tests {
3758 use super::*;
3759
3760 struct RestoreEnv {
3761 name: &'static str,
3762 value: Option<std::ffi::OsString>,
3763 }
3764
3765 impl RestoreEnv {
3766 fn capture(name: &'static str) -> Self {
3767 Self {
3768 name,
3769 value: std::env::var_os(name),
3770 }
3771 }
3772 }
3773
3774 impl Drop for RestoreEnv {
3775 fn drop(&mut self) {
3776 match self.value.take() {
3777 Some(value) => std::env::set_var(self.name, value),
3778 None => std::env::remove_var(self.name),
3779 }
3780 }
3781 }
3782
3783 #[test]
3784 fn package_command_trust_overrides_are_explicit_and_conflict_safe() {
3785 let cwd = Path::new(".");
3786 assert!(package_command_project_trusted(cwd, &["--approve".into()]).unwrap());
3787 assert!(!package_command_project_trusted(cwd, &["--no-approve".into()]).unwrap());
3788 assert!(
3789 package_command_project_trusted(cwd, &["--approve".into(), "--no-approve".into()])
3790 .is_err()
3791 );
3792 assert!(package_command_project_trusted(cwd, &["--unexpected".into()]).is_err());
3793 }
3794
3795 #[test]
3796 fn package_update_accepts_offline_flag_and_skips_all_preflight() {
3797 let _guard = crate::config::test_support::env_lock().lock().unwrap();
3798 let _restore_config = RestoreEnv::capture(config::CONFIG_DIR_ENV);
3799 let _restore_offline = RestoreEnv::capture(crate::args::PI_OFFLINE_ENV);
3800 let tmp = tempfile::tempdir().unwrap();
3801 let agent = tmp.path().join("agent");
3802 std::fs::create_dir_all(&agent).unwrap();
3803 std::fs::write(agent.join("native-packages.json"), "{ malformed").unwrap();
3804 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
3805 std::env::remove_var(crate::args::PI_OFFLINE_ENV);
3806
3807 assert_eq!(run_cli(&["update".into(), "--offline".into()]), 0);
3808 assert_eq!(
3809 std::env::var(crate::args::PI_OFFLINE_ENV).as_deref(),
3810 Ok("1")
3811 );
3812
3813 std::env::set_var(crate::args::PI_OFFLINE_ENV, "0");
3816 assert_eq!(update_packages(tmp.path(), false), 1);
3817 }
3818
3819 #[test]
3820 fn top_level_update_help_is_handled_by_package_updater() {
3821 assert_eq!(run_cli(&["update".into(), "--help".into()]), 0);
3822 }
3823
3824 #[test]
3825 fn discovers_conventional_and_manifest_resources() {
3826 let tmp = tempfile::tempdir().unwrap();
3827 let root = tmp.path().join("pkg");
3828 std::fs::create_dir_all(root.join("custom-skills")).unwrap();
3829 std::fs::create_dir_all(root.join("rpi-skills")).unwrap();
3830 std::fs::create_dir_all(root.join("prompts")).unwrap();
3831 std::fs::create_dir_all(root.join("legacy-prompts")).unwrap();
3832 std::fs::create_dir_all(root.join("themes")).unwrap();
3833 std::fs::write(root.join("custom-skills/a.md"), "---\nname: a\n---\nbody").unwrap();
3834 std::fs::write(root.join("rpi-skills/rpi.md"), "---\nname: rpi\n---\nbody").unwrap();
3835 std::fs::write(root.join("prompts/explain.md"), "explain").unwrap();
3836 std::fs::write(root.join("legacy-prompts/legacy.md"), "legacy").unwrap();
3837 std::fs::write(root.join("themes/ocean.json"), "{}").unwrap();
3838 std::fs::write(
3839 root.join("package.json"),
3840 r#"{"name":"demo","version":"1.0.0","pi":{"skills":["custom-skills"],"prompts":["legacy-prompts"]},"rpi":{"skills":["rpi-skills"]}}"#,
3841 )
3842 .unwrap();
3843
3844 let resources = discover(tmp.path(), &[root.to_string_lossy().into_owned()]);
3845 assert_eq!(resources.packages.len(), 1);
3846 assert_eq!(resources.packages[0].name, "demo");
3847 assert_eq!(resources.skill_dirs(), vec![root.join("rpi-skills/rpi.md")]);
3848 assert_eq!(
3849 resources.prompt_dirs(),
3850 vec![root.join("legacy-prompts/legacy.md")]
3851 );
3852 assert_eq!(
3853 resources.theme_files(),
3854 vec![root.join("themes/ocean.json")]
3855 );
3856 }
3857
3858 #[test]
3859 fn manifest_globs_and_overrides_follow_native_precedence() {
3860 let tmp = tempfile::tempdir().unwrap();
3861 let root = tmp.path().join("pkg");
3862 for dir in [
3863 root.join("extensions"),
3864 root.join("plugins/one/skills/alpha"),
3865 root.join("plugins/two/skills/beta"),
3866 ] {
3867 std::fs::create_dir_all(dir).unwrap();
3868 }
3869 for path in ["extensions/a.ts", "extensions/z.ts"] {
3870 std::fs::write(root.join(path), "export default () => {};").unwrap();
3871 }
3872 for path in [
3873 "plugins/one/skills/alpha/SKILL.md",
3874 "plugins/two/skills/beta/SKILL.md",
3875 ] {
3876 std::fs::write(root.join(path), "---\nname: demo\n---\n").unwrap();
3877 }
3878 std::fs::write(
3879 root.join("package.json"),
3880 r#"{
3881 "name":"glob-demo",
3882 "pi":{
3883 "extensions":[
3884 "extensions/*.ts",
3885 "!**/*.ts",
3886 "+extensions/a.ts",
3887 "-extensions/z.ts",
3888 "+extensions/z.ts"
3889 ],
3890 "skills":["plugins/*/skills"]
3891 }
3892 }"#,
3893 )
3894 .unwrap();
3895
3896 let resources = discover(tmp.path(), &[root.to_string_lossy().into_owned()]);
3897 assert_eq!(
3898 resources.extension_paths(),
3899 vec![root.join("extensions/a.ts")]
3900 );
3901 assert_eq!(
3902 resources.skill_dirs(),
3903 vec![
3904 root.join("plugins/one/skills/alpha/SKILL.md"),
3905 root.join("plugins/two/skills/beta/SKILL.md"),
3906 ]
3907 );
3908 }
3909
3910 #[test]
3911 fn extension_directories_use_smart_entry_discovery() {
3912 let tmp = tempfile::tempdir().unwrap();
3913 let root = tmp.path().join("pkg");
3914 for dir in [
3915 root.join("extensions/group"),
3916 root.join("extensions/custom"),
3917 root.join("extensions/broken"),
3918 ] {
3919 std::fs::create_dir_all(dir).unwrap();
3920 }
3921 for (path, body) in [
3922 ("extensions/standalone.ts", "export default () => {};"),
3923 ("extensions/group/index.ts", "export default () => {};"),
3924 ("extensions/group/helper.ts", "export const helper = 1;"),
3925 ("extensions/custom/main.js", "export default () => {};"),
3926 ("extensions/custom/utils.js", "export const util = 1;"),
3927 ("extensions/broken/helper.ts", "export const helper = 1;"),
3928 ] {
3929 std::fs::write(root.join(path), body).unwrap();
3930 }
3931 std::fs::write(
3932 root.join("extensions/custom/package.json"),
3933 r#"{"pi":{"extensions":["main.js"]}}"#,
3934 )
3935 .unwrap();
3936 std::fs::write(
3937 root.join("package.json"),
3938 r#"{"name":"smart-demo","pi":{"extensions":["extensions"]}}"#,
3939 )
3940 .unwrap();
3941
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!(
3951 package.extensions,
3952 vec![
3953 root.join("extensions/custom/main.js"),
3954 root.join("extensions/group/index.ts"),
3955 root.join("extensions/standalone.ts"),
3956 ]
3957 );
3958
3959 std::fs::write(root.join("extensions/index.js"), "export default () => {};").unwrap();
3960 let package = load_package(
3961 root.clone(),
3962 &root.to_string_lossy(),
3963 tmp.path(),
3964 ResolveScope::Any,
3965 None,
3966 )
3967 .unwrap();
3968 assert_eq!(package.extensions, vec![root.join("extensions/index.js")]);
3969 }
3970
3971 #[test]
3972 fn skill_directory_discovery_ignores_nested_markdown_helpers() {
3973 let tmp = tempfile::tempdir().unwrap();
3974 let root = tmp.path().join("pkg");
3975 for dir in [
3976 root.join("skills/group/nested"),
3977 root.join("skills/docs"),
3978 root.join("skills/deep/alpha"),
3979 ] {
3980 std::fs::create_dir_all(dir).unwrap();
3981 }
3982 for path in [
3983 "skills/root.md",
3984 "skills/group/SKILL.md",
3985 "skills/group/README.md",
3986 "skills/group/nested/SKILL.md",
3987 "skills/docs/README.md",
3988 "skills/deep/alpha/SKILL.md",
3989 ] {
3990 std::fs::write(root.join(path), "---\nname: demo\n---\n").unwrap();
3991 }
3992 std::fs::write(root.join("package.json"), r#"{"name":"skill-demo"}"#).unwrap();
3993
3994 let package = load_package(
3995 root.clone(),
3996 &root.to_string_lossy(),
3997 tmp.path(),
3998 ResolveScope::Any,
3999 None,
4000 )
4001 .unwrap();
4002 assert_eq!(
4003 package.skills,
4004 vec![
4005 root.join("skills/deep/alpha/SKILL.md"),
4006 root.join("skills/group/SKILL.md"),
4007 root.join("skills/root.md"),
4008 ]
4009 );
4010 }
4011
4012 #[test]
4013 fn manifest_prompt_and_theme_directories_are_recursive() {
4014 let tmp = tempfile::tempdir().unwrap();
4015 let root = tmp.path().join("pkg");
4016 std::fs::create_dir_all(root.join("prompt-pack/nested")).unwrap();
4017 std::fs::create_dir_all(root.join("theme-pack/nested")).unwrap();
4018 std::fs::write(root.join("prompt-pack/root.md"), "root").unwrap();
4019 std::fs::write(root.join("prompt-pack/nested/deep.md"), "deep").unwrap();
4020 std::fs::write(root.join("theme-pack/root.json"), "{}").unwrap();
4021 std::fs::write(root.join("theme-pack/nested/deep.json"), "{}").unwrap();
4022 std::fs::write(root.join("theme-pack/nested/not-theme.md"), "ignored").unwrap();
4023 std::fs::write(
4024 root.join("package.json"),
4025 r#"{
4026 "name":"recursive-demo",
4027 "pi":{"prompts":["prompt-pack"],"themes":["theme-pack"]}
4028 }"#,
4029 )
4030 .unwrap();
4031
4032 let resources = discover(tmp.path(), &[root.to_string_lossy().into_owned()]);
4033 assert_eq!(
4034 resources.prompt_dirs(),
4035 vec![
4036 root.join("prompt-pack/nested/deep.md"),
4037 root.join("prompt-pack/root.md"),
4038 ]
4039 );
4040 assert_eq!(
4041 resources.theme_files(),
4042 vec![
4043 root.join("theme-pack/nested/deep.json"),
4044 root.join("theme-pack/root.json"),
4045 ]
4046 );
4047 }
4048
4049 #[test]
4050 fn manifest_resource_paths_cannot_escape_the_package() {
4051 let tmp = tempfile::tempdir().unwrap();
4052 let root = tmp.path().join("pkg");
4053 let outside = tmp.path().join("outside");
4054 std::fs::create_dir_all(&root).unwrap();
4055 std::fs::create_dir_all(&outside).unwrap();
4056 std::fs::write(outside.join("outside.ts"), "export default () => {};").unwrap();
4057 std::fs::write(
4058 root.join("package.json"),
4059 r#"{"name":"escape-demo","pi":{"extensions":["../outside/outside.ts","../outside/*.ts"]}}"#,
4060 )
4061 .unwrap();
4062
4063 let package = load_package(
4064 root.clone(),
4065 &root.to_string_lossy(),
4066 tmp.path(),
4067 ResolveScope::Any,
4068 None,
4069 )
4070 .unwrap();
4071 assert!(package.extensions.is_empty());
4072 }
4073
4074 #[test]
4075 fn manifest_resource_symlink_escape_is_rejected() {
4076 let tmp = tempfile::tempdir().unwrap();
4077 let root = tmp.path().join("pkg");
4078 let outside = tmp.path().join("outside.ts");
4079 std::fs::create_dir_all(&root).unwrap();
4080 std::fs::write(&outside, "export default () => {};").unwrap();
4081 let link = root.join("linked.ts");
4082 #[cfg(unix)]
4083 std::os::unix::fs::symlink(&outside, &link).unwrap();
4084 #[cfg(windows)]
4085 if std::os::windows::fs::symlink_file(&outside, &link).is_err() {
4086 return;
4087 }
4088 std::fs::write(
4089 root.join("package.json"),
4090 r#"{"name":"symlink-demo","pi":{"extensions":["linked.ts"]}}"#,
4091 )
4092 .unwrap();
4093
4094 let package = load_package(
4095 root.clone(),
4096 &root.to_string_lossy(),
4097 tmp.path(),
4098 ResolveScope::Any,
4099 None,
4100 )
4101 .unwrap();
4102 assert!(package.extensions.is_empty());
4103 }
4104
4105 #[test]
4106 fn resolves_package_json_spec_and_deduplicates() {
4107 let tmp = tempfile::tempdir().unwrap();
4108 let root = tmp.path().join("pkg");
4109 std::fs::create_dir_all(&root).unwrap();
4110 std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4111 let manifest = root.join("package.json").to_string_lossy().into_owned();
4112 let resources = discover(
4113 tmp.path(),
4114 &[manifest.clone(), root.to_string_lossy().into_owned()],
4115 );
4116 assert_eq!(resources.packages.len(), 1);
4117 assert!(resources.diagnostics.is_empty());
4118 }
4119
4120 #[test]
4121 fn bare_package_name_prefers_project_rpi_store_over_legacy_pi_store() {
4122 let tmp = tempfile::tempdir().unwrap();
4123 let rpi_root = tmp.path().join(".rpi/packages/demo");
4124 let pi_root = tmp.path().join(".pi/packages/demo");
4125 std::fs::create_dir_all(rpi_root.join("skills")).unwrap();
4126 std::fs::create_dir_all(pi_root.join("skills")).unwrap();
4127 std::fs::write(
4128 rpi_root.join("package.json"),
4129 r#"{"name":"rpi-demo","version":"rpi"}"#,
4130 )
4131 .unwrap();
4132 std::fs::write(
4133 pi_root.join("package.json"),
4134 r#"{"name":"pi-demo","version":"pi"}"#,
4135 )
4136 .unwrap();
4137
4138 let resources = discover(tmp.path(), &["demo".to_string()]);
4139 assert_eq!(resources.packages.len(), 1);
4140 assert_eq!(resources.packages[0].root, rpi_root);
4141 assert_eq!(resources.packages[0].version.as_deref(), Some("rpi"));
4142 }
4143
4144 #[test]
4145 fn npm_scoped_spec_resolves_installed_safe_name() {
4146 let tmp = tempfile::tempdir().unwrap();
4147 let root = tmp.path().join(".rpi/packages/narumitw__pi-btw");
4148 std::fs::create_dir_all(&root).unwrap();
4149 std::fs::write(
4150 root.join("package.json"),
4151 r#"{"name":"@narumitw/pi-btw","version":"0.58.1"}"#,
4152 )
4153 .unwrap();
4154 write_npm_source_marker(&root, "npm:@narumitw/pi-btw").unwrap();
4155
4156 let resources = discover(tmp.path(), &["npm:@narumitw/pi-btw".to_string()]);
4157 assert_eq!(resources.packages.len(), 1);
4158 assert!(resources.diagnostics.is_empty());
4159 assert_eq!(resources.packages[0].name, "@narumitw/pi-btw");
4160 }
4161
4162 #[test]
4163 fn npm_scoped_spec_resolves_project_store_and_versioned_spec() {
4164 let tmp = tempfile::tempdir().unwrap();
4165 let root = tmp.path().join(".pi/npm/node_modules/@scope/demo");
4166 std::fs::create_dir_all(&root).unwrap();
4167 std::fs::write(
4168 root.join("package.json"),
4169 r#"{"name":"@scope/demo","version":"1.2.3"}"#,
4170 )
4171 .unwrap();
4172
4173 for spec in ["npm:@scope/demo", "npm:@scope/demo@1.2.3"] {
4174 let resources = discover(tmp.path(), &[spec.to_string()]);
4175 assert!(resources.diagnostics.is_empty(), "spec={spec}");
4176 assert_eq!(resources.packages[0].root, root, "spec={spec}");
4177 }
4178 }
4179
4180 #[test]
4181 fn npm_store_detection_is_bounded_to_the_configured_agent_root() {
4182 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4183 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4184 let tmp = tempfile::tempdir().unwrap();
4185 let agent = tmp.path().join("real-agent");
4186 let package = agent.join("npm/node_modules/@scope/demo");
4187 let impostor = tmp
4188 .path()
4189 .join("workspace/agent/npm/node_modules/@scope/demo");
4190 std::fs::create_dir_all(&package).unwrap();
4191 std::fs::create_dir_all(&impostor).unwrap();
4192 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4193
4194 assert!(is_npm_store_package_path(
4195 &package,
4196 tmp.path(),
4197 ResolveScope::Any
4198 ));
4199 assert_eq!(
4200 npm_install_root_for_path(&package, tmp.path(), ResolveScope::Any),
4201 std::fs::canonicalize(agent.join("npm")).ok()
4202 );
4203 assert!(!is_npm_store_package_path(
4204 &impostor,
4205 tmp.path(),
4206 ResolveScope::Any
4207 ));
4208 assert!(!is_npm_store_package_path(
4209 &agent.join("npm/node_modules"),
4210 tmp.path(),
4211 ResolveScope::Any
4212 ));
4213 let nested = package.join("node_modules/dependency");
4214 std::fs::create_dir_all(&nested).unwrap();
4215 assert!(!is_npm_store_package_path(
4216 &nested,
4217 tmp.path(),
4218 ResolveScope::Any
4219 ));
4220
4221 match previous {
4222 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4223 None => std::env::remove_var(config::CONFIG_DIR_ENV),
4224 }
4225 }
4226
4227 #[test]
4228 fn legacy_global_npm_is_discovered_but_updates_only_in_managed_store() {
4229 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4230 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4231 let tmp = tempfile::tempdir().unwrap();
4232 let agent = tmp.path().join("agent");
4233 let cwd = tmp.path().join("project");
4234 let global_root = tmp.path().join("legacy-global/node_modules");
4235 let global_package = global_root.join("demo");
4236 std::fs::create_dir_all(&agent).unwrap();
4237 std::fs::create_dir_all(&cwd).unwrap();
4238 std::fs::create_dir_all(global_package.join("extensions")).unwrap();
4239 std::fs::write(
4240 global_package.join("package.json"),
4241 r#"{"name":"demo","version":"1.0.0"}"#,
4242 )
4243 .unwrap();
4244 std::fs::write(
4245 global_package.join("extensions/index.js"),
4246 "export default () => {};",
4247 )
4248 .unwrap();
4249 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4250
4251 let resolved =
4252 resolve_spec_with_legacy_lookup(&cwd, "npm:demo", ResolveScope::User, |name| {
4253 assert_eq!(name, "demo");
4254 std::fs::canonicalize(&global_package).ok()
4255 })
4256 .unwrap();
4257 let canonical_global_root = std::fs::canonicalize(&global_root).unwrap();
4258 assert_eq!(
4259 resolved.root,
4260 std::fs::canonicalize(&global_package).unwrap()
4261 );
4262 assert_eq!(
4263 resolved.legacy_npm_root.as_deref(),
4264 Some(canonical_global_root.as_path())
4265 );
4266
4267 let package = load_package_with_legacy_root(
4268 resolved.root,
4269 "npm:demo",
4270 &cwd,
4271 ResolveScope::User,
4272 None,
4273 resolved.legacy_npm_root,
4274 )
4275 .unwrap();
4276 assert_eq!(package.updateable_npm_name(), Some("demo"));
4277 assert!(package.npm_install_root.is_none());
4278 let update_root = package
4279 .npm_store_root_for_update(&cwd, false)
4280 .unwrap()
4281 .unwrap();
4282 assert_eq!(update_root, agent.join("npm"));
4283 assert_ne!(update_root, global_root);
4284 assert!(!is_managed_package_path(
4285 &global_package,
4286 &cwd,
4287 ResolveScope::User
4288 ));
4289
4290 match previous {
4291 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4292 None => std::env::remove_var(config::CONFIG_DIR_ENV),
4293 }
4294 }
4295
4296 #[test]
4297 fn static_managed_npm_precedes_legacy_lookup() {
4298 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4299 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4300 let tmp = tempfile::tempdir().unwrap();
4301 let agent = tmp.path().join("agent");
4302 let package = agent.join("npm/node_modules/demo");
4303 std::fs::create_dir_all(&package).unwrap();
4304 std::fs::write(package.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4305 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4306
4307 let resolved =
4308 resolve_spec_with_legacy_lookup(tmp.path(), "npm:demo", ResolveScope::User, |_| {
4309 panic!("legacy global lookup must not run for a managed package")
4310 })
4311 .unwrap();
4312 assert_eq!(resolved.root, package);
4313 assert!(resolved.legacy_npm_root.is_none());
4314
4315 match previous {
4316 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4317 None => std::env::remove_var(config::CONFIG_DIR_ENV),
4318 }
4319 }
4320
4321 #[test]
4322 fn legacy_global_lookup_is_never_used_for_project_scope() {
4323 let tmp = tempfile::tempdir().unwrap();
4324 let global_root = tmp.path().join("legacy/node_modules/demo");
4325 std::fs::create_dir_all(&global_root).unwrap();
4326 std::fs::write(global_root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4327 let resolved = resolve_spec_with_legacy_lookup(
4328 &tmp.path().join("project"),
4329 "npm:demo",
4330 ResolveScope::Project,
4331 |_| panic!("project scope must not consult a global package manager"),
4332 );
4333 assert!(resolved.is_none());
4334 }
4335
4336 #[test]
4337 fn legacy_manifest_name_mismatch_blocks_package_loading() {
4338 let tmp = tempfile::tempdir().unwrap();
4339 let global_root = tmp.path().join("legacy/node_modules");
4340 let package_root = global_root.join("demo");
4341 std::fs::create_dir_all(&package_root).unwrap();
4342 std::fs::write(
4343 package_root.join("package.json"),
4344 r#"{"name":"other","version":"1.0.0"}"#,
4345 )
4346 .unwrap();
4347 let error = load_package_with_legacy_root(
4348 std::fs::canonicalize(&package_root).unwrap(),
4349 "npm:demo",
4350 tmp.path(),
4351 ResolveScope::User,
4352 None,
4353 std::fs::canonicalize(&global_root).ok(),
4354 )
4355 .unwrap_err();
4356 assert!(error.contains("manifest name `other`"), "{error}");
4357 assert!(error.contains("configured package `demo`"), "{error}");
4358 }
4359
4360 #[test]
4361 fn legacy_npm_without_manifest_identity_blocks_package_loading() {
4362 let tmp = tempfile::tempdir().unwrap();
4363 let global_root = tmp.path().join("legacy/node_modules");
4364 let package_root = global_root.join("demo");
4365 std::fs::create_dir_all(&package_root).unwrap();
4366 std::fs::write(package_root.join("package.json"), r#"{"version":"1.0.0"}"#).unwrap();
4367
4368 let error = load_package_with_legacy_root(
4369 std::fs::canonicalize(&package_root).unwrap(),
4370 "npm:demo",
4371 tmp.path(),
4372 ResolveScope::User,
4373 None,
4374 std::fs::canonicalize(&global_root).ok(),
4375 )
4376 .unwrap_err();
4377
4378 assert!(error.contains("has no string package name"), "{error}");
4379 assert!(error.contains("expected `demo`"), "{error}");
4380 }
4381
4382 #[test]
4383 fn filtered_package_entries_apply_only_the_requested_resources() {
4384 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4385 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4386 let tmp = tempfile::tempdir().unwrap();
4387 let agent = tmp.path().join("agent");
4388 let root = agent.join("packages/demo");
4389 std::fs::create_dir_all(root.join("extensions")).unwrap();
4390 std::fs::create_dir_all(root.join("skills")).unwrap();
4391 std::fs::write(root.join("extensions/index.js"), "export default () => {};").unwrap();
4392 std::fs::write(root.join("skills/review.md"), "review").unwrap();
4393 std::fs::write(
4394 root.join("package.json"),
4395 r#"{"name":"demo","version":"1.0.0"}"#,
4396 )
4397 .unwrap();
4398 write_npm_source_marker(&root, "npm:demo@beta").unwrap();
4399 std::fs::write(
4400 agent.join("settings.json"),
4401 r#"{"packages":[{"source":"npm:demo@beta","autoload":false,"extensions":["+extensions/index.js"]}]}"#,
4402 )
4403 .unwrap();
4404 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4405
4406 let resources = discover_from_global_settings(tmp.path());
4407
4408 match previous {
4409 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4410 None => std::env::remove_var(config::CONFIG_DIR_ENV),
4411 }
4412 assert_eq!(resources.packages.len(), 1);
4413 assert_eq!(
4414 resources.packages[0].updateable_npm_source(),
4415 Some(("demo", "npm:demo@beta"))
4416 );
4417 assert_eq!(
4418 resources.extension_paths(),
4419 vec![root.join("extensions/index.js")]
4420 );
4421 assert!(resources.skill_dirs().is_empty());
4422 assert!(resources.diagnostics.is_empty());
4423 }
4424
4425 #[test]
4426 fn filtered_package_entries_do_not_disable_other_resource_kinds() {
4427 let tmp = tempfile::tempdir().unwrap();
4428 let root = tmp.path().join("package");
4429 std::fs::create_dir_all(root.join("extensions")).unwrap();
4430 std::fs::create_dir_all(root.join("skills")).unwrap();
4431 std::fs::create_dir_all(root.join("prompts")).unwrap();
4432 std::fs::create_dir_all(root.join("themes")).unwrap();
4433 for (path, body) in [
4434 ("extensions/a.js", "export default () => {};"),
4435 ("skills/keep.md", "keep"),
4436 ("skills/drop.md", "drop"),
4437 ("prompts/one.md", "one"),
4438 ("themes/one.json", "{}"),
4439 ] {
4440 std::fs::write(root.join(path), body).unwrap();
4441 }
4442 std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4443 let filter = crate::settings::PackageFilter {
4444 source: root.to_string_lossy().into_owned(),
4445 autoload: None,
4446 extensions: Some(Vec::new()),
4447 skills: Some(vec!["skills/keep.md".to_string()]),
4448 prompts: None,
4449 themes: None,
4450 unknown: serde_json::Map::new(),
4451 };
4452 let package = load_package(
4453 root.clone(),
4454 &filter.source,
4455 tmp.path(),
4456 ResolveScope::Any,
4457 Some(&filter),
4458 )
4459 .unwrap();
4460 assert!(package.extensions.is_empty());
4461 assert_eq!(package.skills, vec![root.join("skills/keep.md")]);
4462 assert_eq!(package.prompts, vec![root.join("prompts/one.md")]);
4463 assert_eq!(package.themes, vec![root.join("themes/one.json")]);
4464 }
4465
4466 #[test]
4467 fn project_autoload_delta_keeps_matching_global_package_resources() {
4468 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4469 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4470 let tmp = tempfile::tempdir().unwrap();
4471 let agent = tmp.path().join("agent");
4472 let cwd = tmp.path().join("project");
4473 let root = tmp.path().join("shared-package");
4474 std::fs::create_dir_all(root.join("extensions")).unwrap();
4475 std::fs::write(root.join("extensions/a.js"), "export default () => {}; ").unwrap();
4476 std::fs::write(root.join("extensions/b.js"), "export default () => {}; ").unwrap();
4477 std::fs::write(root.join("package.json"), r#"{"name":"shared"}"#).unwrap();
4478 let spec = format!("file:{}", root.display());
4479 std::fs::create_dir_all(&agent).unwrap();
4480 std::fs::write(
4481 agent.join("settings.json"),
4482 serde_json::to_vec(&serde_json::json!({"packages":[spec]})).unwrap(),
4483 )
4484 .unwrap();
4485 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
4486 std::fs::write(
4487 cwd.join(".rpi/settings.json"),
4488 serde_json::to_vec(&serde_json::json!({
4489 "packages":[{"source":spec,"autoload":false,"extensions":["+extensions/a.js"]}]
4490 }))
4491 .unwrap(),
4492 )
4493 .unwrap();
4494 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4495 let resources = discover_from_settings(&cwd);
4496 match previous {
4497 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4498 None => std::env::remove_var(config::CONFIG_DIR_ENV),
4499 }
4500 assert_eq!(resources.packages.len(), 1);
4501 assert!(!resources.packages[0].autoload_delta);
4502 assert_eq!(resources.extension_paths().len(), 2);
4503 }
4504
4505 #[test]
4506 fn configured_packages_with_same_manifest_name_keep_distinct_local_roots() {
4507 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4508 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4509 let tmp = tempfile::tempdir().unwrap();
4510 let agent = tmp.path().join("agent");
4511 let cwd = tmp.path().join("project");
4512 let first = tmp.path().join("first");
4513 let second = tmp.path().join("second");
4514 for root in [&first, &second] {
4515 std::fs::create_dir_all(root.join("skills")).unwrap();
4516 std::fs::write(root.join("skills/item.md"), "item").unwrap();
4517 std::fs::write(root.join("package.json"), r#"{"name":"same"}"#).unwrap();
4518 }
4519 std::fs::create_dir_all(&agent).unwrap();
4520 std::fs::write(
4521 agent.join("settings.json"),
4522 serde_json::to_vec(
4523 &serde_json::json!({"packages":[format!("file:{}", second.display())]}),
4524 )
4525 .unwrap(),
4526 )
4527 .unwrap();
4528 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
4529 std::fs::write(
4530 cwd.join(".rpi/settings.json"),
4531 serde_json::to_vec(
4532 &serde_json::json!({"packages":[format!("file:{}", first.display())]}),
4533 )
4534 .unwrap(),
4535 )
4536 .unwrap();
4537 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4538 let resources = discover_from_settings(&cwd);
4539 match previous {
4540 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4541 None => std::env::remove_var(config::CONFIG_DIR_ENV),
4542 }
4543 assert_eq!(resources.packages.len(), 2);
4544 assert_eq!(resources.packages[0].root, first);
4545 assert_eq!(resources.packages[1].root, second);
4546 }
4547
4548 #[test]
4549 fn project_relative_package_paths_resolve_from_pi_config_directory() {
4550 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4551 let tmp = tempfile::tempdir().unwrap();
4552 let previous_config = std::env::var_os(config::CONFIG_DIR_ENV);
4553 let isolated_agent = tmp.path().join("agent");
4554 std::fs::create_dir_all(&isolated_agent).unwrap();
4555 std::env::set_var(config::CONFIG_DIR_ENV, &isolated_agent);
4556 let cwd = tmp.path().join("project");
4557 let root = cwd.join(".pi/packages/demo");
4558 std::fs::create_dir_all(root.join("skills")).unwrap();
4559 std::fs::write(root.join("skills/item.md"), "item").unwrap();
4560 std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4561 std::fs::write(
4562 cwd.join(".pi/settings.json"),
4563 r#"{"packages":["./packages/demo"]}"#,
4564 )
4565 .unwrap();
4566 let resources = discover_from_settings(&cwd);
4567 match previous_config {
4568 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4569 None => std::env::remove_var(config::CONFIG_DIR_ENV),
4570 }
4571 assert_eq!(resources.packages.len(), 1);
4572 assert_eq!(resources.packages[0].root, root);
4573 }
4574
4575 #[test]
4576 fn native_git_sources_resolve_only_inside_pi_git_store() {
4577 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4578 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4579 let tmp = tempfile::tempdir().unwrap();
4580 let agent = tmp.path().join("agent");
4581 std::fs::create_dir_all(&agent).unwrap();
4582 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4583 let cwd = tmp.path().join("project");
4584 let root = cwd.join(".pi/git/github.com/example/repo");
4585 std::fs::create_dir_all(root.join(".git")).unwrap();
4586 std::fs::create_dir_all(root.join("skills")).unwrap();
4587 std::fs::write(root.join("skills/item.md"), "item").unwrap();
4588 std::fs::write(root.join("package.json"), r#"{"name":"repo"}"#).unwrap();
4589 std::fs::create_dir_all(cwd.join(".pi")).unwrap();
4590 std::fs::write(
4591 cwd.join(".pi/settings.json"),
4592 r#"{"packages":["git:https://github.com/example/repo.git@main"]}"#,
4593 )
4594 .unwrap();
4595 let resources = discover_from_settings(&cwd);
4596 match previous {
4597 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4598 None => std::env::remove_var(config::CONFIG_DIR_ENV),
4599 }
4600 assert_eq!(resources.packages.len(), 1);
4601 assert_eq!(resources.packages[0].source, PackageSource::Git);
4602 assert_eq!(resources.packages[0].git_revision.as_deref(), Some("main"));
4603 }
4604
4605 #[test]
4606 fn git_sources_preserve_slash_refs_across_supported_transports() {
4607 let cases = [
4608 (
4609 "git:github.com/example/repo@feature/branch",
4610 "github.com",
4611 "example/repo",
4612 Some("feature/branch"),
4613 ),
4614 (
4615 "https://github.com/example/repo.git@feature/branch",
4616 "github.com",
4617 "example/repo",
4618 Some("feature/branch"),
4619 ),
4620 (
4621 "ssh://git@github.com/example/repo@release/v2",
4622 "github.com",
4623 "example/repo",
4624 Some("release/v2"),
4625 ),
4626 (
4627 "git://github.com/example/repo.git@refs/heads/main",
4628 "github.com",
4629 "example/repo",
4630 Some("refs/heads/main"),
4631 ),
4632 (
4633 "git:git@github.com:example/repo@hotfix/security",
4634 "github.com",
4635 "example/repo",
4636 Some("hotfix/security"),
4637 ),
4638 ];
4639 for (spec, host, path, revision) in cases {
4640 let parsed = parse_git_source(spec).unwrap_or_else(|| panic!("spec={spec}"));
4641 assert_eq!(parsed.host, host, "spec={spec}");
4642 assert_eq!(parsed.path, path, "spec={spec}");
4643 assert_eq!(parsed.revision.as_deref(), revision, "spec={spec}");
4644 }
4645 }
4646
4647 #[test]
4648 fn git_source_parser_rejects_encoded_traversal_and_unsafe_refs() {
4649 for spec in [
4650 "git:git@evil.example:../../victim/repo",
4651 "https://evil.example/..%2F..%2Fvictim/repo",
4652 "git:github.com/example/repo@../escape",
4653 "git:github.com/example/repo@-upload-pack=evil",
4654 "git:github.com/example/repo@feature\\branch",
4655 "git:github.com/example/repo@feature%2F..%2Fescape",
4656 ] {
4657 assert!(parse_git_source(spec).is_none(), "spec={spec}");
4658 }
4659 }
4660
4661 #[test]
4662 fn git_source_parser_preserves_remote_transport_authority() {
4663 let shorthand = parse_git_source("git:github.com/example/repo").unwrap();
4664 assert_eq!(shorthand.transport, GitTransport::Https);
4665 assert_eq!(shorthand.port, None);
4666 assert_eq!(shorthand.user_info, None);
4667
4668 let https = parse_git_source("https://token@github.com:8443/example/repo.git").unwrap();
4669 assert_eq!(https.transport, GitTransport::Https);
4670 assert_eq!(https.port, Some(8443));
4671 assert_eq!(https.user_info.as_deref(), Some("token"));
4672
4673 let scp = parse_git_source("git:git@github.com:example/repo").unwrap();
4674 assert_eq!(scp.transport, GitTransport::Ssh);
4675 assert_eq!(scp.port, None);
4676 assert_eq!(scp.user_info.as_deref(), Some("git"));
4677
4678 for invalid in [
4679 "https://github.com:70000/example/repo",
4680 "https://user @github.com/example/repo",
4681 ] {
4682 assert!(parse_git_source(invalid).is_none(), "spec={invalid}");
4683 }
4684 }
4685
4686 #[test]
4687 fn pinned_git_packages_are_selected_for_manual_updates() {
4688 let tmp = tempfile::tempdir().unwrap();
4689 let root = tmp.path().join(".pi/git/github.com/example/repo");
4690 std::fs::create_dir_all(root.join(".git")).unwrap();
4691 std::fs::write(root.join("package.json"), r#"{"name":"repo"}"#).unwrap();
4692 let package = load_package(
4693 root,
4694 "git:github.com/example/repo@feature/branch",
4695 tmp.path(),
4696 ResolveScope::Any,
4697 None,
4698 )
4699 .unwrap();
4700 assert_eq!(package.source, PackageSource::Git);
4701 assert_eq!(package.git_revision.as_deref(), Some("feature/branch"));
4702 assert!(package.updateable_git_source());
4703 }
4704
4705 #[test]
4706 fn missing_npm_update_targets_use_native_managed_roots_and_keep_pins() {
4707 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4708 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4709 let tmp = tempfile::tempdir().unwrap();
4710 let cwd = tmp.path().join("project");
4711 let agent = tmp.path().join("agent");
4712 std::fs::create_dir_all(&cwd).unwrap();
4713 std::fs::create_dir_all(&agent).unwrap();
4714 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4715
4716 let project =
4717 missing_package_for_update(&cwd, "npm:@scope/demo@beta", ResolveScope::Project, None)
4718 .unwrap()
4719 .unwrap();
4720 assert!(project.missing_install);
4721 assert_eq!(project.name, "@scope/demo");
4722 assert_eq!(project.root, cwd.join(".pi/npm/node_modules/@scope/demo"));
4723 assert_eq!(project.npm_install_root, Some(cwd.join(".pi/npm")));
4724 assert_eq!(
4725 project.updateable_npm_source(),
4726 Some(("@scope/demo", "npm:@scope/demo@beta"))
4727 );
4728
4729 let user = missing_package_for_update(&cwd, "npm:demo@1.2.3", ResolveScope::User, None)
4730 .unwrap()
4731 .unwrap();
4732 assert_eq!(user.root, agent.join("npm/node_modules/demo"));
4733 assert_eq!(user.npm_install_root, Some(agent.join("npm")));
4734 assert_eq!(
4735 user.updateable_npm_source(),
4736 Some(("demo", "npm:demo@1.2.3"))
4737 );
4738
4739 match previous {
4740 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4741 None => std::env::remove_var(config::CONFIG_DIR_ENV),
4742 }
4743 }
4744
4745 #[test]
4746 fn missing_git_update_target_preserves_ref_and_scope() {
4747 let _guard = crate::config::test_support::env_lock().lock().unwrap();
4748 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4749 let tmp = tempfile::tempdir().unwrap();
4750 let cwd = tmp.path().join("project");
4751 let agent = tmp.path().join("agent");
4752 std::fs::create_dir_all(&cwd).unwrap();
4753 std::fs::create_dir_all(&agent).unwrap();
4754 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4755
4756 let project = missing_package_for_update(
4757 &cwd,
4758 "git:github.com/example/repo@feature/branch",
4759 ResolveScope::Project,
4760 None,
4761 )
4762 .unwrap()
4763 .unwrap();
4764 assert!(project.missing_install);
4765 assert!(project.updateable_git_source());
4766 assert_eq!(project.name, "repo");
4767 assert_eq!(project.root, cwd.join(".pi/git/github.com/example/repo"));
4768 assert_eq!(project.git_store_root, Some(cwd.join(".pi/git")));
4769 assert_eq!(project.git_revision.as_deref(), Some("feature/branch"));
4770 assert_eq!(
4771 update_recovery_targets(
4772 &cwd,
4773 "git:github.com/example/repo@feature/branch",
4774 ResolveScope::Project,
4775 ),
4776 vec![
4777 cwd.join(".rpi/git/github.com/example/repo"),
4778 cwd.join(".pi/git/github.com/example/repo"),
4779 ]
4780 );
4781
4782 let user = missing_package_for_update(
4783 &cwd,
4784 "https://github.com/example/other.git@release/v2",
4785 ResolveScope::User,
4786 None,
4787 )
4788 .unwrap()
4789 .unwrap();
4790 assert_eq!(user.root, agent.join("git/github.com/example/other"));
4791 assert_eq!(user.git_store_root, Some(agent.join("git")));
4792 assert_eq!(user.git_revision.as_deref(), Some("release/v2"));
4793 let mut recovery_targets = vec![agent.join("git/github.com/example/other")];
4794 if let Some(home) = dirs::home_dir() {
4795 recovery_targets.push(home.join(".pi/agent/git/github.com/example/other"));
4796 }
4797 assert_eq!(
4798 update_recovery_targets(
4799 &cwd,
4800 "https://github.com/example/other.git@release/v2",
4801 ResolveScope::User,
4802 ),
4803 recovery_targets
4804 );
4805
4806 match previous {
4807 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4808 None => std::env::remove_var(config::CONFIG_DIR_ENV),
4809 }
4810 }
4811
4812 #[test]
4813 fn update_discovery_represents_missing_registry_and_git_sources_only() {
4814 let tmp = tempfile::tempdir().unwrap();
4815 let cwd = tmp.path().join("project");
4816 std::fs::create_dir_all(&cwd).unwrap();
4817 let entries = [
4818 "npm:demo@latest",
4819 "npm:fixed@1.2.3",
4820 "git:github.com/example/repo@main",
4821 "file:missing-local",
4822 ]
4823 .into_iter()
4824 .map(|source| crate::settings::PackageSetting::from(source.to_string()))
4825 .collect::<Vec<_>>();
4826
4827 let resources =
4828 discover_with_scope_and_command(&cwd, &entries, ResolveScope::Project, true, None);
4829 assert_eq!(resources.packages.len(), 3);
4830 assert!(resources
4831 .packages
4832 .iter()
4833 .all(|package| package.missing_install));
4834 assert!(resources.diagnostics.is_empty());
4835
4836 let ordinary =
4837 discover_with_scope_and_command(&cwd, &entries, ResolveScope::Project, false, None);
4838 assert!(ordinary.packages.is_empty());
4839 assert_eq!(ordinary.diagnostics.len(), entries.len());
4840 }
4841
4842 #[test]
4843 fn git_metadata_requires_a_real_directory() {
4844 let tmp = tempfile::tempdir().unwrap();
4845 let git_file = tmp.path().join(".git");
4846 std::fs::write(&git_file, "gitdir: ../outside/.git\n").unwrap();
4847 assert!(!is_real_git_metadata(&git_file));
4848 std::fs::remove_file(&git_file).unwrap();
4849 std::fs::create_dir(&git_file).unwrap();
4850 assert!(is_real_git_metadata(&git_file));
4851 }
4852
4853 #[test]
4854 fn marker_source_updates_ranges_and_tags_but_skips_exact_versions() {
4855 let tmp = tempfile::tempdir().unwrap();
4856 let root = tmp.path().join(".rpi/packages/demo");
4857 std::fs::create_dir_all(&root).unwrap();
4858 std::fs::write(
4859 root.join("package.json"),
4860 r#"{"name":"demo","version":"1.0.0"}"#,
4861 )
4862 .unwrap();
4863
4864 let file_spec = format!("file:{}", root.display());
4865 for (source_spec, updateable) in [
4866 ("npm:demo@1.0.0", false),
4867 ("npm:demo@1.0.0-beta.1", false),
4868 ("npm:demo@^1", true),
4869 ("npm:demo@latest", true),
4870 ("npm:demo@beta", true),
4871 ("npm:demo", true),
4872 ] {
4873 write_npm_source_marker(&root, source_spec).unwrap();
4874 let package = load_package(
4875 root.clone(),
4876 &file_spec,
4877 tmp.path(),
4878 ResolveScope::Any,
4879 None,
4880 )
4881 .unwrap();
4882 assert_eq!(
4883 package.updateable_npm_name().is_some(),
4884 updateable,
4885 "source_spec={source_spec}"
4886 );
4887 }
4888 }
4889
4890 #[test]
4891 fn explicit_npm_in_ordinary_node_modules_is_never_updateable() {
4892 let tmp = tempfile::tempdir().unwrap();
4893 let root = tmp.path().join("node_modules/demo");
4894 std::fs::create_dir_all(&root).unwrap();
4895 std::fs::write(
4896 root.join("package.json"),
4897 r#"{"name":"demo","version":"1.0.0"}"#,
4898 )
4899 .unwrap();
4900 write_npm_source_marker(&root, "npm:demo").unwrap();
4901 let package = load_package(root, "npm:demo", tmp.path(), ResolveScope::Any, None).unwrap();
4902 assert_eq!(package.source, PackageSource::Unknown);
4903 assert_eq!(package.updateable_npm_name(), None);
4904 }
4905
4906 #[test]
4907 fn managed_npm_marker_must_match_manifest_name() {
4908 let tmp = tempfile::tempdir().unwrap();
4909 let root = tmp.path().join(".rpi/packages/demo");
4910 std::fs::create_dir_all(&root).unwrap();
4911 std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4912 write_npm_source_marker(&root, "npm:other").unwrap();
4913 let spec = format!("file:{}", root.display());
4914 let package = load_package(root, &spec, tmp.path(), ResolveScope::Any, None).unwrap();
4915 assert_eq!(package.source, PackageSource::Local);
4916 }
4917
4918 #[test]
4919 fn explicit_npm_source_must_match_marker_and_manifest() {
4920 let tmp = tempfile::tempdir().unwrap();
4921 let root = tmp.path().join(".rpi/packages/demo");
4922 std::fs::create_dir_all(&root).unwrap();
4923 std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4924 write_npm_source_marker(&root, "npm:demo@beta").unwrap();
4925
4926 let matching = load_package(
4927 root.clone(),
4928 "npm:demo@beta",
4929 tmp.path(),
4930 ResolveScope::Any,
4931 None,
4932 )
4933 .unwrap();
4934 assert_eq!(
4935 matching.updateable_npm_source(),
4936 Some(("demo", "npm:demo@beta"))
4937 );
4938
4939 let wrong_name = load_package(
4940 root.clone(),
4941 "npm:other@beta",
4942 tmp.path(),
4943 ResolveScope::Any,
4944 None,
4945 )
4946 .unwrap_err();
4947 assert!(wrong_name.contains("manifest name `demo`"), "{wrong_name}");
4948
4949 let wrong_selector =
4950 load_package(root, "npm:demo@^1", tmp.path(), ResolveScope::Any, None).unwrap_err();
4951 assert!(wrong_selector.contains("provenance"), "{wrong_selector}");
4952 }
4953
4954 #[test]
4955 fn managed_npm_alias_marker_target_mismatch_blocks_loading() {
4956 let tmp = tempfile::tempdir().unwrap();
4957 let root = tmp.path().join(".rpi/packages/alias");
4958 std::fs::create_dir_all(&root).unwrap();
4959 std::fs::write(
4960 root.join("package.json"),
4961 r#"{"name":"real","version":"1.2.3"}"#,
4962 )
4963 .unwrap();
4964 write_npm_source_marker(&root, "npm:alias@npm:other@^1").unwrap();
4965
4966 let error = load_package(
4967 root,
4968 "npm:alias@npm:real@^1",
4969 tmp.path(),
4970 ResolveScope::Any,
4971 None,
4972 )
4973 .unwrap_err();
4974
4975 assert!(error.contains("provenance"), "{error}");
4976 }
4977
4978 #[test]
4979 fn explicit_native_npm_without_manifest_identity_is_not_loadable() {
4980 let tmp = tempfile::tempdir().unwrap();
4981 let cwd = tmp.path().join("project");
4982 let root = cwd.join(".pi/npm/node_modules/demo");
4983 std::fs::create_dir_all(root.join("extensions")).unwrap();
4984 std::fs::write(root.join("extensions/index.js"), "export default () => {};").unwrap();
4985 let entries = [crate::settings::PackageSetting::from(
4986 "npm:demo".to_string(),
4987 )];
4988
4989 for manifest in [None, Some(r#"{"version":"1.0.0"}"#)] {
4990 if let Some(manifest) = manifest {
4991 std::fs::write(root.join("package.json"), manifest).unwrap();
4992 }
4993 let resources =
4994 discover_with_scope_and_command(&cwd, &entries, ResolveScope::Project, false, None);
4995 assert!(resources.packages.is_empty(), "manifest={manifest:?}");
4996 assert!(
4997 resources.extension_paths().is_empty(),
4998 "manifest={manifest:?}"
4999 );
5000 assert_eq!(resources.diagnostics.len(), 1, "manifest={manifest:?}");
5001 assert!(
5002 resources.diagnostics[0]
5003 .message
5004 .contains("has no string package name"),
5005 "{}",
5006 resources.diagnostics[0].message
5007 );
5008 }
5009 }
5010
5011 #[test]
5012 fn managed_file_entry_remains_updateable_after_changing_cwd() {
5013 let tmp = tempfile::tempdir().unwrap();
5014 let root = tmp.path().join("project-a/.rpi/packages/demo");
5015 let other_cwd = tmp.path().join("project-b");
5016 std::fs::create_dir_all(&root).unwrap();
5017 std::fs::create_dir_all(&other_cwd).unwrap();
5018 std::fs::write(
5019 root.join("package.json"),
5020 r#"{"name":"demo","version":"1.0.0"}"#,
5021 )
5022 .unwrap();
5023 write_npm_source_marker(&root, "npm:demo@beta").unwrap();
5024 let spec = format!("file:{}", root.display());
5025
5026 let package = load_package(root, &spec, &other_cwd, ResolveScope::User, None).unwrap();
5027 assert_eq!(
5028 package.updateable_npm_source(),
5029 Some(("demo", "npm:demo@beta"))
5030 );
5031 }
5032
5033 #[test]
5034 fn legacy_file_entry_for_managed_git_clone_keeps_git_provenance() {
5035 let tmp = tempfile::tempdir().unwrap();
5036 let root = tmp.path().join(".rpi/packages/demo");
5037 std::fs::create_dir_all(root.join(".git")).unwrap();
5038 std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5039 let spec = format!("file:{}", root.display());
5040 let package = load_package(root, &spec, tmp.path(), ResolveScope::Any, None).unwrap();
5041 assert_eq!(package.source, PackageSource::Git);
5042 }
5043
5044 #[test]
5045 fn native_scoped_npm_root_has_registry_provenance() {
5046 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5047 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5048 let tmp = tempfile::tempdir().unwrap();
5049 let agent = tmp.path().join(".pi/agent");
5050 let root = agent.join("npm/node_modules/@scope/demo");
5051 std::fs::create_dir_all(&root).unwrap();
5052 std::fs::write(
5053 root.join("package.json"),
5054 r#"{"name":"@scope/demo","version":"1.0.0"}"#,
5055 )
5056 .unwrap();
5057 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5058 let package =
5059 load_package(root, "@scope/demo", tmp.path(), ResolveScope::Any, None).unwrap();
5060 match previous {
5061 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5062 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5063 }
5064 assert_eq!(package.updateable_npm_name(), Some("@scope/demo"));
5065 }
5066
5067 #[test]
5068 fn exact_semver_and_npm_name_validation_are_conservative() {
5069 for version in ["1.2.3", "v1.2.3", "1.2.3-beta.1", "1.2.3+build"] {
5070 assert!(is_exact_npm_version(version), "version={version}");
5071 }
5072 for version in ["1", "1.2", "^1.2.3", "latest", "1.2.3-", "1.02.3"] {
5073 assert!(!is_exact_npm_version(version), "version={version}");
5074 }
5075 for name in [
5076 "-rf",
5077 "--workspace",
5078 "@scope/..",
5079 "@scope/a\\b",
5080 "@scope/a?b",
5081 "a b",
5082 "a#b",
5083 ] {
5084 assert!(!valid_npm_name(name), "name={name}");
5085 }
5086 }
5087
5088 #[test]
5089 fn npm_alias_parser_separates_install_slot_from_manifest_name() {
5090 for (spec, install_name, manifest_name, requested, target_selector) in [
5091 ("alias@npm:real", "alias", "real", "npm:real", None),
5092 (
5093 "npm:@scope/alias@npm:real@^1",
5094 "@scope/alias",
5095 "real",
5096 "npm:real@^1",
5097 Some("^1"),
5098 ),
5099 (
5100 "alias@npm:@target/real@beta",
5101 "alias",
5102 "@target/real",
5103 "npm:@target/real@beta",
5104 Some("beta"),
5105 ),
5106 (
5107 "@scope/alias@npm:@target/real@1.2.3",
5108 "@scope/alias",
5109 "@target/real",
5110 "npm:@target/real@1.2.3",
5111 Some("1.2.3"),
5112 ),
5113 ] {
5114 let parsed = parse_npm_package_spec(spec).unwrap_or_else(|| panic!("spec={spec}"));
5115 assert_eq!(parsed.install_name, install_name, "spec={spec}");
5116 assert_eq!(parsed.manifest_name, manifest_name, "spec={spec}");
5117 assert_eq!(parsed.requested.as_deref(), Some(requested), "spec={spec}");
5118 assert_eq!(
5119 parsed.target_selector.as_deref(),
5120 target_selector,
5121 "spec={spec}"
5122 );
5123 assert!(parsed.is_alias, "spec={spec}");
5124 }
5125
5126 for spec in [
5127 "alias@npm:real@npm:other",
5128 "alias@file:../real",
5129 "alias@npm:real@file:../other",
5130 "alias@git:https://example.com/repo.git",
5131 "alias@npm:",
5132 "@scope/alias@npm:@target/real@npm:other",
5133 "alias@npm:real\nlatest",
5134 ] {
5135 assert!(parse_npm_package_spec(spec).is_none(), "spec={spec}");
5136 }
5137 }
5138
5139 #[test]
5140 fn managed_npm_alias_keeps_provenance_and_rejects_manifest_mismatch() {
5141 let tmp = tempfile::tempdir().unwrap();
5142 let root = tmp.path().join(".pi/npm/node_modules/@scope/alias");
5143 std::fs::create_dir_all(&root).unwrap();
5144 std::fs::write(
5145 root.join("package.json"),
5146 r#"{"name":"@target/real","version":"1.2.3"}"#,
5147 )
5148 .unwrap();
5149 let spec = "npm:@scope/alias@npm:@target/real@^1";
5150
5151 let package =
5152 load_package(root.clone(), spec, tmp.path(), ResolveScope::Project, None).unwrap();
5153 assert_eq!(package.name, "@target/real");
5154 assert_eq!(package_identity(&package), "npm:@scope/alias");
5155 assert_eq!(
5156 package.updateable_npm_source(),
5157 Some(("@scope/alias", spec))
5158 );
5159
5160 std::fs::write(
5161 root.join("package.json"),
5162 r#"{"name":"@target/wrong","version":"1.2.3"}"#,
5163 )
5164 .unwrap();
5165 let mismatched =
5166 load_package(root, spec, tmp.path(), ResolveScope::Project, None).unwrap_err();
5167 assert!(
5168 mismatched.contains("manifest name `@target/wrong`"),
5169 "{mismatched}"
5170 );
5171 }
5172
5173 #[test]
5174 fn runtime_npm_alias_matches_target_selector_and_pinning() {
5175 let tmp = tempfile::tempdir().unwrap();
5176 for (slot, target, version, selector, needs_install, pinned) in [
5177 (
5178 "exact-match",
5179 "real-exact-match",
5180 "1.2.3",
5181 "1.2.3",
5182 false,
5183 true,
5184 ),
5185 (
5186 "exact-stale",
5187 "real-exact-stale",
5188 "1.2.4",
5189 "1.2.3",
5190 true,
5191 true,
5192 ),
5193 (
5194 "range-match",
5195 "real-range-match",
5196 "1.9.0",
5197 "^1.2.3",
5198 false,
5199 false,
5200 ),
5201 (
5202 "range-stale",
5203 "real-range-stale",
5204 "2.0.0",
5205 "^1.2.3",
5206 true,
5207 false,
5208 ),
5209 ("tag", "real-tag", "1.0.0", "beta", false, false),
5210 ] {
5211 let root = tmp.path().join(".pi/npm/node_modules").join(slot);
5212 std::fs::create_dir_all(&root).unwrap();
5213 std::fs::write(
5214 root.join("package.json"),
5215 serde_json::to_vec(&serde_json::json!({
5216 "name": target,
5217 "version": version
5218 }))
5219 .unwrap(),
5220 )
5221 .unwrap();
5222 let spec = format!("npm:{slot}@npm:{target}@{selector}");
5223 let package =
5224 load_package(root, &spec, tmp.path(), ResolveScope::Project, None).unwrap();
5225
5226 assert_eq!(
5227 runtime_npm_needs_install(&package),
5228 needs_install,
5229 "spec={spec}"
5230 );
5231 assert_eq!(
5232 matches!(package.source, PackageSource::Npm { pinned: true, .. }),
5233 pinned,
5234 "spec={spec}"
5235 );
5236 assert_eq!(
5237 package.updateable_npm_source().is_some(),
5238 !pinned,
5239 "spec={spec}"
5240 );
5241 }
5242 }
5243
5244 #[test]
5245 fn runtime_npm_version_matching_covers_native_common_ranges() {
5246 for (installed, requested, expected) in [
5247 (Some("1.2.3"), "1.2.3", Some(true)),
5248 (Some("1.2.4"), "1.2.3", Some(false)),
5249 (Some("1.9.0"), "^1.2.3", Some(true)),
5250 (Some("2.0.0"), "^1.2.3", Some(false)),
5251 (Some("1.2.9"), "~1.2.3", Some(true)),
5252 (Some("1.3.0"), "~1.2.3", Some(false)),
5253 (Some("1.2.9"), "1.2", Some(true)),
5254 (Some("1.3.0"), "1.2", Some(false)),
5255 (Some("1.5.0"), ">=1.2.0 <2.0.0", Some(true)),
5256 (Some("1.9.9"), ">= 2.0.0", Some(false)),
5257 (Some("2.0.0"), ">= 2.0.0", Some(true)),
5258 (Some("2.5.0"), ">= 2.0.0 < 3.0.0", Some(true)),
5259 (Some("3.0.0"), ">= 2.0.0 < 3.0.0", Some(false)),
5260 (Some("2.1.0"), "^1 || ^2", Some(true)),
5261 (Some("3.0.0"), "^1 || ^2", Some(false)),
5262 (Some("1.3.9"), "1.2 - 1.3", Some(true)),
5263 (Some("1.4.0"), "1.2 - 1.3", Some(false)),
5264 (Some("2.9.0"), "1 - 2", Some(true)),
5265 (Some("3.0.0"), "1 - 2", Some(false)),
5266 (Some("1.0.0"), "latest", None),
5267 (None, "1.2.3", Some(false)),
5268 ] {
5269 assert_eq!(
5270 npm_version_matches_requirement(installed, requested),
5271 expected,
5272 "installed={installed:?}, requested={requested}"
5273 );
5274 }
5275 }
5276
5277 #[test]
5278 fn runtime_missing_exact_npm_fails_closed_for_invalid_command() {
5279 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5280 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5281 let tmp = tempfile::tempdir().unwrap();
5282 let agent = tmp.path().join("agent");
5283 let cwd = tmp.path().join("project");
5284 std::fs::create_dir_all(&agent).unwrap();
5285 std::fs::create_dir_all(&cwd).unwrap();
5286 std::fs::write(
5287 agent.join("settings.json"),
5288 r#"{"packages":["npm:demo@1.2.3"],"npmCommand":[""]}"#,
5289 )
5290 .unwrap();
5291 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5292
5293 let resources = resolve_from_global_settings(&cwd);
5294
5295 match previous {
5296 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5297 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5298 }
5299 assert!(resources.packages.is_empty());
5300 assert_eq!(resources.diagnostics.len(), 1);
5301 assert!(resources.diagnostics[0]
5302 .message
5303 .contains("invalid npmCommand"));
5304 assert!(!agent.join("npm/node_modules/demo").exists());
5305 }
5306
5307 #[test]
5308 fn offline_runtime_quarantines_mismatched_npm_but_keeps_matching_range() {
5309 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5310 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5311 let tmp = tempfile::tempdir().unwrap();
5312 let agent = tmp.path().join("agent");
5313 let cwd = tmp.path().join("project");
5314 for (name, version) in [("stale", "1.0.0"), ("matching", "1.5.0")] {
5315 let root = agent.join("npm/node_modules").join(name);
5316 std::fs::create_dir_all(root.join("extensions")).unwrap();
5317 std::fs::write(
5318 root.join("package.json"),
5319 serde_json::to_vec(&serde_json::json!({
5320 "name": name,
5321 "version": version
5322 }))
5323 .unwrap(),
5324 )
5325 .unwrap();
5326 std::fs::write(root.join("extensions/index.js"), "export default () => {};").unwrap();
5327 }
5328 std::fs::create_dir_all(&agent).unwrap();
5329 std::fs::create_dir_all(&cwd).unwrap();
5330 std::fs::write(
5331 agent.join("settings.json"),
5332 r#"{
5333 "npmCommand":[""],
5334 "packages":["npm:stale@2.0.0","npm:matching@^1.0.0"]
5335 }"#,
5336 )
5337 .unwrap();
5338 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5339
5340 let resources = resolve_offline_from_global_settings(&cwd);
5341
5342 match previous {
5343 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5344 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5345 }
5346 assert_eq!(resources.packages.len(), 1);
5347 assert_eq!(resources.packages[0].name, "matching");
5348 assert_eq!(resources.diagnostics.len(), 1);
5349 assert_eq!(resources.diagnostics[0].spec, "npm:stale@2.0.0");
5350 assert!(resources.diagnostics[0].message.contains("offline"));
5351 }
5352
5353 #[test]
5354 fn offline_runtime_never_invokes_configured_npm_for_legacy_lookup() {
5355 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5356 let _restore_config = RestoreEnv::capture(config::CONFIG_DIR_ENV);
5357 let tmp = tempfile::tempdir().unwrap();
5358 let agent = tmp.path().join("agent");
5359 let cwd = tmp.path().join("project");
5360 let marker = tmp.path().join("npm-command-ran");
5361 let script = tmp
5362 .path()
5363 .join(if cfg!(windows) { "npm.ps1" } else { "npm.sh" });
5364 let script_body = if cfg!(windows) {
5365 format!(
5366 "Set-Content -LiteralPath '{}' -Value invoked\nexit 0\n",
5367 marker.to_string_lossy().replace('\'', "''")
5368 )
5369 } else {
5370 format!(
5371 "printf invoked > '{}'\nexit 0\n",
5372 marker.to_string_lossy().replace('\'', "'\\''")
5373 )
5374 };
5375 std::fs::create_dir_all(&agent).unwrap();
5376 std::fs::create_dir_all(&cwd).unwrap();
5377 std::fs::write(&script, script_body).unwrap();
5378 let npm_command = if cfg!(windows) {
5379 vec![
5380 "powershell.exe".to_string(),
5381 "-NoProfile".to_string(),
5382 "-NonInteractive".to_string(),
5383 "-File".to_string(),
5384 script.to_string_lossy().into_owned(),
5385 ]
5386 } else {
5387 vec!["sh".to_string(), script.to_string_lossy().into_owned()]
5388 };
5389 std::fs::write(
5390 agent.join("settings.json"),
5391 serde_json::to_vec(&serde_json::json!({
5392 "npmCommand": npm_command,
5393 "packages": ["npm:missing-legacy-package"]
5394 }))
5395 .unwrap(),
5396 )
5397 .unwrap();
5398 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5399
5400 let resources = resolve_offline_from_global_settings(&cwd);
5401
5402 assert!(resources.packages.is_empty());
5403 assert_eq!(resources.diagnostics.len(), 1);
5404 assert!(
5405 !marker.exists(),
5406 "offline package discovery unexpectedly launched npmCommand"
5407 );
5408 }
5409
5410 #[test]
5411 fn runtime_rechecks_version_after_a_noop_package_manager_success() {
5412 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5413 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5414 let tmp = tempfile::tempdir().unwrap();
5415 let agent = tmp.path().join("agent");
5416 let cwd = tmp.path().join("project");
5417 let root = agent.join("npm/node_modules/demo");
5418 std::fs::create_dir_all(&root).unwrap();
5419 std::fs::create_dir_all(&cwd).unwrap();
5420 std::fs::write(
5421 root.join("package.json"),
5422 r#"{"name":"demo","version":"1.0.0"}"#,
5423 )
5424 .unwrap();
5425 let noop_script = tmp
5426 .path()
5427 .join(if cfg!(windows) { "noop.ps1" } else { "noop.sh" });
5428 std::fs::write(&noop_script, "exit 0\n").unwrap();
5429 let command = if cfg!(windows) {
5430 vec![
5431 "powershell.exe",
5432 "-NoProfile",
5433 "-NonInteractive",
5434 "-File",
5435 noop_script.to_str().unwrap(),
5436 ]
5437 } else {
5438 vec!["sh", noop_script.to_str().unwrap()]
5439 };
5440 std::fs::create_dir_all(&agent).unwrap();
5441 std::fs::write(
5442 agent.join("settings.json"),
5443 serde_json::to_vec(&serde_json::json!({
5444 "npmCommand": command,
5445 "packages": ["npm:demo@2.0.0"]
5446 }))
5447 .unwrap(),
5448 )
5449 .unwrap();
5450 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5451
5452 let resources = resolve_from_global_settings(&cwd);
5453
5454 match previous {
5455 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5456 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5457 }
5458 assert!(resources.packages.is_empty());
5459 assert_eq!(resources.diagnostics.len(), 1);
5460 assert!(resources.diagnostics[0]
5461 .message
5462 .contains("still does not satisfy"));
5463 assert_eq!(
5464 serde_json::from_str::<serde_json::Value>(
5465 &std::fs::read_to_string(root.join("package.json")).unwrap()
5466 )
5467 .unwrap()["version"],
5468 "1.0.0"
5469 );
5470 }
5471
5472 #[test]
5473 fn global_npm_spec_cannot_be_shadowed_by_project_store_or_node_modules() {
5474 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5475 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5476 let tmp = tempfile::tempdir().unwrap();
5477 let agent = tmp.path().join("user-agent");
5478 let cwd = tmp.path().join("workspace/project");
5479 let user = agent.join("npm/node_modules/demo");
5480 for root in [&user, &cwd.join(".rpi/packages/demo")] {
5481 std::fs::create_dir_all(root).unwrap();
5482 std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5483 }
5484 let workspace_package = tmp.path().join("workspace/node_modules/demo");
5485 std::fs::create_dir_all(&workspace_package).unwrap();
5486 std::fs::write(workspace_package.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5487 std::fs::create_dir_all(&agent).unwrap();
5488 std::fs::write(agent.join("settings.json"), r#"{"packages":["npm:demo"]}"#).unwrap();
5489 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5490
5491 let resources = discover_from_global_settings(&cwd);
5492 match previous {
5493 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5494 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5495 }
5496 assert_eq!(resources.packages.len(), 1);
5497 assert_eq!(resources.packages[0].root, user);
5498 }
5499
5500 #[test]
5501 fn configured_project_and_user_packages_resolve_in_separate_scopes() {
5502 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5503 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5504 let tmp = tempfile::tempdir().unwrap();
5505 let agent = tmp.path().join("user-agent");
5506 let cwd = tmp.path().join("project");
5507 let project_root = cwd.join(".rpi/packages/demo");
5508 let user_root = agent.join("packages/demo");
5509 for root in [&project_root, &user_root] {
5510 std::fs::create_dir_all(root).unwrap();
5511 std::fs::write(
5512 root.join("package.json"),
5513 r#"{"name":"demo","version":"1.0.0"}"#,
5514 )
5515 .unwrap();
5516 write_npm_source_marker(root, "npm:demo").unwrap();
5517 }
5518 std::fs::create_dir_all(&agent).unwrap();
5519 std::fs::write(agent.join("settings.json"), r#"{"packages":["npm:demo"]}"#).unwrap();
5520 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5521
5522 let user_only = discover_from_settings(&cwd);
5523 assert_eq!(user_only.packages.len(), 1);
5524 assert_eq!(user_only.packages[0].root, user_root);
5525
5526 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
5527 std::fs::write(
5528 cwd.join(".rpi/settings.json"),
5529 r#"{"packages":["npm:demo"]}"#,
5530 )
5531 .unwrap();
5532 let combined = discover_from_settings(&cwd);
5533 assert_eq!(combined.packages.len(), 1);
5534 assert_eq!(combined.packages[0].root, project_root);
5535
5536 match previous {
5537 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5538 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5539 }
5540 }
5541
5542 #[test]
5543 fn update_discovery_keeps_same_identity_in_both_scopes() {
5544 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5545 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5546 let tmp = tempfile::tempdir().unwrap();
5547 let agent = tmp.path().join("agent");
5548 let cwd = tmp.path().join("project");
5549 let project_root = cwd.join(".pi/npm/node_modules/demo");
5550 let user_root = agent.join("npm/node_modules/demo");
5551 for root in [&project_root, &user_root] {
5552 std::fs::create_dir_all(root).unwrap();
5553 std::fs::write(
5554 root.join("package.json"),
5555 r#"{"name":"demo","version":"1.0.0"}"#,
5556 )
5557 .unwrap();
5558 }
5559 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
5560 std::fs::write(
5561 cwd.join(".rpi/settings.json"),
5562 r#"{"packages":["npm:demo@latest"]}"#,
5563 )
5564 .unwrap();
5565 std::fs::create_dir_all(&agent).unwrap();
5566 std::fs::write(
5567 agent.join("settings.json"),
5568 r#"{"packages":["npm:demo@latest"]}"#,
5569 )
5570 .unwrap();
5571 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5572
5573 let resources = discover_from_settings_for_update(&cwd, true).unwrap().0;
5574
5575 match previous {
5576 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5577 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5578 }
5579 assert_eq!(resources.packages.len(), 2);
5580 assert!(resources
5581 .packages
5582 .iter()
5583 .any(|package| package.root == project_root));
5584 assert!(resources
5585 .packages
5586 .iter()
5587 .any(|package| package.root == user_root));
5588 }
5589
5590 #[test]
5591 fn update_discovery_ignores_untrusted_project_settings_but_keeps_user_packages() {
5592 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5593 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5594 let tmp = tempfile::tempdir().unwrap();
5595 let agent = tmp.path().join("agent");
5596 let cwd = tmp.path().join("project");
5597 let user_root = agent.join("packages/user-demo");
5598 let project_root = cwd.join(".rpi/packages/project-demo");
5599 for (root, name) in [(&user_root, "user-demo"), (&project_root, "project-demo")] {
5600 std::fs::create_dir_all(root).unwrap();
5601 std::fs::write(
5602 root.join("package.json"),
5603 serde_json::to_vec(&serde_json::json!({"name": name, "version": "1.0.0"})).unwrap(),
5604 )
5605 .unwrap();
5606 write_npm_source_marker(root, &format!("npm:{name}")).unwrap();
5607 }
5608 std::fs::write(
5609 agent.join("settings.json"),
5610 r#"{"packages":["npm:user-demo"]}"#,
5611 )
5612 .unwrap();
5613 std::fs::write(
5614 cwd.join(".rpi/settings.json"),
5615 r#"{"packages":["npm:project-demo"]}"#,
5616 )
5617 .unwrap();
5618 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5619
5620 let untrusted = discover_from_settings_for_update(&cwd, false).unwrap().0;
5621 let trusted = discover_from_settings_for_update(&cwd, true).unwrap().0;
5622
5623 match previous {
5624 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5625 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5626 }
5627 assert_eq!(untrusted.packages.len(), 1);
5628 assert_eq!(untrusted.packages[0].name, "user-demo");
5629 assert_eq!(trusted.packages.len(), 2);
5630 assert_eq!(trusted.packages[0].name, "project-demo");
5631 assert_eq!(trusted.packages[1].name, "user-demo");
5632 }
5633
5634 #[test]
5635 fn update_discovery_recovers_missing_configured_target_and_cleans_stale_backup() {
5636 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5637 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5638 let tmp = tempfile::tempdir().unwrap();
5639 let agent = tmp.path().join("user-agent");
5640 let cwd = tmp.path().join("project");
5641 let target = cwd.join(".rpi/packages/demo");
5642 let backup = cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000001");
5643 std::fs::create_dir_all(&agent).unwrap();
5644 std::fs::create_dir_all(&backup).unwrap();
5645 std::fs::write(
5646 backup.join("package.json"),
5647 r#"{"name":"demo","version":"1.0.0"}"#,
5648 )
5649 .unwrap();
5650 write_npm_source_marker(&backup, "npm:demo@beta").unwrap();
5651 let spec = format!("file:{}", target.display());
5652 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
5653 std::fs::write(
5654 cwd.join(".rpi/settings.json"),
5655 serde_json::to_vec(&serde_json::json!({ "packages": [spec] })).unwrap(),
5656 )
5657 .unwrap();
5658 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5659
5660 let ordinary = discover_from_settings(&cwd);
5661 assert!(ordinary.packages.is_empty());
5662 assert!(backup.is_dir());
5663 assert!(!target.exists());
5664
5665 let recovered = discover_from_settings_for_update(&cwd, true).unwrap().0;
5666 assert_eq!(recovered.packages.len(), 1);
5667 assert_eq!(recovered.packages[0].root, target);
5668 assert!(!backup.exists());
5669
5670 let stale = cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000002");
5671 std::fs::create_dir_all(&stale).unwrap();
5672 let visible = discover_from_settings_for_update(&cwd, true).unwrap().0;
5673 assert_eq!(visible.packages.len(), 1);
5674 assert!(!stale.exists());
5675
5676 let next_backup =
5677 cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000003");
5678 std::fs::rename(&target, &next_backup).unwrap();
5679 let recovered_again = discover_from_settings_for_update(&cwd, true).unwrap().0;
5680 assert_eq!(recovered_again.packages.len(), 1);
5681 assert!(target.is_dir());
5682 assert!(!next_backup.exists());
5683
5684 match previous {
5685 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5686 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5687 }
5688 }
5689
5690 #[test]
5691 fn update_discovery_recovers_native_project_and_user_npm_targets() {
5692 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5693 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5694 let tmp = tempfile::tempdir().unwrap();
5695 let agent = tmp.path().join("user-agent");
5696 let cwd = tmp.path().join("project");
5697 let project_target = cwd.join(".pi/npm/node_modules/demo");
5698 let project_backup =
5699 cwd.join(".pi/npm/node_modules/.demo.rpi-backup-00000000000000000000000000000011");
5700 let user_target = agent.join("npm/node_modules/@scope/demo");
5701 let user_backup =
5702 agent.join("npm/node_modules/@scope/.demo.rpi-backup-00000000000000000000000000000012");
5703 for (backup, name) in [(&project_backup, "demo"), (&user_backup, "@scope/demo")] {
5704 std::fs::create_dir_all(backup).unwrap();
5705 std::fs::write(
5706 backup.join("package.json"),
5707 serde_json::to_vec(&serde_json::json!({
5708 "name": name,
5709 "version": "1.0.0"
5710 }))
5711 .unwrap(),
5712 )
5713 .unwrap();
5714 }
5715 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
5716 std::fs::write(
5717 cwd.join(".rpi/settings.json"),
5718 r#"{"packages":["npm:demo"]}"#,
5719 )
5720 .unwrap();
5721 std::fs::create_dir_all(&agent).unwrap();
5722 std::fs::write(
5723 agent.join("settings.json"),
5724 r#"{"packages":["npm:@scope/demo"]}"#,
5725 )
5726 .unwrap();
5727 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5728
5729 let ordinary = discover_from_settings(&cwd);
5730 assert!(ordinary.packages.is_empty());
5731 assert!(project_backup.is_dir());
5732 assert!(user_backup.is_dir());
5733
5734 let recovered = discover_from_settings_for_update(&cwd, true).unwrap().0;
5735 assert_eq!(recovered.packages.len(), 2);
5736 assert!(recovered
5737 .packages
5738 .iter()
5739 .any(|package| package.root == project_target));
5740 assert!(recovered
5741 .packages
5742 .iter()
5743 .any(|package| package.root == user_target));
5744 assert!(!project_backup.exists());
5745 assert!(!user_backup.exists());
5746
5747 match previous {
5748 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5749 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5750 }
5751 }
5752
5753 #[test]
5754 fn update_returns_failure_when_recovery_is_ambiguous() {
5755 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5756 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5757 let tmp = tempfile::tempdir().unwrap();
5758 let agent = tmp.path().join("user-agent");
5759 let cwd = tmp.path().join("project");
5760 let target = cwd.join(".rpi/packages/demo");
5761 for suffix in [1_u8, 2] {
5762 let backup = cwd.join(format!(".rpi/packages/.demo.rpi-backup-{suffix:032x}"));
5763 std::fs::create_dir_all(&backup).unwrap();
5764 std::fs::write(backup.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5765 }
5766 let spec = format!("file:{}", target.display());
5767 std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
5768 std::fs::write(
5769 cwd.join(".rpi/settings.json"),
5770 serde_json::to_vec(&serde_json::json!({ "packages": [spec] })).unwrap(),
5771 )
5772 .unwrap();
5773 std::fs::create_dir_all(&agent).unwrap();
5774 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5775
5776 assert_eq!(update_packages(&cwd, true), 1);
5777 assert!(!target.exists());
5778
5779 match previous {
5780 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5781 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5782 }
5783 }
5784
5785 #[test]
5786 fn update_with_malformed_project_settings_performs_no_recovery() {
5787 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5788 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5789 let tmp = tempfile::tempdir().unwrap();
5790 let agent = tmp.path().join("agent");
5791 let cwd = tmp.path().join("project");
5792 let target = cwd.join(".rpi/packages/demo");
5793 let backup = cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000031");
5794 std::fs::create_dir_all(&agent).unwrap();
5795 std::fs::create_dir_all(&backup).unwrap();
5796 std::fs::write(backup.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5797 std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
5798 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5799
5800 assert_eq!(update_packages(&cwd, true), 1);
5801
5802 match previous {
5803 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5804 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5805 }
5806 assert!(backup.is_dir());
5807 assert!(!target.exists());
5808 }
5809
5810 #[test]
5811 fn update_with_corrupt_native_registry_performs_no_ts_recovery() {
5812 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5813 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5814 let tmp = tempfile::tempdir().unwrap();
5815 let agent = tmp.path().join("agent");
5816 let cwd = tmp.path().join("project");
5817 let target = cwd.join(".rpi/packages/demo");
5818 let backup = cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000034");
5819 let metadata = agent.join("native-packages.json");
5820 let original = b"[{broken native metadata";
5821 std::fs::create_dir_all(&agent).unwrap();
5822 std::fs::write(&metadata, original).unwrap();
5823 std::fs::create_dir_all(&backup).unwrap();
5824 std::fs::write(backup.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5825 let spec = format!("file:{}", target.display());
5826 std::fs::write(
5827 cwd.join(".rpi/settings.json"),
5828 serde_json::to_vec(&serde_json::json!({ "packages": [spec] })).unwrap(),
5829 )
5830 .unwrap();
5831 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5832
5833 assert_eq!(update_packages(&cwd, true), 1);
5834
5835 match previous {
5836 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5837 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5838 }
5839 assert_eq!(std::fs::read(metadata).unwrap(), original);
5840 assert!(backup.is_dir());
5841 assert!(!target.exists());
5842 }
5843
5844 #[test]
5845 fn update_with_malformed_global_settings_performs_no_project_recovery() {
5846 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5847 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5848 let tmp = tempfile::tempdir().unwrap();
5849 let agent = tmp.path().join("agent");
5850 let cwd = tmp.path().join("project");
5851 let target = cwd.join(".rpi/packages/demo");
5852 let backup = cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000032");
5853 std::fs::create_dir_all(&agent).unwrap();
5854 std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
5855 std::fs::create_dir_all(&backup).unwrap();
5856 std::fs::write(backup.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5857 std::fs::write(
5858 cwd.join(".rpi/settings.json"),
5859 serde_json::to_vec(&serde_json::json!({
5860 "packages": [format!("file:{}", target.display())]
5861 }))
5862 .unwrap(),
5863 )
5864 .unwrap();
5865 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5866
5867 assert_eq!(update_packages(&cwd, true), 1);
5868
5869 match previous {
5870 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5871 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5872 }
5873 assert!(backup.is_dir());
5874 assert!(!target.exists());
5875 }
5876
5877 #[test]
5878 fn update_with_invalid_npm_command_performs_no_recovery() {
5879 let _guard = crate::config::test_support::env_lock().lock().unwrap();
5880 let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5881 let tmp = tempfile::tempdir().unwrap();
5882 let agent = tmp.path().join("agent");
5883 let cwd = tmp.path().join("project");
5884 let target = cwd.join(".rpi/packages/demo");
5885 let backup = cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000033");
5886 std::fs::create_dir_all(&agent).unwrap();
5887 std::fs::create_dir_all(&backup).unwrap();
5888 std::fs::write(backup.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5889 std::fs::write(
5890 cwd.join(".rpi/settings.json"),
5891 serde_json::to_vec(&serde_json::json!({
5892 "npmCommand": [""],
5893 "packages": [format!("file:{}", target.display())]
5894 }))
5895 .unwrap(),
5896 )
5897 .unwrap();
5898 std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5899
5900 assert_eq!(update_packages(&cwd, true), 1);
5901
5902 match previous {
5903 Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5904 None => std::env::remove_var(config::CONFIG_DIR_ENV),
5905 }
5906 assert!(backup.is_dir());
5907 assert!(!target.exists());
5908 }
5909}