Skip to main content

pi/core/
package_manager.rs

1//! Extension package management: source parsing, install paths, trust gating,
2//! npm/git/local install/remove/update, and atomic `packages[]` settings edits.
3//!
4//! Ports the install/remove/update/list side of
5//! `.references/pi/packages/coding-agent/src/core/package-manager.ts`. The
6//! resolve-side resource collection lives in
7//! [`crate::core::resources::discovery`]; [`PackageManager::resolve`]
8//! delegates to [`PackagePathResolver`] so the two surfaces never disagree on
9//! managed install paths. Source parsing reuses [`parse_package_source`] for
10//! the same reason.
11//!
12//! # Design
13//!
14//! - [`Runner`] is a narrow, synchronous command-execution seam. [`SystemRunner`]
15//!   spawns real subprocesses with a detached process group, a per-call timeout,
16//!   and process-tree kill + reap on timeout. Tests inject a [`Runner`]
17//!   implementation to assert exact argv and to simulate offline/timeout
18//!   failures without touching the network.
19//! - All network package operations apply [`NETWORK_TIMEOUT_MS`] (10 s) and are
20//!   skipped entirely when `PI_OFFLINE` is enabled.
21//! - Settings edits read the per-scope `packages[]`, compute the next array
22//!   (idempotent normalize / dedupe, project-wins), and persist through
23//!   [`SettingsManager`], whose locked overlay preserves every unknown field.
24
25use std::fs;
26use std::io::Read;
27use std::path::{Component, Path, PathBuf};
28use std::process::{Command, Stdio};
29use std::sync::Mutex;
30use std::time::Duration;
31
32use semver::{Version, VersionReq};
33use serde_json::Value;
34use thiserror::Error;
35
36use crate::core::config::{CONFIG_DIR_NAME, PathInputOptions, resolve_path, resolve_path_with};
37use crate::core::resources::discovery::{
38    PackagePathResolver, PackageResolveError, ParsedSource, ResolvedPaths, parse_package_source,
39    temporary_dir_hash,
40};
41use crate::core::settings::{
42    PackageSource, PackageSourceFilter, SettingsManager, SettingsManagerError,
43};
44
45/// Network package operation timeout in milliseconds (`NETWORK_TIMEOUT_MS`).
46pub const NETWORK_TIMEOUT_MS: u64 = 10_000;
47
48/// Canonical content written to a managed install root `.gitignore`.
49const GITIGNORE_CONTENT: &str = "*\n!.gitignore\n";
50/// Managed npm project `package.json` body.
51const NPM_PROJECT_PACKAGE_JSON: &str = "{\n  \"name\": \"pi-extensions\",\n  \"private\": true\n}";
52
53/// TypeScript `parseSource`: delegate to the shared resolver parser so install
54/// paths and identities match [`PackagePathResolver`] exactly.
55///
56/// This is a free function (not an associated function on [`PackageManager`])
57/// so callers do not have to fix the `Runner` type parameter.
58#[must_use]
59pub fn parse_source(source: &str) -> ParsedSource {
60    parse_package_source(source)
61}
62
63/// Errors raised by [`PackageManager`].
64#[derive(Debug, Error)]
65pub enum PackageManagerError {
66    /// Project-scoped storage was requested while the project is untrusted.
67    #[error("Project is not trusted; refusing to access project package storage")]
68    ProjectNotTrusted,
69    /// A local install source path does not exist on disk.
70    #[error("Path does not exist: {0}")]
71    PathNotFound(String),
72    /// A computed managed path escaped its install root.
73    #[error("Refusing to use path outside package install root: {0}")]
74    PathEscape(String),
75    /// Git install root was missing for a non-temporary scope.
76    #[error("Missing git install root")]
77    MissingGitInstallRoot,
78    /// Configured `npmCommand` had an empty first entry.
79    #[error("Invalid npmCommand: first array entry must be a non-empty command")]
80    InvalidNpmCommand,
81    /// Install source kind is not supported.
82    #[error("Unsupported install source: {0}")]
83    UnsupportedInstallSource(String),
84    /// Remove source kind is not supported.
85    #[error("Unsupported remove source: {0}")]
86    UnsupportedRemoveSource(String),
87    /// No configured package matched an `update` filter.
88    #[error("No matching package found for {0}")]
89    NoMatchingPackage(String),
90    /// No configured package matched an `update` filter, with a suggestion.
91    #[error("No matching package found for {0}. Did you mean {1}?")]
92    NoMatchingPackageWithSuggestion(String, String),
93    /// A subprocess failed, timed out, or could not be spawned.
94    #[error("{0}")]
95    Runner(String),
96    /// The underlying settings manager rejected a project write.
97    #[error(transparent)]
98    Settings(SettingsManagerError),
99    /// A resolve-side path error propagated from [`PackagePathResolver`].
100    #[error(transparent)]
101    Resolve(#[from] PackageResolveError),
102}
103
104impl From<SettingsManagerError> for PackageManagerError {
105    fn from(error: SettingsManagerError) -> Self {
106        match error {
107            SettingsManagerError::ProjectNotTrusted => Self::ProjectNotTrusted,
108            error @ SettingsManagerError::InvalidSetting { .. } => Self::Settings(error),
109        }
110    }
111}
112
113/// Callback invoked for each package operation progress event.
114pub type ProgressCallback = Box<dyn Fn(&ProgressEvent) + Send + Sync>;
115
116/// Installed package scope: global agent directory or project `.pi`.
117///
118/// The temporary/CLI scope is internal to resolve-side discovery and never
119/// appears in the install/remove/update API.
120#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
121pub enum Scope {
122    /// Global agent directory (`~/.pi/agent` or `PI_CODING_AGENT_DIR`).
123    User,
124    /// Project-local (`{cwd}/.pi`), trust-gated.
125    Project,
126}
127
128impl Scope {
129    /// Wire discriminant.
130    #[must_use]
131    pub const fn as_str(self) -> &'static str {
132        match self {
133            Self::User => "user",
134            Self::Project => "project",
135        }
136    }
137}
138
139/// Caller decision when a configured package is missing on disk.
140#[derive(Clone, Copy, Debug, Eq, PartialEq)]
141pub enum MissingSourceAction {
142    /// Install the missing package.
143    Install,
144    /// Leave it missing and skip.
145    Skip,
146    /// Fail resolution with an error.
147    Error,
148}
149
150/// One progress notification emitted during a package operation.
151#[derive(Clone, Debug, Eq, PartialEq)]
152pub struct ProgressEvent {
153    /// Event phase.
154    pub kind: ProgressKind,
155    /// Operation category.
156    pub action: ProgressAction,
157    /// Source string the operation targets.
158    pub source: String,
159    /// Human-readable detail (start message or error text).
160    pub message: Option<String>,
161}
162
163/// Progress event phase (`type` in TypeScript).
164#[derive(Clone, Copy, Debug, Eq, PartialEq)]
165pub enum ProgressKind {
166    /// Operation started.
167    Start,
168    /// Operation completed.
169    Complete,
170    /// Operation failed.
171    Error,
172}
173
174/// Operation category (`action` in TypeScript).
175#[derive(Clone, Copy, Debug, Eq, PartialEq)]
176pub enum ProgressAction {
177    /// `install`.
178    Install,
179    /// `remove`.
180    Remove,
181    /// `update`.
182    Update,
183    /// `pull` (temporary git refresh).
184    Pull,
185}
186
187/// One configured package row returned by [`PackageManager::list_configured_packages`].
188#[derive(Clone, Debug, Eq, PartialEq)]
189pub struct ConfiguredPackage {
190    /// Package source string (`npm:…`, git URL, or local path).
191    pub source: String,
192    /// Scope the package is configured in.
193    pub scope: Scope,
194    /// Whether the entry used the object/filter form.
195    pub filtered: bool,
196    /// Absolute install path when present on disk, else `None`.
197    pub installed_path: Option<PathBuf>,
198}
199
200/// Command execution request handed to a [`Runner`].
201#[derive(Clone, Debug, Eq, PartialEq)]
202pub struct RunRequest {
203    /// Executable name or path.
204    pub command: String,
205    /// Argument vector (already includes any configured `npmCommand` prefix).
206    pub args: Vec<String>,
207    /// Working directory; `None` inherits the process cwd.
208    pub cwd: Option<PathBuf>,
209    /// Per-call timeout in milliseconds; `None` waits forever.
210    pub timeout_ms: Option<u64>,
211    /// Extra environment overrides merged on top of the process environment.
212    pub env: Vec<(String, String)>,
213}
214
215impl RunRequest {
216    /// Build a request with no timeout and no extra env.
217    #[must_use]
218    pub fn new(command: impl Into<String>, args: Vec<String>) -> Self {
219        Self {
220            command: command.into(),
221            args,
222            cwd: None,
223            timeout_ms: None,
224            env: Vec::new(),
225        }
226    }
227
228    /// Set the working directory.
229    #[must_use]
230    pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
231        self.cwd = Some(cwd.into());
232        self
233    }
234
235    /// Set the per-call timeout.
236    #[must_use]
237    pub fn timeout_ms(mut self, timeout_ms: u64) -> Self {
238        self.timeout_ms = Some(timeout_ms);
239        self
240    }
241
242    /// Add one environment override.
243    #[must_use]
244    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
245        self.env.push((key.into(), value.into()));
246        self
247    }
248
249    /// The TypeScript command label used in error messages
250    /// (`{command} {args joined by space}`).
251    #[must_use]
252    pub fn label(&self) -> String {
253        let mut label = self.command.clone();
254        for arg in &self.args {
255            label.push(' ');
256            label.push_str(arg);
257        }
258        label
259    }
260}
261
262/// Errors raised by a [`Runner`].
263#[derive(Debug, Error)]
264pub enum RunError {
265    /// Process exited nonzero, failed to spawn, or was signaled.
266    #[error("{0}")]
267    Failed(String),
268    /// Process did not exit before the requested timeout.
269    #[error("{0}")]
270    TimedOut(String),
271}
272
273/// Narrow, synchronous command-execution seam.
274///
275/// [`SystemRunner`] is the production implementation. Tests inject a fake to
276/// assert exact argv and to simulate offline / timeout behavior without real
277/// subprocesses. Both methods receive the in-flight [`RunRequest`] and apply
278/// the same timeout / kill / reap semantics.
279pub trait Runner: Send + Sync {
280    /// Run with inherited stdout/stderr; error on nonzero exit.
281    ///
282    /// # Errors
283    ///
284    /// Returns [`RunError::Failed`] on a nonzero exit or spawn failure, and
285    /// [`RunError::TimedOut`] when `req.timeout_ms` elapses first.
286    fn run(&self, req: &RunRequest) -> Result<(), RunError>;
287
288    /// Run capturing trimmed stdout; error on nonzero exit.
289    ///
290    /// # Errors
291    ///
292    /// Returns [`RunError::Failed`] (message includes `stderr || stdout`) on a
293    /// nonzero exit or spawn failure, and [`RunError::TimedOut`] on timeout.
294    fn capture(&self, req: &RunRequest) -> Result<String, RunError>;
295}
296
297/// Production [`Runner`] over real subprocesses.
298///
299/// Spawns each command in its own process group (Unix) or with
300/// `CREATE_NO_WINDOW` (Windows), applies the requested timeout, and on timeout
301/// kills the whole process tree then reaps it. stdout/stderr are inherited for
302/// [`Runner::run`] and piped for [`Runner::capture`].
303#[derive(Clone, Copy, Debug, Default)]
304pub struct SystemRunner;
305
306impl Runner for SystemRunner {
307    fn run(&self, req: &RunRequest) -> Result<(), RunError> {
308        let mut cmd = build_command(req);
309        cmd.stdin(Stdio::null());
310        cmd.stdout(Stdio::inherit());
311        cmd.stderr(Stdio::inherit());
312        configure_platform(&mut cmd);
313        let mut child = cmd
314            .spawn()
315            .map_err(|e| RunError::Failed(format!("{}: {e}", req.label())))?;
316        wait_with_timeout(&mut child, req)
317    }
318
319    fn capture(&self, req: &RunRequest) -> Result<String, RunError> {
320        let mut cmd = build_command(req);
321        cmd.stdin(Stdio::null());
322        cmd.stdout(Stdio::piped());
323        cmd.stderr(Stdio::piped());
324        configure_platform(&mut cmd);
325        let mut child = cmd
326            .spawn()
327            .map_err(|e| RunError::Failed(format!("{}: {e}", req.label())))?;
328        let stdout = child.stdout.take();
329        let stderr = child.stderr.take();
330        let stdout_thread = std::thread::spawn(move || read_pipe(stdout));
331        let stderr_thread = std::thread::spawn(move || read_pipe(stderr));
332
333        match wait_with_timeout(&mut child, req) {
334            Ok(()) => {
335                let stdout_out = stdout_thread.join().unwrap_or_default();
336                Ok(stdout_out.trim().to_owned())
337            }
338            Err(RunError::Failed(_)) => {
339                let stdout_out = stdout_thread.join().unwrap_or_default();
340                let stderr_out = stderr_thread.join().unwrap_or_default();
341                let detail = if stderr_out.is_empty() {
342                    stdout_out
343                } else {
344                    stderr_out
345                };
346                Err(RunError::Failed(format!(
347                    "{} failed with: {detail}",
348                    req.label()
349                )))
350            }
351            Err(other) => Err(other),
352        }
353    }
354}
355
356/// Build a [`Command`] from a request, inheriting the process environment and
357/// applying the requested overrides.
358fn build_command(req: &RunRequest) -> Command {
359    let mut cmd = Command::new(&req.command);
360    cmd.args(&req.args);
361    if let Some(cwd) = &req.cwd {
362        cmd.current_dir(cwd);
363    }
364    for (key, value) in &req.env {
365        cmd.env(key, value);
366    }
367    cmd
368}
369
370/// Apply platform-specific spawn flags (process group on Unix,
371/// `CREATE_NO_WINDOW` on Windows).
372fn configure_platform(cmd: &mut Command) {
373    #[cfg(unix)]
374    {
375        use std::os::unix::process::CommandExt as _;
376        cmd.process_group(0);
377    }
378    #[cfg(windows)]
379    {
380        use std::os::windows::process::CommandExt as _;
381        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
382        cmd.creation_flags(CREATE_NO_WINDOW);
383    }
384}
385
386/// Wait for `child` with the request timeout, killing the tree on timeout.
387fn wait_with_timeout(child: &mut std::process::Child, req: &RunRequest) -> Result<(), RunError> {
388    use wait_timeout::ChildExt as _;
389    let Some(timeout_ms) = req.timeout_ms else {
390        let status = child
391            .wait()
392            .map_err(|e| RunError::Failed(format!("{}: {e}", req.label())))?;
393        return exit_status_result(&req.label(), status);
394    };
395    let duration = Duration::from_millis(timeout_ms);
396    match child.wait_timeout(duration) {
397        Ok(Some(status)) => exit_status_result(&req.label(), status),
398        Ok(None) => {
399            kill_process_tree(child.id());
400            let _ = child.wait();
401            Err(RunError::TimedOut(format!(
402                "{} timed out after {timeout_ms}ms",
403                req.label()
404            )))
405        }
406        Err(error) => Err(RunError::Failed(format!("{}: {error}", req.label()))),
407    }
408}
409
410/// Map a finished exit status into a [`RunError`] when nonzero.
411fn exit_status_result(label: &str, status: std::process::ExitStatus) -> Result<(), RunError> {
412    if status.success() {
413        Ok(())
414    } else {
415        let exit_status = match status.code() {
416            Some(code) => format!("code {code}"),
417            None => "signal".to_owned(),
418        };
419        Err(RunError::Failed(format!(
420            "{label} failed with {exit_status}"
421        )))
422    }
423}
424
425/// Read an optional pipe to a string (best-effort).
426fn read_pipe<R: Read>(mut pipe: Option<R>) -> String {
427    let Some(inner) = pipe.as_mut() else {
428        return String::new();
429    };
430    let mut buf = String::new();
431    let _ = inner.read_to_string(&mut buf);
432    buf
433}
434
435/// Kill a process and its descendants (process group on Unix, `taskkill /T` on Windows).
436fn kill_process_tree(pid: u32) {
437    #[cfg(unix)]
438    {
439        use nix::sys::signal::{Signal, kill, killpg};
440        use nix::unistd::Pid;
441        if let Ok(raw) = i32::try_from(pid) {
442            let group = Pid::from_raw(raw);
443            if killpg(group, Signal::SIGKILL).is_err() {
444                let _ = kill(group, Signal::SIGKILL);
445            }
446        }
447    }
448    #[cfg(not(unix))]
449    {
450        let _ = Command::new("taskkill")
451            .args(["/F", "/T", "/PID", &pid.to_string()])
452            .stdin(Stdio::null())
453            .stdout(Stdio::null())
454            .stderr(Stdio::null())
455            .spawn();
456    }
457}
458
459/// Options for constructing a [`PackageManager`].
460#[derive(Clone, Debug)]
461pub struct PackageManagerOptions {
462    /// Project working directory.
463    pub cwd: PathBuf,
464    /// Agent config directory (`~/.pi/agent` or `PI_CODING_AGENT_DIR`).
465    pub agent_dir: PathBuf,
466    /// Optional home directory seam (defaults to the process home).
467    pub home_dir: Option<PathBuf>,
468}
469
470impl PackageManagerOptions {
471    /// Create options from cwd and agent dir.
472    #[must_use]
473    pub fn new(cwd: impl Into<PathBuf>, agent_dir: impl Into<PathBuf>) -> Self {
474        Self {
475            cwd: cwd.into(),
476            agent_dir: agent_dir.into(),
477            home_dir: None,
478        }
479    }
480
481    /// Override the home directory seam.
482    #[must_use]
483    pub fn home_dir(mut self, home_dir: impl Into<PathBuf>) -> Self {
484        self.home_dir = Some(home_dir.into());
485        self
486    }
487}
488
489/// Extension package manager: install, remove, update, list, resolve.
490///
491/// Generic over a [`Runner`] so tests can inject command execution. Production
492/// code uses [`PackageManager`] (which defaults to [`SystemRunner`]).
493pub struct PackageManager<R: Runner = SystemRunner> {
494    cwd: PathBuf,
495    agent_dir: PathBuf,
496    home_dir: Option<PathBuf>,
497    runner: R,
498    progress: Option<ProgressCallback>,
499    /// `Some(force)` overrides `PI_OFFLINE`; `None` reads the env each call.
500    offline: Option<bool>,
501    /// Cached global npm root (`npm root -g` / `bun pm bin -g`), keyed by npmCommand argv.
502    global_npm_root: Mutex<Option<(String, String)>>,
503}
504
505impl PackageManager<SystemRunner> {
506    /// Create a manager backed by the real subprocess runner.
507    #[must_use]
508    pub fn new(options: PackageManagerOptions) -> Self {
509        Self::with_runner(options, SystemRunner)
510    }
511}
512
513impl<R: Runner> PackageManager<R> {
514    /// Create a manager with an injected runner.
515    #[must_use]
516    pub fn with_runner(options: PackageManagerOptions, runner: R) -> Self {
517        let cwd = resolve_path_with(
518            &options.cwd.to_string_lossy(),
519            Path::new("."),
520            path_options(options.home_dir.as_deref()).trim(true),
521        );
522        let agent_dir = resolve_path_with(
523            &options.agent_dir.to_string_lossy(),
524            Path::new("."),
525            path_options(options.home_dir.as_deref()).trim(true),
526        );
527        Self {
528            cwd,
529            agent_dir,
530            home_dir: options.home_dir,
531            runner,
532            progress: None,
533            offline: None,
534            global_npm_root: Mutex::new(None),
535        }
536    }
537
538    /// Force offline mode (`Some(true)`) or online (`Some(false)`), overriding
539    /// `PI_OFFLINE`. `None` (the default) reads the env on each call.
540    #[must_use]
541    pub fn with_offline(mut self, offline: bool) -> Self {
542        self.offline = Some(offline);
543        self
544    }
545
546    /// Install a progress callback (replaces any previous one).
547    pub fn set_progress_callback(&mut self, callback: Option<ProgressCallback>) {
548        self.progress = callback;
549    }
550
551    /// Whether package network operations are skipped.
552    fn is_offline(&self) -> bool {
553        self.offline.unwrap_or_else(pi_offline_enabled)
554    }
555
556    /// Absolute install path for `source` in `scope`, or `None` when absent.
557    ///
558    /// # Errors
559    ///
560    /// Returns [`PackageManagerError::ProjectNotTrusted`] for project scope
561    /// while untrusted, or [`PackageManagerError::Resolve`] on path escape.
562    pub fn get_installed_path(
563        &self,
564        settings: &SettingsManager,
565        source: &str,
566        scope: Scope,
567    ) -> Result<Option<PathBuf>, PackageManagerError> {
568        let parsed = parse_source(source);
569        match parsed {
570            ParsedSource::Npm { name, .. } => {
571                let path = self.npm_install_path(&name, scope, settings)?;
572                Ok(path.exists().then_some(path))
573            }
574            ParsedSource::Git { host, path, .. } => {
575                let path = self.git_install_path(&host, &path, scope)?;
576                Ok(path.exists().then_some(path))
577            }
578            ParsedSource::Local { path } => {
579                let base = self.base_dir_for_scope(scope);
580                let resolved = self.resolve_path_from_base(&path, &base);
581                Ok(resolved.exists().then_some(resolved))
582            }
583        }
584    }
585
586    /// Install `source` into `scope` without recording it in settings.
587    ///
588    /// Local sources only verify the path exists; npm and git run the package
589    /// manager / git clone under [`NETWORK_TIMEOUT_MS`].
590    ///
591    /// # Errors
592    ///
593    /// Returns [`PackageManagerError::ProjectNotTrusted`] for untrusted project
594    /// scope, [`PackageManagerError::PathNotFound`] for a missing local path,
595    /// or [`PackageManagerError::Runner`] on subprocess failure.
596    pub fn install(
597        &self,
598        settings: &SettingsManager,
599        source: &str,
600        scope: Scope,
601    ) -> Result<(), PackageManagerError> {
602        let parsed = parse_source(source);
603        Self::assert_project_trusted(scope, settings)?;
604        let settings_ref = settings;
605        self.with_progress(
606            ProgressAction::Install,
607            source,
608            format!("Installing {source}..."),
609            || match &parsed {
610                ParsedSource::Npm { spec, .. } => {
611                    self.install_npm(settings_ref, spec, scope, false)
612                }
613                ParsedSource::Git { .. } => self.install_git(settings_ref, &parsed, scope),
614                ParsedSource::Local { path } => {
615                    let resolved = self.resolve_path(path);
616                    if resolved.exists() {
617                        Ok(())
618                    } else {
619                        Err(PackageManagerError::PathNotFound(path_string(&resolved)))
620                    }
621                }
622            },
623        )
624    }
625
626    /// Install then persist the source into the scope's `packages[]`.
627    ///
628    /// # Errors
629    ///
630    /// See [`Self::install`] and [`Self::add_source_to_settings`].
631    pub fn install_and_persist(
632        &self,
633        settings: &mut SettingsManager,
634        source: &str,
635        scope: Scope,
636    ) -> Result<(), PackageManagerError> {
637        self.install(settings, source, scope)?;
638        self.add_source_to_settings(settings, source, scope)?;
639        Ok(())
640    }
641
642    /// Remove `source` from `scope` (disk only; settings untouched).
643    ///
644    /// Local sources are a no-op; npm uninstalls and git removes the clone.
645    ///
646    /// # Errors
647    ///
648    /// Returns [`PackageManagerError::ProjectNotTrusted`] for untrusted project
649    /// scope or [`PackageManagerError::Runner`] on subprocess failure.
650    pub fn remove(
651        &self,
652        settings: &SettingsManager,
653        source: &str,
654        scope: Scope,
655    ) -> Result<(), PackageManagerError> {
656        let parsed = parse_source(source);
657        Self::assert_project_trusted(scope, settings)?;
658        let settings_ref = settings;
659        self.with_progress(
660            ProgressAction::Remove,
661            source,
662            format!("Removing {source}..."),
663            || match &parsed {
664                ParsedSource::Npm { .. } => self.uninstall_npm(settings_ref, &parsed, scope),
665                ParsedSource::Git { .. } => self.remove_git(&parsed, scope),
666                ParsedSource::Local { .. } => Ok(()),
667            },
668        )
669    }
670
671    /// Remove from disk then drop the source from `packages[]`.
672    ///
673    /// Returns whether the settings array actually changed.
674    ///
675    /// # Errors
676    ///
677    /// See [`Self::remove`] and [`Self::remove_source_from_settings`].
678    pub fn remove_and_persist(
679        &self,
680        settings: &mut SettingsManager,
681        source: &str,
682        scope: Scope,
683    ) -> Result<bool, PackageManagerError> {
684        self.remove(settings, source, scope)?;
685        self.remove_source_from_settings(settings, source, scope)
686    }
687
688    /// Update one package (`Some`) or every configured package (`None`).
689    ///
690    /// Pinned npm versions are skipped (they are fixed). Pinned git refs are
691    /// reconciled against the configured ref. Offline mode or an empty match
692    /// set is a no-op. A filter that matches nothing is an error.
693    ///
694    /// # Errors
695    ///
696    /// Returns [`PackageManagerError::NoMatchingPackage`] (with a suggestion
697    /// when one exists) for an unmatched filter, or [`PackageManagerError`]
698    /// variants from the underlying install/clone operations.
699    pub fn update_extensions(
700        &self,
701        settings: &SettingsManager,
702        source: Option<&str>,
703    ) -> Result<(), PackageManagerError> {
704        if self.is_offline() {
705            return Ok(());
706        }
707        let global = settings.get_global_settings();
708        let project = settings.get_project_settings();
709        let identity = source.map(|s| self.package_identity(s, None));
710        let mut matched = false;
711        let mut targets: Vec<(String, Scope)> = Vec::new();
712        for pkg in global.packages.clone().unwrap_or_default() {
713            let src = package_source_string(&pkg);
714            if identity
715                .as_ref()
716                .is_some_and(|id| self.package_identity(&src, Some(Scope::User)) != *id)
717            {
718                continue;
719            }
720            matched = true;
721            targets.push((src, Scope::User));
722        }
723        for pkg in project.packages.clone().unwrap_or_default() {
724            let src = package_source_string(&pkg);
725            if identity
726                .as_ref()
727                .is_some_and(|id| self.package_identity(&src, Some(Scope::Project)) != *id)
728            {
729                continue;
730            }
731            matched = true;
732            targets.push((src, Scope::Project));
733        }
734        if source.is_some() && !matched {
735            let configured: Vec<PackageSource> = global
736                .packages
737                .clone()
738                .unwrap_or_default()
739                .into_iter()
740                .chain(project.packages.clone().unwrap_or_default())
741                .collect();
742            return Err(no_matching_package_error(source.unwrap_or(""), &configured));
743        }
744        self.update_configured_targets(settings, &targets)?;
745        Ok(())
746    }
747
748    /// List every configured package across both scopes with its install path.
749    ///
750    /// # Errors
751    ///
752    /// Returns [`PackageManagerError`] only when an install-path lookup hits a
753    /// trust or path-escape failure.
754    pub fn list_configured_packages(
755        &self,
756        settings: &SettingsManager,
757    ) -> Result<Vec<ConfiguredPackage>, PackageManagerError> {
758        let global = settings.get_global_settings();
759        let project = settings.get_project_settings();
760        let mut out = Vec::new();
761        for pkg in global.packages.clone().unwrap_or_default() {
762            let source = package_source_string(&pkg);
763            let filtered = matches!(pkg, PackageSource::Filtered(_));
764            let installed_path = self.get_installed_path(settings, &source, Scope::User)?;
765            out.push(ConfiguredPackage {
766                source,
767                scope: Scope::User,
768                filtered,
769                installed_path,
770            });
771        }
772        for pkg in project.packages.clone().unwrap_or_default() {
773            let source = package_source_string(&pkg);
774            let filtered = matches!(pkg, PackageSource::Filtered(_));
775            let installed_path = self.get_installed_path(settings, &source, Scope::Project)?;
776            out.push(ConfiguredPackage {
777                source,
778                scope: Scope::Project,
779                filtered,
780                installed_path,
781            });
782        }
783        Ok(out)
784    }
785
786    /// Resolve all configured packages and local resources to concrete paths.
787    ///
788    /// Delegates to [`PackagePathResolver::resolve`] so this surface and the
789    /// resolve-side discovery never disagree on managed install locations.
790    /// Missing installs are skipped (no network install here).
791    ///
792    /// # Errors
793    ///
794    /// Propagates [`PackageResolveError`] as [`PackageManagerError::Resolve`].
795    pub fn resolve(
796        &self,
797        settings: &SettingsManager,
798    ) -> Result<ResolvedPaths, PackageManagerError> {
799        let resolver = PackagePathResolver::new(&self.cwd, &self.agent_dir, settings);
800        Ok(resolver.resolve()?)
801    }
802
803    /// Add (or normalize) `source` in `scope`'s `packages[]`.
804    ///
805    /// Returns whether the settings array changed. Adding the same source is
806    /// idempotent (returns `false`); a re-normalized local path returns `true`.
807    ///
808    /// # Errors
809    ///
810    /// Returns [`PackageManagerError::ProjectNotTrusted`] (via the settings
811    /// setter) for untrusted project scope.
812    pub fn add_source_to_settings(
813        &self,
814        settings: &mut SettingsManager,
815        source: &str,
816        scope: Scope,
817    ) -> Result<bool, PackageManagerError> {
818        let current = Self::scope_packages(settings, scope);
819        let normalized = self.normalize_source_for_settings(source, scope);
820        if let Some(index) = self.find_match(&current, source, scope) {
821            let existing = &current[index];
822            if package_source_string(existing) == normalized {
823                return Ok(false);
824            }
825            let mut next = current.clone();
826            next[index] = replace_source(existing, normalized);
827            Self::set_scope_packages(settings, scope, &next)?;
828            return Ok(true);
829        }
830        let mut next = current;
831        next.push(PackageSource::Source(normalized));
832        Self::set_scope_packages(settings, scope, &next)?;
833        Ok(true)
834    }
835
836    /// Remove `source` from `scope`'s `packages[]`.
837    ///
838    /// Returns whether the array changed.
839    ///
840    /// # Errors
841    ///
842    /// Returns [`PackageManagerError::ProjectNotTrusted`] for untrusted project
843    /// scope.
844    pub fn remove_source_from_settings(
845        &self,
846        settings: &mut SettingsManager,
847        source: &str,
848        scope: Scope,
849    ) -> Result<bool, PackageManagerError> {
850        let current = Self::scope_packages(settings, scope);
851        let mut next = Vec::new();
852        for pkg in &current {
853            if !self.sources_match(pkg, source, scope) {
854                next.push(pkg.clone());
855            }
856        }
857        if next.len() == current.len() {
858            return Ok(false);
859        }
860        Self::set_scope_packages(settings, scope, &next)?;
861        Ok(true)
862    }
863
864    // -- internal: progress ---------------------------------------------------
865
866    fn with_progress<F>(
867        &self,
868        action: ProgressAction,
869        source: &str,
870        message: String,
871        op: F,
872    ) -> Result<(), PackageManagerError>
873    where
874        F: FnOnce() -> Result<(), PackageManagerError>,
875    {
876        self.emit(&ProgressEvent {
877            kind: ProgressKind::Start,
878            action,
879            source: source.to_owned(),
880            message: Some(message),
881        });
882        match op() {
883            Ok(()) => {
884                self.emit(&ProgressEvent {
885                    kind: ProgressKind::Complete,
886                    action,
887                    source: source.to_owned(),
888                    message: None,
889                });
890                Ok(())
891            }
892            Err(error) => {
893                self.emit(&ProgressEvent {
894                    kind: ProgressKind::Error,
895                    action,
896                    source: source.to_owned(),
897                    message: Some(error.to_string()),
898                });
899                Err(error)
900            }
901        }
902    }
903
904    fn emit(&self, event: &ProgressEvent) {
905        if let Some(callback) = &self.progress {
906            callback(event);
907        }
908    }
909
910    // -- internal: trust / settings ----------------------------------------
911
912    fn assert_project_trusted(
913        scope: Scope,
914        settings: &SettingsManager,
915    ) -> Result<(), PackageManagerError> {
916        if scope == Scope::Project && !settings.is_project_trusted() {
917            return Err(PackageManagerError::ProjectNotTrusted);
918        }
919        Ok(())
920    }
921
922    fn scope_packages(settings: &SettingsManager, scope: Scope) -> Vec<PackageSource> {
923        match scope {
924            Scope::Project => settings
925                .get_project_settings()
926                .packages
927                .clone()
928                .unwrap_or_default(),
929            Scope::User => settings
930                .get_global_settings()
931                .packages
932                .clone()
933                .unwrap_or_default(),
934        }
935    }
936
937    fn set_scope_packages(
938        settings: &mut SettingsManager,
939        scope: Scope,
940        packages: &[PackageSource],
941    ) -> Result<(), PackageManagerError> {
942        match scope {
943            Scope::Project => settings.set_project_packages(packages)?,
944            Scope::User => settings.set_packages(packages),
945        }
946        Ok(())
947    }
948
949    fn find_match(&self, packages: &[PackageSource], input: &str, scope: Scope) -> Option<usize> {
950        let right = self.source_match_key_for_input(input);
951        for (index, pkg) in packages.iter().enumerate() {
952            let left = self.source_match_key_for_settings(&package_source_string(pkg), scope);
953            if left == right {
954                return Some(index);
955            }
956        }
957        None
958    }
959
960    fn sources_match(&self, existing: &PackageSource, input: &str, scope: Scope) -> bool {
961        self.find_match(std::slice::from_ref(existing), input, scope)
962            .is_some()
963    }
964
965    /// `getPackageIdentity`: identity ignores version/ref. Local identity is
966    /// scope-base-relative when a scope is given.
967    fn package_identity(&self, source: &str, scope: Option<Scope>) -> String {
968        match parse_source(source) {
969            ParsedSource::Npm { name, .. } => format!("npm:{name}"),
970            ParsedSource::Git { host, path, .. } => format!("git:{host}/{path}"),
971            ParsedSource::Local { path } => match scope {
972                Some(scope) => {
973                    let base = self.base_dir_for_scope(scope);
974                    format!(
975                        "local:{}",
976                        path_string(&self.resolve_path_from_base(&path, &base))
977                    )
978                }
979                None => format!("local:{}", path_string(&self.resolve_path(&path))),
980            },
981        }
982    }
983
984    fn source_match_key_for_input(&self, source: &str) -> String {
985        match parse_source(source) {
986            ParsedSource::Npm { name, .. } => format!("npm:{name}"),
987            ParsedSource::Git { host, path, .. } => format!("git:{host}/{path}"),
988            ParsedSource::Local { path } => {
989                format!("local:{}", path_string(&self.resolve_path(&path)))
990            }
991        }
992    }
993
994    fn source_match_key_for_settings(&self, source: &str, scope: Scope) -> String {
995        match parse_source(source) {
996            ParsedSource::Npm { name, .. } => format!("npm:{name}"),
997            ParsedSource::Git { host, path, .. } => format!("git:{host}/{path}"),
998            ParsedSource::Local { path } => {
999                let base = self.base_dir_for_scope(scope);
1000                format!(
1001                    "local:{}",
1002                    path_string(&self.resolve_path_from_base(&path, &base))
1003                )
1004            }
1005        }
1006    }
1007
1008    fn normalize_source_for_settings(&self, source: &str, scope: Scope) -> String {
1009        match parse_source(source) {
1010            ParsedSource::Local { path } => {
1011                let base = self.base_dir_for_scope(scope);
1012                let resolved = self.resolve_path(&path);
1013                let rel = relative_path(&base, &resolved);
1014                rel.to_string_lossy().into_owned()
1015            }
1016            _ => source.to_owned(),
1017        }
1018    }
1019
1020    // -- internal: npm ------------------------------------------------------
1021
1022    fn npm_command(
1023        settings: &SettingsManager,
1024    ) -> Result<(String, Vec<String>), PackageManagerError> {
1025        let configured = settings.get_npm_command().unwrap_or_default();
1026        if configured.is_empty() {
1027            return Ok(("npm".to_owned(), Vec::new()));
1028        }
1029        let mut iter = configured.into_iter();
1030        let command = iter.next().ok_or(PackageManagerError::InvalidNpmCommand)?;
1031        if command.is_empty() {
1032            return Err(PackageManagerError::InvalidNpmCommand);
1033        }
1034        Ok((command, iter.collect()))
1035    }
1036
1037    fn package_manager_name(command: &str, args: &[String]) -> String {
1038        let mut parts = vec![command.to_owned()];
1039        parts.extend(args.iter().cloned());
1040        let pm = match parts.iter().rposition(|p| p == "--") {
1041            Some(idx) => parts.get(idx + 1).cloned().unwrap_or_default(),
1042            None => command.to_owned(),
1043        };
1044        basename_no_exe(&pm)
1045    }
1046
1047    /// `getNpmInstallArgs` dialect (no configured-arg prefix).
1048    fn npm_install_dialect(manager: &str, specs: &[String], install_root: &Path) -> Vec<String> {
1049        match manager {
1050            "bun" => {
1051                let mut out = vec!["install".to_owned()];
1052                out.extend(specs.iter().cloned());
1053                out.push("--cwd".to_owned());
1054                out.push(path_string(install_root));
1055                out.push("--omit=peer".to_owned());
1056                out
1057            }
1058            "pnpm" => {
1059                let mut out = vec!["install".to_owned()];
1060                out.extend(specs.iter().cloned());
1061                out.push("--prefix".to_owned());
1062                out.push(path_string(install_root));
1063                out.push("--config.auto-install-peers=false".to_owned());
1064                out.push("--config.strict-peer-dependencies=false".to_owned());
1065                out.push("--config.strict-dep-builds=false".to_owned());
1066                out
1067            }
1068            _ => {
1069                let mut out = vec!["install".to_owned()];
1070                out.extend(specs.iter().cloned());
1071                out.push("--prefix".to_owned());
1072                out.push(path_string(install_root));
1073                out.push("--legacy-peer-deps".to_owned());
1074                out
1075            }
1076        }
1077    }
1078
1079    /// `uninstallNpm` dialect (no configured-arg prefix).
1080    fn npm_uninstall_dialect(manager: &str, name: &str, install_root: &Path) -> Vec<String> {
1081        match manager {
1082            "bun" => vec![
1083                "uninstall".to_owned(),
1084                name.to_owned(),
1085                "--cwd".to_owned(),
1086                path_string(install_root),
1087            ],
1088            "pnpm" => vec![
1089                "uninstall".to_owned(),
1090                name.to_owned(),
1091                "--prefix".to_owned(),
1092                path_string(install_root),
1093            ],
1094            _ => vec![
1095                "uninstall".to_owned(),
1096                name.to_owned(),
1097                "--prefix".to_owned(),
1098                path_string(install_root),
1099                "--legacy-peer-deps".to_owned(),
1100            ],
1101        }
1102    }
1103
1104    /// `getGitDependencyInstallArgs` dialect.
1105    fn git_dependency_dialect(settings: &SettingsManager) -> Vec<String> {
1106        if settings.get_npm_command().is_some_and(|c| !c.is_empty()) {
1107            vec!["install".to_owned()]
1108        } else {
1109            vec!["install".to_owned(), "--omit=dev".to_owned()]
1110        }
1111    }
1112
1113    /// Run `command prefix… dialect…` (TS `runNpmCommand`).
1114    fn run_npm(
1115        &self,
1116        settings: &SettingsManager,
1117        dialect: Vec<String>,
1118        cwd: Option<&Path>,
1119    ) -> Result<(), PackageManagerError> {
1120        let (command, prefix) = Self::npm_command(settings)?;
1121        let mut full = prefix;
1122        full.extend(dialect);
1123        let mut req = RunRequest::new(command, full).timeout_ms(NETWORK_TIMEOUT_MS);
1124        if let Some(cwd) = cwd {
1125            req = req.cwd(cwd);
1126        }
1127        self.runner.run(&req).map_err(|error| runner_error(&error))
1128    }
1129
1130    fn install_npm(
1131        &self,
1132        settings: &SettingsManager,
1133        spec: &str,
1134        scope: Scope,
1135        temporary: bool,
1136    ) -> Result<(), PackageManagerError> {
1137        let install_root = self.npm_install_root(scope, temporary)?;
1138        Self::ensure_npm_project(&install_root)?;
1139        let (command, prefix) = Self::npm_command(settings)?;
1140        let manager = Self::package_manager_name(&command, &prefix);
1141        let spec_owned = spec.to_owned();
1142        let dialect =
1143            Self::npm_install_dialect(&manager, std::slice::from_ref(&spec_owned), &install_root);
1144        let mut full = prefix;
1145        full.extend(dialect);
1146        self.runner
1147            .run(&RunRequest::new(command, full).timeout_ms(NETWORK_TIMEOUT_MS))
1148            .map_err(|error| runner_error(&error))
1149    }
1150
1151    fn uninstall_npm(
1152        &self,
1153        settings: &SettingsManager,
1154        parsed: &ParsedSource,
1155        scope: Scope,
1156    ) -> Result<(), PackageManagerError> {
1157        let ParsedSource::Npm { name, .. } = parsed else {
1158            return Ok(());
1159        };
1160        let install_root = self.npm_install_root(scope, false)?;
1161        if !install_root.exists() {
1162            return Ok(());
1163        }
1164        let (command, prefix) = Self::npm_command(settings)?;
1165        let manager = Self::package_manager_name(&command, &prefix);
1166        let dialect = Self::npm_uninstall_dialect(&manager, name, &install_root);
1167        let mut full = prefix;
1168        full.extend(dialect);
1169        self.runner
1170            .run(&RunRequest::new(command, full).timeout_ms(NETWORK_TIMEOUT_MS))
1171            .map_err(|error| runner_error(&error))
1172    }
1173
1174    // -- internal: git ------------------------------------------------------
1175
1176    fn install_git(
1177        &self,
1178        settings: &SettingsManager,
1179        parsed: &ParsedSource,
1180        scope: Scope,
1181    ) -> Result<(), PackageManagerError> {
1182        let ParsedSource::Git { repo, ref_name, .. } = parsed else {
1183            return Ok(());
1184        };
1185        let target = self.git_install_path_from_parsed(parsed, scope)?;
1186        if target.exists() {
1187            // Existing clone: reconcile to the configured ref/upstream. Deps are
1188            // reinstalled inside `ensure_git_ref` only when a reset happens.
1189            if let Some(reference) = ref_name {
1190                self.ensure_git_ref(
1191                    settings,
1192                    &target,
1193                    &["fetch".to_owned(), "origin".to_owned(), reference.clone()],
1194                    "FETCH_HEAD",
1195                )?;
1196            } else {
1197                let update_target = self.local_git_update_target(&target);
1198                self.ensure_git_ref(
1199                    settings,
1200                    &target,
1201                    &update_target.fetch_args,
1202                    &update_target.reference,
1203                )?;
1204            }
1205            return Ok(());
1206        }
1207        let root = self.git_install_root(scope);
1208        Self::ensure_git_ignore(&root)?;
1209        if let Some(parent) = target.parent() {
1210            fs::create_dir_all(parent).map_err(|error| io_escape(&error))?;
1211        }
1212        self.runner
1213            .run(
1214                &RunRequest::new(
1215                    "git",
1216                    vec!["clone".to_owned(), repo.clone(), path_string(&target)],
1217                )
1218                .timeout_ms(NETWORK_TIMEOUT_MS),
1219            )
1220            .map_err(|error| runner_error(&error))?;
1221        // Fresh clone checks out the configured ref directly (TS `git checkout`).
1222        if let Some(reference) = ref_name {
1223            self.runner
1224                .run(
1225                    &RunRequest::new("git", vec!["checkout".to_owned(), reference.clone()])
1226                        .cwd(&target)
1227                        .timeout_ms(NETWORK_TIMEOUT_MS),
1228                )
1229                .map_err(|error| runner_error(&error))?;
1230        }
1231        if target.join("package.json").exists() {
1232            self.run_npm(
1233                settings,
1234                Self::git_dependency_dialect(settings),
1235                Some(&target),
1236            )?;
1237        }
1238        Ok(())
1239    }
1240
1241    fn update_git(
1242        &self,
1243        settings: &SettingsManager,
1244        parsed: &ParsedSource,
1245        scope: Scope,
1246    ) -> Result<(), PackageManagerError> {
1247        let target = self.git_install_path_from_parsed(parsed, scope)?;
1248        if !target.exists() {
1249            return self.install_git(settings, parsed, scope);
1250        }
1251        let ParsedSource::Git { ref_name, .. } = parsed else {
1252            return Ok(());
1253        };
1254        // Deps reinstall happens inside `ensure_git_ref` when a reset occurs.
1255        if let Some(reference) = ref_name {
1256            self.ensure_git_ref(
1257                settings,
1258                &target,
1259                &["fetch".to_owned(), "origin".to_owned(), reference.clone()],
1260                "FETCH_HEAD",
1261            )?;
1262        } else {
1263            let update_target = self.local_git_update_target(&target);
1264            self.ensure_git_ref(
1265                settings,
1266                &target,
1267                &update_target.fetch_args,
1268                &update_target.reference,
1269            )?;
1270        }
1271        Ok(())
1272    }
1273
1274    /// `ensureGitRef`: fetch the target ref, and when HEAD differs reset
1275    /// `--hard`, run `git clean -fdx`, and reinstall npm deps so a reconciled
1276    /// extension is pristine (no stale untracked files or `node_modules`).
1277    fn ensure_git_ref(
1278        &self,
1279        settings: &SettingsManager,
1280        target: &Path,
1281        fetch_args: &[String],
1282        reference: &str,
1283    ) -> Result<(), PackageManagerError> {
1284        self.runner
1285            .run(
1286                &RunRequest::new("git", fetch_args.to_vec())
1287                    .cwd(target)
1288                    .timeout_ms(NETWORK_TIMEOUT_MS),
1289            )
1290            .map_err(|error| runner_error(&error))?;
1291        let local_head = self
1292            .runner
1293            .capture(
1294                &RunRequest::new("git", vec!["rev-parse".to_owned(), "HEAD".to_owned()])
1295                    .cwd(target)
1296                    .timeout_ms(NETWORK_TIMEOUT_MS),
1297            )
1298            .map_err(|error| runner_error(&error))?
1299            .trim()
1300            .to_owned();
1301        let commit_ref = format!("{reference}^{{commit}}");
1302        let target_head = self
1303            .runner
1304            .capture(
1305                &RunRequest::new("git", vec!["rev-parse".to_owned(), commit_ref.clone()])
1306                    .cwd(target)
1307                    .timeout_ms(NETWORK_TIMEOUT_MS),
1308            )
1309            .map_err(|error| runner_error(&error))?
1310            .trim()
1311            .to_owned();
1312        if local_head == target_head {
1313            return Ok(());
1314        }
1315        self.runner
1316            .run(
1317                &RunRequest::new(
1318                    "git",
1319                    vec!["reset".to_owned(), "--hard".to_owned(), commit_ref],
1320                )
1321                .cwd(target)
1322                .timeout_ms(NETWORK_TIMEOUT_MS),
1323            )
1324            .map_err(|error| runner_error(&error))?;
1325        // Clean untracked files (extensions should be pristine after a reset).
1326        self.runner
1327            .run(
1328                &RunRequest::new("git", vec!["clean".to_owned(), "-fdx".to_owned()])
1329                    .cwd(target)
1330                    .timeout_ms(NETWORK_TIMEOUT_MS),
1331            )
1332            .map_err(|error| runner_error(&error))?;
1333        if target.join("package.json").exists() {
1334            self.run_npm(
1335                settings,
1336                Self::git_dependency_dialect(settings),
1337                Some(target),
1338            )?;
1339        }
1340        Ok(())
1341    }
1342
1343    fn remove_git(&self, parsed: &ParsedSource, scope: Scope) -> Result<(), PackageManagerError> {
1344        let target = self.git_install_path_from_parsed(parsed, scope)?;
1345        if !target.exists() {
1346            return Ok(());
1347        }
1348        remove_all(&target)?;
1349        let root = self.git_install_root(scope);
1350        Self::prune_empty_git_parents(&target, &root);
1351        Ok(())
1352    }
1353
1354    /// Detect the local upstream tracking target (`getLocalGitUpdateTarget`).
1355    fn local_git_update_target(&self, target: &Path) -> GitUpdateTarget {
1356        let upstream = self.runner.capture(
1357            &RunRequest::new(
1358                "git",
1359                vec![
1360                    "rev-parse".to_owned(),
1361                    "--abbrev-ref".to_owned(),
1362                    "@{upstream}".to_owned(),
1363                ],
1364            )
1365            .cwd(target)
1366            .timeout_ms(NETWORK_TIMEOUT_MS),
1367        );
1368        if let Ok(upstream) = upstream {
1369            let trimmed = upstream.trim();
1370            if let Some(branch) = trimmed.strip_prefix("origin/")
1371                && !branch.is_empty()
1372            {
1373                return GitUpdateTarget {
1374                    reference: "@{upstream}".to_owned(),
1375                    fetch_args: git_fetch_args(branch),
1376                };
1377            }
1378        }
1379        let _ = self.runner.run(
1380            &RunRequest::new(
1381                "git",
1382                vec![
1383                    "remote".to_owned(),
1384                    "set-head".to_owned(),
1385                    "origin".to_owned(),
1386                    "-a".to_owned(),
1387                ],
1388            )
1389            .cwd(target)
1390            .timeout_ms(NETWORK_TIMEOUT_MS),
1391        );
1392        let head_ref = self
1393            .runner
1394            .capture(
1395                &RunRequest::new(
1396                    "git",
1397                    vec![
1398                        "symbolic-ref".to_owned(),
1399                        "refs/remotes/origin/HEAD".to_owned(),
1400                    ],
1401                )
1402                .cwd(target)
1403                .timeout_ms(NETWORK_TIMEOUT_MS),
1404            )
1405            .unwrap_or_default()
1406            .trim()
1407            .trim_start_matches("refs/remotes/origin/")
1408            .to_owned();
1409        if head_ref.is_empty() {
1410            // TS final fallback: fetch HEAD directly into refs/remotes/origin/HEAD.
1411            return GitUpdateTarget {
1412                reference: "origin/HEAD".to_owned(),
1413                fetch_args: git_head_fallback_fetch_args(),
1414            };
1415        }
1416        GitUpdateTarget {
1417            reference: "origin/HEAD".to_owned(),
1418            fetch_args: git_fetch_args(&head_ref),
1419        }
1420    }
1421
1422    // -- internal: update batching -----------------------------------------
1423
1424    fn update_configured_targets(
1425        &self,
1426        settings: &SettingsManager,
1427        targets: &[(String, Scope)],
1428    ) -> Result<(), PackageManagerError> {
1429        if targets.is_empty() {
1430            return Ok(());
1431        }
1432        // Sequential update checks (correctness over TS's bounded concurrency;
1433        // the observable result — every matching package updated — is identical).
1434        for (source, scope) in targets {
1435            let parsed = parse_source(source);
1436            match &parsed {
1437                ParsedSource::Npm {
1438                    spec,
1439                    name,
1440                    version,
1441                    ..
1442                } => {
1443                    if is_exact_npm_version(version.as_deref()) {
1444                        continue;
1445                    }
1446                    if !self.should_update_npm(settings, name, spec, version.as_deref(), *scope)? {
1447                        continue;
1448                    }
1449                    // TS `updateNpmBatch`: unpinned packages install `name@latest`.
1450                    let update_spec = spec_if_version(spec, name, version.as_deref());
1451                    let scope = *scope;
1452                    self.with_progress(
1453                        ProgressAction::Update,
1454                        source,
1455                        format!("Updating {source}..."),
1456                        || self.install_npm(settings, &update_spec, scope, false),
1457                    )?;
1458                }
1459                ParsedSource::Git { .. } => {
1460                    let scope = *scope;
1461                    let parsed = parsed.clone();
1462                    self.with_progress(
1463                        ProgressAction::Update,
1464                        source,
1465                        format!("Updating {source}..."),
1466                        || self.update_git(settings, &parsed, scope),
1467                    )?;
1468                }
1469                ParsedSource::Local { .. } => {}
1470            }
1471        }
1472        Ok(())
1473    }
1474
1475    fn should_update_npm(
1476        &self,
1477        settings: &SettingsManager,
1478        name: &str,
1479        spec: &str,
1480        version: Option<&str>,
1481        scope: Scope,
1482    ) -> Result<bool, PackageManagerError> {
1483        let installed_path = self.managed_npm_install_path(name, scope)?;
1484        let Some(installed_version) = read_installed_version(&installed_path) else {
1485            return Ok(true);
1486        };
1487        // TS `getLatestNpmVersion`: `source.version ? source.spec : source.name`.
1488        let view_spec = if version.is_some() {
1489            spec.to_owned()
1490        } else {
1491            name.to_owned()
1492        };
1493        let target = self.latest_npm_version(settings, view_spec, version)?;
1494        Ok(target.as_deref() != Some(installed_version.as_str()))
1495    }
1496
1497    fn latest_npm_version(
1498        &self,
1499        settings: &SettingsManager,
1500        package_spec: String,
1501        range: Option<&str>,
1502    ) -> Result<Option<String>, PackageManagerError> {
1503        let (command, prefix) = Self::npm_command(settings)?;
1504        let mut args = prefix;
1505        args.push("view".to_owned());
1506        args.push(package_spec);
1507        args.push("version".to_owned());
1508        args.push("--json".to_owned());
1509        let req = RunRequest::new(command, args)
1510            .cwd(&self.cwd)
1511            .timeout_ms(NETWORK_TIMEOUT_MS);
1512        let Ok(raw) = self.runner.capture(&req) else {
1513            return Ok(None);
1514        };
1515        let trimmed = raw.trim();
1516        if trimmed.is_empty() {
1517            return Ok(None);
1518        }
1519        Ok(parse_npm_view_version(trimmed, range))
1520    }
1521
1522    // -- internal: paths ----------------------------------------------------
1523
1524    fn base_dir_for_scope(&self, scope: Scope) -> PathBuf {
1525        match scope {
1526            Scope::Project => self.cwd.join(CONFIG_DIR_NAME),
1527            Scope::User => self.agent_dir.clone(),
1528        }
1529    }
1530
1531    fn npm_install_root(
1532        &self,
1533        scope: Scope,
1534        temporary: bool,
1535    ) -> Result<PathBuf, PackageManagerError> {
1536        if temporary {
1537            return self.temporary_dir("npm", None);
1538        }
1539        match scope {
1540            Scope::Project => Ok(self.cwd.join(CONFIG_DIR_NAME).join("npm")),
1541            Scope::User => Ok(self.agent_dir.join("npm")),
1542        }
1543    }
1544
1545    fn git_install_root(&self, scope: Scope) -> PathBuf {
1546        match scope {
1547            Scope::Project => self.cwd.join(CONFIG_DIR_NAME).join("git"),
1548            Scope::User => self.agent_dir.join("git"),
1549        }
1550    }
1551
1552    fn managed_npm_install_path(
1553        &self,
1554        name: &str,
1555        scope: Scope,
1556    ) -> Result<PathBuf, PackageManagerError> {
1557        Ok(self
1558            .npm_install_root(scope, false)?
1559            .join("node_modules")
1560            .join(name))
1561    }
1562
1563    fn git_install_path(
1564        &self,
1565        host: &str,
1566        path: &str,
1567        scope: Scope,
1568    ) -> Result<PathBuf, PackageManagerError> {
1569        let root = self.git_install_root(scope);
1570        resolve_managed_path(&root, &[host, path])
1571    }
1572
1573    fn git_install_path_from_parsed(
1574        &self,
1575        parsed: &ParsedSource,
1576        scope: Scope,
1577    ) -> Result<PathBuf, PackageManagerError> {
1578        let ParsedSource::Git { host, path, .. } = parsed else {
1579            return Err(PackageManagerError::UnsupportedInstallSource(
1580                "non-git".to_owned(),
1581            ));
1582        };
1583        self.git_install_path(host, path, scope)
1584    }
1585
1586    fn npm_install_path(
1587        &self,
1588        name: &str,
1589        scope: Scope,
1590        settings: &SettingsManager,
1591    ) -> Result<PathBuf, PackageManagerError> {
1592        let managed = self.managed_npm_install_path(name, scope)?;
1593        if scope != Scope::User || managed.exists() {
1594            return Ok(managed);
1595        }
1596        if let Some(legacy) = self.legacy_global_npm_install_path(name, settings)?
1597            && legacy.exists()
1598        {
1599            return Ok(legacy);
1600        }
1601        Ok(managed)
1602    }
1603
1604    fn legacy_global_npm_install_path(
1605        &self,
1606        name: &str,
1607        settings: &SettingsManager,
1608    ) -> Result<Option<PathBuf>, PackageManagerError> {
1609        if let Some(path) = self.pnpm_global_package_path(name, settings)? {
1610            return Ok(Some(path));
1611        }
1612        let root = self.global_npm_root(settings)?;
1613        if root.is_empty() {
1614            return Ok(None);
1615        }
1616        Ok(Some(Path::new(&root).join(name)))
1617    }
1618
1619    fn pnpm_global_package_path(
1620        &self,
1621        name: &str,
1622        settings: &SettingsManager,
1623    ) -> Result<Option<PathBuf>, PackageManagerError> {
1624        let (command, args) = Self::npm_command(settings)?;
1625        if Self::package_manager_name(&command, &args) != "pnpm" {
1626            return Ok(None);
1627        }
1628        let mut full = args;
1629        full.extend([
1630            "list".to_owned(),
1631            "-g".to_owned(),
1632            "--depth".to_owned(),
1633            "0".to_owned(),
1634            "--json".to_owned(),
1635        ]);
1636        let Ok(output) = self
1637            .runner
1638            .capture(&RunRequest::new(command, full).timeout_ms(NETWORK_TIMEOUT_MS))
1639        else {
1640            return Ok(None);
1641        };
1642        Ok(parse_pnpm_global_path(&output, name))
1643    }
1644
1645    fn global_npm_root(&self, settings: &SettingsManager) -> Result<String, PackageManagerError> {
1646        let (command, args) = Self::npm_command(settings)?;
1647        let key = command_key(&command, &args);
1648        if let Some(entry) = self
1649            .global_npm_root
1650            .lock()
1651            .ok()
1652            .and_then(|guard| guard.clone())
1653            && entry.0 == key
1654        {
1655            return Ok(entry.1);
1656        }
1657        let manager = Self::package_manager_name(&command, &args);
1658        let root = if manager == "bun" {
1659            let mut a = args.clone();
1660            a.extend(["pm".to_owned(), "bin".to_owned(), "-g".to_owned()]);
1661            let bin_dir = self
1662                .runner
1663                .capture(&RunRequest::new(command.clone(), a).timeout_ms(NETWORK_TIMEOUT_MS))
1664                .unwrap_or_default();
1665            let parent = Path::new(bin_dir.trim())
1666                .parent()
1667                .map_or_else(|| bin_dir.trim().to_owned(), path_string);
1668            format!(
1669                "{}/install/global/node_modules",
1670                parent.trim_end_matches('/')
1671            )
1672        } else {
1673            let mut a = args.clone();
1674            a.extend(["root".to_owned(), "-g".to_owned()]);
1675            self.runner
1676                .capture(&RunRequest::new(command.clone(), a).timeout_ms(NETWORK_TIMEOUT_MS))
1677                .unwrap_or_default()
1678                .trim()
1679                .to_owned()
1680        };
1681        if let Ok(mut guard) = self.global_npm_root.lock() {
1682            *guard = Some((key, root.clone()));
1683        }
1684        Ok(root)
1685    }
1686
1687    fn temporary_dir(
1688        &self,
1689        prefix: &str,
1690        suffix: Option<&str>,
1691    ) -> Result<PathBuf, PackageManagerError> {
1692        let root = resolve_managed_path(&extension_temp_folder(&self.agent_dir), &[prefix])?;
1693        let suffix_str = suffix.unwrap_or("");
1694        let hash = temporary_dir_hash(prefix, suffix_str);
1695        if suffix_str.is_empty() {
1696            resolve_managed_path(&root, &[&hash])
1697        } else {
1698            resolve_managed_path(&root, &[&hash, suffix_str])
1699        }
1700    }
1701
1702    fn ensure_npm_project(install_root: &Path) -> Result<(), PackageManagerError> {
1703        if !install_root.exists() {
1704            fs::create_dir_all(install_root).map_err(|error| io_escape(&error))?;
1705        }
1706        Self::ensure_git_ignore(install_root)?;
1707        let package_json = install_root.join("package.json");
1708        if !package_json.exists() {
1709            fs::write(&package_json, NPM_PROJECT_PACKAGE_JSON)
1710                .map_err(|error| io_escape(&error))?;
1711        }
1712        Ok(())
1713    }
1714
1715    fn ensure_git_ignore(dir: &Path) -> Result<(), PackageManagerError> {
1716        if !dir.exists() {
1717            fs::create_dir_all(dir).map_err(|error| io_escape(&error))?;
1718        }
1719        let ignore_path = dir.join(".gitignore");
1720        if !ignore_path.exists() {
1721            fs::write(&ignore_path, GITIGNORE_CONTENT).map_err(|error| io_escape(&error))?;
1722        }
1723        Ok(())
1724    }
1725
1726    fn prune_empty_git_parents(target: &Path, install_root: &Path) {
1727        let resolved_root = resolve_path(path_string(install_root));
1728        let mut current = match target.parent() {
1729            Some(parent) => parent.to_path_buf(),
1730            None => return,
1731        };
1732        while current.starts_with(&resolved_root) && current != resolved_root {
1733            if !current.exists() {
1734                current = match current.parent() {
1735                    Some(parent) => parent.to_path_buf(),
1736                    None => return,
1737                };
1738                continue;
1739            }
1740            let is_empty = fs::read_dir(&current).is_ok_and(|mut it| it.next().is_none());
1741            if !is_empty {
1742                break;
1743            }
1744            let _ = fs::remove_dir_all(&current);
1745            current = match current.parent() {
1746                Some(parent) => parent.to_path_buf(),
1747                None => return,
1748            };
1749        }
1750    }
1751
1752    // -- internal: path resolution seams -----------------------------------
1753
1754    fn resolve_path(&self, input: &str) -> PathBuf {
1755        resolve_path_with(
1756            input,
1757            &self.cwd,
1758            path_options(self.home_dir.as_deref()).trim(true),
1759        )
1760    }
1761
1762    fn resolve_path_from_base(&self, input: &str, base: &Path) -> PathBuf {
1763        resolve_path_with(
1764            input,
1765            base,
1766            path_options(self.home_dir.as_deref()).trim(true),
1767        )
1768    }
1769}
1770
1771// ===========================================================================
1772// Free helpers
1773// ===========================================================================
1774
1775/// Extension temp folder (`{agentDir}/tmp/extensions`).
1776fn extension_temp_folder(agent_dir: &Path) -> PathBuf {
1777    agent_dir.join("tmp").join("extensions")
1778}
1779
1780/// Resolve `root/parts…` refusing any component that escapes the root.
1781fn resolve_managed_path(root: &Path, parts: &[&str]) -> Result<PathBuf, PackageManagerError> {
1782    let resolved_root = resolve_path(path_string(root));
1783    let mut resolved = resolved_root.clone();
1784    for part in parts {
1785        for component in Path::new(part).components() {
1786            match component {
1787                Component::Normal(seg) => resolved.push(seg),
1788                Component::CurDir => {}
1789                Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
1790                    return Err(PackageManagerError::PathEscape(path_string(&resolved)));
1791                }
1792            }
1793        }
1794    }
1795    if resolved != resolved_root && !resolved.starts_with(&resolved_root) {
1796        return Err(PackageManagerError::PathEscape(path_string(&resolved)));
1797    }
1798    Ok(resolved)
1799}
1800
1801/// `relative(base, resolved)`, defaulting to `.` when equal (TS `rel || "."`).
1802fn relative_path(base: &Path, resolved: &Path) -> PathBuf {
1803    match resolved.strip_prefix(base) {
1804        Ok(rel) if rel.as_os_str().is_empty() => PathBuf::from("."),
1805        Ok(rel) => rel.to_path_buf(),
1806        Err(_) => resolved.to_path_buf(),
1807    }
1808}
1809
1810fn path_string(path: &Path) -> String {
1811    path.to_string_lossy().into_owned()
1812}
1813
1814fn path_options(home_dir: Option<&Path>) -> PathInputOptions<'_> {
1815    PathInputOptions::new().home_dir(home_dir)
1816}
1817
1818fn basename_no_exe(name: &str) -> String {
1819    let stem = Path::new(name)
1820        .file_stem()
1821        .map_or_else(|| name.to_owned(), |s| s.to_string_lossy().into_owned());
1822    stem.trim_end_matches(".cmd")
1823        .trim_end_matches(".exe")
1824        .to_owned()
1825}
1826
1827fn command_key(command: &str, args: &[String]) -> String {
1828    let mut key = command.to_owned();
1829    for arg in args {
1830        key.push('\0');
1831        key.push_str(arg);
1832    }
1833    key
1834}
1835
1836fn git_fetch_args(branch: &str) -> Vec<String> {
1837    vec![
1838        "fetch".to_owned(),
1839        "--prune".to_owned(),
1840        "--no-tags".to_owned(),
1841        "origin".to_owned(),
1842        format!("+refs/heads/{branch}:refs/remotes/origin/{branch}"),
1843    ]
1844}
1845
1846/// `getLocalGitUpdateTarget` final fallback: fetch HEAD into the origin/HEAD ref.
1847fn git_head_fallback_fetch_args() -> Vec<String> {
1848    vec![
1849        "fetch".to_owned(),
1850        "--prune".to_owned(),
1851        "--no-tags".to_owned(),
1852        "origin".to_owned(),
1853        "+HEAD:refs/remotes/origin/HEAD".to_owned(),
1854    ]
1855}
1856
1857/// `git@{upstream}` resolved fetch/reset target.
1858struct GitUpdateTarget {
1859    reference: String,
1860    fetch_args: Vec<String>,
1861}
1862
1863fn is_exact_npm_version(version: Option<&str>) -> bool {
1864    version.is_some_and(|v| Version::parse(v).is_ok())
1865}
1866
1867fn spec_if_version(spec: &str, name: &str, version: Option<&str>) -> String {
1868    if version.is_some() {
1869        spec.to_owned()
1870    } else {
1871        format!("{name}@latest")
1872    }
1873}
1874
1875fn read_installed_version(install_path: &Path) -> Option<String> {
1876    let package_json = install_path.join("package.json");
1877    let content = fs::read_to_string(package_json).ok()?;
1878    let value: Value = serde_json::from_str(&content).ok()?;
1879    value
1880        .get("version")
1881        .and_then(Value::as_str)
1882        .map(str::to_owned)
1883}
1884
1885fn parse_npm_view_version(raw: &str, range: Option<&str>) -> Option<String> {
1886    let value: Value = serde_json::from_str(raw).ok()?;
1887    if let Some(s) = value.as_str() {
1888        return Some(s.to_owned());
1889    }
1890    let arr = value.as_array()?;
1891    let mut versions: Vec<String> = arr
1892        .iter()
1893        .filter_map(|v| v.as_str().filter(|s| !s.is_empty()).map(str::to_owned))
1894        .collect();
1895    if let Some(range) = range
1896        && let Ok(req) = VersionReq::parse(range)
1897    {
1898        versions.retain(|v| Version::parse(v).is_ok_and(|parsed| req.matches(&parsed)));
1899    }
1900    semantic_max(versions)
1901}
1902
1903/// Select the highest semver from a list, stringifying the result (TS `rcompare`).
1904fn semantic_max(versions: Vec<String>) -> Option<String> {
1905    versions
1906        .into_iter()
1907        .filter_map(|v| Version::parse(&v).ok().map(|parsed| (v, parsed)))
1908        .max_by(|a, b| a.1.cmp(&b.1))
1909        .map(|(v, _)| v)
1910}
1911
1912fn parse_pnpm_global_path(raw: &str, name: &str) -> Option<PathBuf> {
1913    let value: Value = serde_json::from_str(raw).ok()?;
1914    let entries = value.as_array()?;
1915    for entry in entries {
1916        if let Some(deps) = entry.get("dependencies").and_then(Value::as_object)
1917            && let Some(path) = deps
1918                .get(name)
1919                .and_then(|d| d.get("path"))
1920                .and_then(Value::as_str)
1921        {
1922            return Some(PathBuf::from(path));
1923        }
1924    }
1925    None
1926}
1927
1928fn package_source_string(pkg: &PackageSource) -> String {
1929    match pkg {
1930        PackageSource::Source(source) => source.clone(),
1931        PackageSource::Filtered(filter) => filter.source.clone(),
1932    }
1933}
1934
1935fn replace_source(existing: &PackageSource, normalized: String) -> PackageSource {
1936    match existing {
1937        PackageSource::Source(_) => PackageSource::Source(normalized),
1938        PackageSource::Filtered(filter) => PackageSource::Filtered(PackageSourceFilter {
1939            source: normalized,
1940            autoload: filter.autoload,
1941            extensions: filter.extensions.clone(),
1942            skills: filter.skills.clone(),
1943            prompts: filter.prompts.clone(),
1944            themes: filter.themes.clone(),
1945            extra: filter.extra.clone(),
1946        }),
1947    }
1948}
1949
1950fn no_matching_package_error(source: &str, configured: &[PackageSource]) -> PackageManagerError {
1951    match find_suggested_source(source, configured) {
1952        Some(suggestion) => {
1953            PackageManagerError::NoMatchingPackageWithSuggestion(source.to_owned(), suggestion)
1954        }
1955        None => PackageManagerError::NoMatchingPackage(source.to_owned()),
1956    }
1957}
1958
1959fn find_suggested_source(source: &str, configured: &[PackageSource]) -> Option<String> {
1960    let trimmed = source.trim();
1961    for pkg in configured {
1962        let src = package_source_string(pkg);
1963        match parse_source(&src) {
1964            ParsedSource::Npm { name, spec, .. } => {
1965                if trimmed == name || trimmed == spec {
1966                    return Some(src);
1967                }
1968            }
1969            ParsedSource::Git {
1970                host,
1971                path,
1972                ref_name,
1973                ..
1974            } => {
1975                let shorthand = format!("{host}/{path}");
1976                let with_ref = ref_name.as_ref().map(|r| format!("{shorthand}@{r}"));
1977                if trimmed == shorthand || with_ref.is_some_and(|w| trimmed == w) {
1978                    return Some(src);
1979                }
1980            }
1981            ParsedSource::Local { .. } => {}
1982        }
1983    }
1984    None
1985}
1986
1987fn runner_error(error: &RunError) -> PackageManagerError {
1988    PackageManagerError::Runner(error.to_string())
1989}
1990
1991fn io_escape(error: &std::io::Error) -> PackageManagerError {
1992    PackageManagerError::Runner(error.to_string())
1993}
1994
1995fn remove_all(path: &Path) -> Result<(), PackageManagerError> {
1996    if path.is_dir() {
1997        fs::remove_dir_all(path).map_err(|error| io_escape(&error))
1998    } else if path.exists() {
1999        fs::remove_file(path).map_err(|error| io_escape(&error))
2000    } else {
2001        Ok(())
2002    }
2003}
2004
2005fn pi_offline_enabled() -> bool {
2006    match std::env::var("PI_OFFLINE") {
2007        Ok(value) => {
2008            value == "1" || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes")
2009        }
2010        Err(_) => false,
2011    }
2012}
2013
2014#[cfg(test)]
2015mod tests;