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