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