Skip to main content

rpi_cli/
packages.rs

1//! Discovery of Pi-compatible package resources.
2//!
3//! This module resolves package manifests and static resource paths. Extension
4//! paths are handed to the Node bridge by `js_extensions`; the Rust cdylib
5//! loader remains a separate extension mechanism. A package is a directory containing a
6//! `package.json` (or a conventional `skills/`, `prompts/`, `themes/` tree).
7//! The optional `pi`/`rpi` manifest object may override those resource paths.
8
9use 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    /// Original settings entry used to resolve this package.
25    pub spec: String,
26    /// Provenance used by update checks. Managed-directory placement alone is
27    /// not enough to prove that a package came from the npm registry.
28    pub source: PackageSource,
29    /// Native Pi/npm-managed install root when this package lives below an
30    /// owned `npm/node_modules` tree. These packages must be updated through
31    /// the package manager at the root so its manifest and lockfile stay in
32    /// sync; only rpi's standalone package roots may use leaf-directory swaps.
33    npm_install_root: Option<PathBuf>,
34    /// Canonical `node_modules` ancestor that validated a legacy global
35    /// install. This is discovery-only provenance: callers must migrate the
36    /// package into an rpi/native managed root before any update.
37    legacy_npm_root: Option<PathBuf>,
38    /// Project `autoload:false` entries are deltas over a matching user entry.
39    /// Keep the marker so scope merging can retain both sides of the delta.
40    autoload_delta: bool,
41    scope: ResolveScope,
42    git_store_root: Option<PathBuf>,
43    git_revision: Option<String>,
44    /// Configured source whose managed checkout is absent. These records are
45    /// emitted only while planning remediation so the caller can restore the
46    /// installation without exposing nonexistent resources as loadable files.
47    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    /// JavaScript/TypeScript extension entry files discovered from
55    /// `pi.extensions`/`rpi.extensions` or the conventional directory.
56    pub extensions: Vec<PathBuf>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum PackageSource {
61    Npm {
62        name: String,
63        spec: String,
64        /// Configured version, range, tag, or one-level `npm:` alias target.
65        requested: Option<String>,
66        /// Only an exact semantic version is pinned. Ranges and tags update.
67        pinned: bool,
68    },
69    Git,
70    Local,
71    Unknown,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub(crate) struct ParsedNpmPackageSpec {
76    /// Dependency key and on-disk `node_modules` slot.
77    pub(crate) install_name: String,
78    /// Name required in the installed package's manifest. This differs from
79    /// `install_name` only for one-level registry aliases.
80    pub(crate) manifest_name: String,
81    /// Version, range, tag, or the complete `npm:<target>` alias selector.
82    pub(crate) requested: Option<String>,
83    /// Version, range, or tag applied to the package named by `manifest_name`.
84    /// For aliases this strips the outer `npm:<target>` portion so runtime
85    /// compatibility checks compare the installed target version correctly.
86    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
155/// Resolve package specs from the settings file and conventional local roots.
156/// Empty or missing `packages` means no packages are enabled, matching Pi's
157/// explicit package list instead of silently executing every directory found
158/// under the user's home directory.
159pub 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    // Runtime resolution is project-first for an identity collision, but the
288    // update command must reconcile both physical installations. Native Pi's
289    // update() likewise queues global and project settings independently.
290    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
298/// Resolve packages for the explicitly enabled JS/TS runtime. Unlike the
299/// metadata-only discovery helpers, this mirrors native Pi by restoring a
300/// missing npm/Git source and reconciling an installed npm version that no
301/// longer satisfies its configured semver range. Callers must already have
302/// passed the project trust gate before selecting the project variant.
303pub fn resolve_from_settings(cwd: &Path) -> PackageResources {
304    resolve_configured_packages_for_runtime(cwd, true)
305}
306
307/// Runtime resolution restricted to global settings. This is used when the
308/// current project is not trusted, so no project command or storage path is
309/// read or touched.
310pub fn resolve_from_global_settings(cwd: &Path) -> PackageResources {
311    resolve_configured_packages_for_runtime(cwd, false)
312}
313
314/// Offline runtime resolution never invokes npm or Git. Installed npm
315/// packages that do not satisfy their configured version are withheld rather
316/// than executing stale code, matching native Pi's offline missing-source
317/// behavior.
318pub 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    // Store enough identity alongside each operation to prevent a failed or
373    // partially completed package-manager command from exposing the old,
374    // incompatible package to the JS runtime.
375    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
557/// Return `None` for npm tags or range syntax the Rust semver parser cannot
558/// represent. Native Pi does not version-check tags, and treating an unknown
559/// range as satisfied avoids a reinstall loop while retaining exact, caret,
560/// tilde, wildcard, comparator, OR, and hyphen range support.
561fn 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    // Bare partial versions have npm semantics that differ from Cargo's
605    // caret-default syntax. Express their upper bound explicitly.
606    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
630/// Cargo's semver parser requires comma-separated comparators and does not
631/// accept npm's optional whitespace between an operator and its version.
632/// Normalize only a conservative comparator set; tags and other npm-only
633/// syntax continue to return `None` instead of being guessed at.
634fn 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                // Native Pi keeps a project autoload:false entry as a delta
699                // over the matching global package. All other collisions are
700                // project-first (the resources iterator is project then user).
701                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
717/// Resolve only packages declared in the global settings file. Project-local
718/// package declarations are intentionally excluded when the current project
719/// has not been trusted.
720pub 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
728/// Discover packages in settings order. Package resources are intentionally
729/// returned after project and global resources; callers append these paths last
730/// so a package cannot shadow a project-local or user-local resource.
731pub 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                    // Native Pi's manual update ignores local sources. A
827                    // missing local path therefore does not turn an otherwise
828                    // valid package update into a failure.
829                    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
1009/// Validate and load one package spec. Used by `rpi package add` before the
1010/// spec is persisted to settings.
1011pub 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
1017/// Load a theme from an explicit JSON path without discovering configured Pi
1018/// packages. Startup code that has passed the package gate uses
1019/// [`load_theme_with_resources`] to resolve package theme names.
1020pub 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
1024/// Load a package theme from an already-resolved resource set. Startup callers
1025/// use this variant so a disabled package configuration cannot be re-discovered
1026/// indirectly from a TUI theme selector.
1027pub 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
1148/// `rpi package ...` command for managing the enabled Pi package list. This is
1149/// a local package manager; use `install-pi` when the package must be fetched.
1150pub 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
1318/// Top-level `rpi update`: update only installed Rust-native extensions.
1319pub fn run_native_update(args: &[String]) -> i32 {
1320    run_top_level_update(args, UpdateScope::Native)
1321}
1322
1323/// Top-level `rpi pi-update`: update only configured Pi npm/Git packages.
1324pub 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    // Native updates validate their registry before mutating anything. Pi
1437    // package updates independently validate settings before recovering an
1438    // interrupted npm/Git directory swap.
1439    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    // Load every settings document before performing Pi package recovery or
1453    // invoking a package manager. A malformed active file must make the Pi
1454    // update a no-op rather than silently narrowing the requested package set.
1455    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
1713/// Return the native Pi git checkout for a URL only when both the store and
1714/// the checkout are real directories (no symlink/junction traversal) and the
1715/// relative host/path matches exactly. This keeps `git pull` authority inside
1716/// the configured store.
1717fn 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        // A git worktree stores `.git` as a file containing a `gitdir:` pointer.
1817        // Treating that pointer as package metadata could make the update
1818        // command operate on a repository outside the managed store. Only a
1819        // real directory is therefore eligible for automatic updates.
1820        .map(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
1821        .unwrap_or(false)
1822}
1823
1824/// Recognize exactly one npm package below a Pi-managed install root. Lexical
1825/// shape prevents nested dependencies from gaining update authority, while
1826/// the canonical containment check permits pnpm links only when they resolve
1827/// back inside the same install root.
1828fn 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
1907/// Installed project packages are persisted as absolute `file:` entries in
1908/// user settings, so they must remain recognizable after the process changes
1909/// working directory. Canonicalizing first prevents a symlink placed at this
1910/// shape from granting update permission to an arbitrary target directory.
1911fn 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        // Native Pi treats a Git ref as a configured checkout target. Manual
1976        // update reconciles it as well; only automatic update notifications
1977        // skip pinned sources.
1978        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            // rpi's legacy git clones live as direct children of a managed
1984            // package store. Native stores carry an explicit root above.
1985            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    /// Set only when the path came from a validated package-manager global
2033    /// lookup. Static rpi/Pi stores leave this unset.
2034    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    // `npm:` is a package-source prefix, not part of the on-disk package
2069    // name. Keeping it in the candidates makes installed npm packages look
2070    // like directories literally named `npm:...`.
2071    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                // Native Pi resolves project-local package paths from the
2091                // project config directory (`.pi`); rpi's preferred `.rpi`
2092                // directory is accepted first for its own settings.
2093                candidates.push(cwd.join(".rpi").join(&direct));
2094                candidates.push(cwd.join(".pi").join(&direct));
2095                // Keep the historical cwd-relative fallback for callers of
2096                // the public `discover` helper and old rpi settings.
2097                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        // Prefer rpi-owned package stores over native Pi stores and generic
2123        // node_modules when a bare package name resolves in more than one
2124        // place.
2125        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                // Pi's native npm installer keeps packages under
2149                // ~/.pi/agent/npm/node_modules rather than ~/.pi/agent/packages.
2150                // Keep the same layout usable when rpi reads Pi's settings.json.
2151                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                // Keep native Pi's installed package store usable when the user
2158                // has not copied it into the rpi-owned config directory yet.
2159                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    // Native Pi can still load a package installed by the user's global
2191    // package manager. This is a read-only compatibility lookup: the command
2192    // validates and canonicalizes the package path, while update code later
2193    // migrates it into the controlled rpi/native npm store.
2194    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        // Repeat the direct-child/canonical containment check at the package
2199        // boundary. Even a future lookup implementation cannot turn an
2200        // arbitrary command output path into update/delete authority.
2201        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
2227/// Strip an npm version suffix while preserving the `@scope/name` portion.
2228fn 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
2238/// Build the same collision identity native Pi uses: npm package names ignore
2239/// the requested range/tag, git packages use their normalized repository
2240/// identity, and local packages use their canonical path. Manifest names are
2241/// deliberately not used because two independent packages may publish the
2242/// same display name.
2243fn 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    // `git:` is Pi's source prefix, while `git://` is also a valid transport
2290    // URL. Do not strip the latter's scheme accidentally.
2291    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    // Native Pi splits the first `@` in the repository path, not the last
2300    // one. This preserves refs such as `feature/branch` and avoids treating
2301    // URL user-info (`git@host`) as a ref.
2302    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            // Historical `git:github.com/user/repo` shorthand.
2328            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
2370/// Split a git URL into its repository and optional ref. The separator is
2371/// searched only after the URL authority, matching the upstream Pi parser.
2372fn 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    // URL.hostname excludes user-info and a numeric port. Keep the same
2407    // identity semantics while rejecting ambiguous/malformed authorities.
2408    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        // An explicitly empty resource array disables that resource kind in
2669        // native Pi; it is different from an omitted property.
2670        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    // Directory defaults need to expand to individual files before a delta
2762    // can remove one member. If no explicit files were present, start with
2763    // every discovered file, matching the default autoload state.
2764    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        // A legacy lookup grants read-only provenance only after the manifest
3351        // identity check above. Updates still migrate into a managed store.
3352        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    // rpi-specific manifest settings win per resource key; a missing rpi key
3376    // falls back to the original Pi key so partial migrations stay compatible.
3377    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        // A non-truthy value must not silently suppress the same invalid
3902        // registry preflight.
3903        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}