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