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