Skip to main content

yapi_core/packages/
mod.rs

1//! Packages: npm, git and local sources that bring extensions, skills,
2//! prompt templates and themes. Port of pi's package manager
3//! (`core/package-manager.ts` in pi `v1.0.0`), with a built-in npm client.
4//!
5//! Packages live under the scope's directory: `<agent>/npm` and
6//! `<agent>/git/<host>/<path>` for the user, `<cwd>/.yapi/...` for a project.
7//! Settings list them in `packages`.
8
9pub mod npm;
10pub mod resolve;
11pub mod source;
12
13use std::ops::ControlFlow;
14use std::path::{Path, PathBuf};
15
16use aws_lc_rs::digest::{SHA256, digest};
17use futures_util::StreamExt as _;
18use futures_util::future::Either;
19use serde_json::Value;
20use yapi_types::settings::FilteredPackage;
21
22use crate::config::PROJECT_DIR;
23use crate::settings::{Scope, SettingsError, SettingsManager};
24pub use source::{Source, parse};
25
26/// Why a package operation failed, with pi's message where pi has one.
27#[derive(Debug, thiserror::Error)]
28pub enum PackageError {
29    /// A failure with pi's message.
30    #[error("{0}")]
31    Message(String),
32    /// Project settings cannot change while the project is untrusted.
33    #[error("Project is not trusted. Use --approve to modify local package config.")]
34    Untrusted,
35    /// The npm client failed.
36    #[error(transparent)]
37    Npm(#[from] npm::NpmError),
38    /// Settings could not be written.
39    #[error(transparent)]
40    Settings(#[from] SettingsError),
41}
42
43/// A configured package, as `list` shows it.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct Configured {
46    /// The source as written in settings.
47    pub source: String,
48    /// Whose settings list it.
49    pub scope: Scope,
50    /// Whether the entry filters the package's resources.
51    pub filtered: bool,
52    /// Where it is installed, when it is.
53    pub installed_path: Option<PathBuf>,
54    /// Its extension entry files after the entry's filters. Empty when it
55    /// is not installed.
56    pub extensions: Vec<PathBuf>,
57}
58
59/// Where `source`, written in settings whose directory is `base`, is or
60/// would be installed.
61pub fn install_location(source: &Source, base: &Path) -> PathBuf {
62    match source {
63        Source::Npm { name, .. } => base.join("npm").join("node_modules").join(name),
64        Source::Git { host, path, .. } => base.join("git").join(host).join(path),
65        Source::Local { path } => source::local_path(path, base),
66    }
67}
68
69/// pi's `DefaultPackageManager.resolve` for the settings of the project in
70/// `project_root` and of `agent_dir`: every resource of installed packages,
71/// settings entries and the discovered directories, each enabled or not,
72/// with `builtins` as the built-in extensions. Packages that are not
73/// installed are left out. `extra` packages, such as chest's, follow the
74/// settings' packages, and `skills` are skill folders placed for them.
75pub fn resolve_resources(
76    project_root: &Path,
77    agent_dir: &Path,
78    settings: &SettingsManager,
79    builtins: &[&str],
80    extra: &[resolve::PackageInput],
81    skills: &[resolve::PackageInput],
82) -> resolve::ResolvedPaths {
83    let project_dir = project_root.join(PROJECT_DIR);
84    let base = |scope: &str| {
85        if scope == "project" {
86            project_dir.clone()
87        } else {
88            agent_dir.to_path_buf()
89        }
90    };
91    let mut packages = resolve::package_inputs(
92        &settings_packages(settings, Scope::Project),
93        &settings_packages(settings, Scope::Global),
94        base,
95        |source, scope| install_location(source, &base(scope)),
96    );
97    packages.extend(extra.iter().cloned());
98    let user = resolve::settings_lists(settings.document(Scope::Global));
99    let project = resolve::settings_lists(settings.document(Scope::Project));
100    resolve::resolve(&resolve::ResolveInput {
101        cwd: project_root.to_path_buf(),
102        agent_dir: agent_dir.to_path_buf(),
103        project_dir: project_dir.clone(),
104        home: crate::tools::path::home_dir(),
105        project_trusted: settings.project_trusted(),
106        packages,
107        skills: skills.to_vec(),
108        user: [&user[0], &user[1], &user[2], &user[3]],
109        project: [&project[0], &project[1], &project[2], &project[3]],
110        builtins,
111    })
112}
113
114/// The `packages` entries of `scope`'s settings.
115pub fn settings_packages(settings: &SettingsManager, scope: Scope) -> Vec<Value> {
116    settings
117        .document(scope)
118        .get("packages")
119        .and_then(Value::as_array)
120        .cloned()
121        .unwrap_or_default()
122}
123
124/// The directory a scope's packages and relative settings paths use: the
125/// agent directory for the user, the project's config directory otherwise.
126pub fn scope_dir(scope: Scope, cwd: &Path, agent_dir: &Path) -> PathBuf {
127    match scope {
128        Scope::Global => agent_dir.to_path_buf(),
129        Scope::Project => cwd.join(PROJECT_DIR),
130    }
131}
132
133/// A settings entry: a source string or a filtered package.
134pub fn entry_source(entry: &Value) -> Option<&str> {
135    entry.as_str().or_else(|| entry["source"].as_str())
136}
137
138/// Installs, removes and resolves packages for a working directory.
139pub struct PackageManager {
140    cwd: PathBuf,
141    agent_dir: PathBuf,
142    settings: SettingsManager,
143    npm: npm::Npm,
144    progress: Box<dyn Fn(&str) + Send + Sync>,
145}
146
147impl std::fmt::Debug for PackageManager {
148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149        f.debug_struct("PackageManager")
150            .field("cwd", &self.cwd)
151            .finish_non_exhaustive()
152    }
153}
154
155/// The standard output of `git` with `args`, run in `dir` (else the process
156/// directory) without a terminal prompt.
157pub(crate) async fn run_git(args: &[&str], dir: Option<&Path>) -> Result<String, PackageError> {
158    run_git_with(args, dir, &[]).await
159}
160
161/// [`run_git`] with `env` set on top of what git normally gets.
162pub(crate) async fn run_git_with(
163    args: &[&str],
164    dir: Option<&Path>,
165    env: &[(&str, &str)],
166) -> Result<String, PackageError> {
167    let mut command = tokio::process::Command::new("git");
168    command
169        .args(args)
170        .env("GIT_TERMINAL_PROMPT", "0")
171        .envs(crate::config::child_env())
172        .envs(env.iter().copied())
173        .stdin(std::process::Stdio::null())
174        .kill_on_drop(true);
175    if let Some(dir) = dir {
176        command.current_dir(dir);
177    }
178    let output = command
179        .output()
180        .await
181        .map_err(|err| PackageError::Message(format!("git {}: {err}", args[0])))?;
182    if output.status.success() {
183        return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
184    }
185    Err(PackageError::Message(format!(
186        "git {} failed with code {}: {}",
187        args[0],
188        output.status.code().unwrap_or(-1),
189        String::from_utf8_lossy(&output.stderr).trim()
190    )))
191}
192
193/// The scope that [`resolve::dedupe_packages`] and
194/// [`resolve::locate_packages`] name `project` or `user`.
195fn named_scope(name: &str) -> Scope {
196    if name == "project" {
197        Scope::Project
198    } else {
199        Scope::Global
200    }
201}
202
203/// pi's `NETWORK_TIMEOUT_MS`, for registry lookups and git remotes.
204const NETWORK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
205
206/// pi's `UPDATE_CHECK_CONCURRENCY`: how many update checks run at once.
207const UPDATE_CHECK_CONCURRENCY: usize = 4;
208
209/// The standard output of `command`, given up after pi's network timeout.
210/// `None` when it fails.
211async fn command_output(mut command: tokio::process::Command) -> Option<String> {
212    command
213        .envs(crate::config::child_env())
214        .stdin(std::process::Stdio::null())
215        .kill_on_drop(true);
216    let output = tokio::time::timeout(NETWORK_TIMEOUT, command.output())
217        .await
218        .ok()?
219        .ok()?;
220    output
221        .status
222        .success()
223        .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
224}
225
226/// The output of `git` with `args` in `dir`, given up after pi's network
227/// timeout. `None` when it fails.
228async fn git_output(args: &[&str], dir: &Path) -> Option<String> {
229    tokio::time::timeout(NETWORK_TIMEOUT, run_git(args, Some(dir)))
230        .await
231        .ok()?
232        .ok()
233}
234
235/// pi's `getLatestNpmVersion` for npm package `name` from `version`: what
236/// `npm view` reports through the `npmCommand` setting when it is set, else
237/// the built-in registry client's answer, given up after pi's network
238/// timeout.
239async fn latest_npm_version(
240    npm: &mut npm::Npm,
241    command: Option<&[String]>,
242    cwd: &Path,
243    name: &str,
244    version: Option<&str>,
245) -> Option<String> {
246    let Some((program, args)) = command.and_then(<[String]>::split_first) else {
247        let lookup = npm.latest(name, version.unwrap_or("latest"));
248        return tokio::time::timeout(NETWORK_TIMEOUT, lookup)
249            .await
250            .ok()?
251            .ok();
252    };
253    let spec = version.map_or_else(|| name.to_owned(), |version| format!("{name}@{version}"));
254    let mut view = tokio::process::Command::new(program);
255    view.args(args)
256        .args(["view", &spec, "version", "--json"])
257        .current_dir(cwd);
258    let output = command_output(view).await?;
259    npm::viewed_version(&output, version.filter(|version| npm::is_range(version)))
260}
261
262/// pi's `gitHasAvailableUpdate`: whether the remote commit of the checkout
263/// in `dir`'s upstream branch, else of the remote's `HEAD`, differs from the
264/// checkout's. `false` when git cannot tell.
265async fn git_newer(dir: &Path) -> bool {
266    let Some(local) = git_output(&["rev-parse", "HEAD"], dir).await else {
267        return false;
268    };
269    let branch = git_output(&["rev-parse", "--abbrev-ref", "@{upstream}"], dir)
270        .await
271        .and_then(|upstream| {
272            let branch = upstream.trim().strip_prefix("origin/")?;
273            (!branch.is_empty()).then(|| format!("refs/heads/{branch}"))
274        });
275    let commit = |listing: &str, reference: Option<&str>| {
276        listing.lines().find_map(|line| {
277            let (commit, name) = line.split_once(char::is_whitespace)?;
278            let named = reference.is_none_or(|reference| name.trim() == reference);
279            (commit.len() == 40 && commit.bytes().all(|byte| byte.is_ascii_hexdigit()) && named)
280                .then(|| commit.to_owned())
281        })
282    };
283    let mut remote = None;
284    if let Some(branch) = branch
285        && let Some(listing) = git_output(&["ls-remote", "origin", &branch], dir).await
286    {
287        remote = commit(&listing, None);
288    }
289    if remote.is_none()
290        && let Some(listing) = git_output(&["ls-remote", "origin", "HEAD"], dir).await
291    {
292        remote = commit(&listing, Some("HEAD"));
293    }
294    remote.is_some_and(|remote| remote != local.trim())
295}
296
297/// pi's `resolveManagedPath`: `parts` resolved against `root`, which it
298/// must not leave.
299fn managed_path(root: &Path, parts: &Path) -> Result<PathBuf, PackageError> {
300    let path = crate::tools::path::resolve_lexically(root, parts);
301    if path.starts_with(root) {
302        Ok(path)
303    } else {
304        Err(PackageError::Message(format!(
305            "Refusing to use path outside package install root: {}",
306            path.display()
307        )))
308    }
309}
310
311impl PackageManager {
312    /// A manager for `cwd`, reading and writing `settings`, with npm's
313    /// configuration `npm_config`, as [`npm::config`] reads it.
314    pub fn new(
315        cwd: PathBuf,
316        agent_dir: PathBuf,
317        settings: SettingsManager,
318        npm_config: std::collections::HashMap<String, String>,
319    ) -> PackageManager {
320        PackageManager {
321            cwd,
322            agent_dir,
323            settings,
324            npm: npm::Npm::new(npm_config),
325            progress: Box::new(|_| {}),
326        }
327    }
328
329    /// Reports the start of each install, removal and update.
330    pub fn on_progress(&mut self, progress: impl Fn(&str) + Send + Sync + 'static) {
331        self.progress = Box::new(progress);
332    }
333
334    /// The settings it reads and writes.
335    pub fn settings(&self) -> &SettingsManager {
336        &self.settings
337    }
338
339    fn base(&self, scope: Scope) -> PathBuf {
340        scope_dir(scope, &self.cwd, &self.agent_dir)
341    }
342
343    fn npm_root(&self, scope: Scope) -> PathBuf {
344        self.base(scope).join("npm")
345    }
346
347    /// Where `source`, as written in `scope`'s settings, is or would be
348    /// installed.
349    fn install_path(&self, source: &Source, scope: Scope) -> PathBuf {
350        install_location(source, &self.base(scope))
351    }
352
353    /// Where `source`, as written in `scope`'s settings, is installed.
354    pub fn installed_path(&self, source: &str, scope: Scope) -> Option<PathBuf> {
355        let path = self.install_path(&parse(source), scope);
356        path.exists().then_some(path)
357    }
358
359    fn assert_writable(&self, scope: Scope) -> Result<(), PackageError> {
360        if scope == Scope::Project && !self.settings.project_trusted() {
361            return Err(PackageError::Untrusted);
362        }
363        Ok(())
364    }
365
366    fn scope(local: bool) -> Scope {
367        if local { Scope::Project } else { Scope::Global }
368    }
369
370    /// pi's `installAndPersist`: installs `source` into the user's (or with
371    /// `local`, the project's) packages and lists it in their settings.
372    pub async fn install(&mut self, source: &str, local: bool) -> Result<(), PackageError> {
373        let scope = Self::scope(local);
374        self.assert_writable(scope)?;
375        (self.progress)(&format!("Installing {source}..."));
376        let parsed = parse(source);
377        match &parsed {
378            Source::Npm { name, version, .. } => {
379                self.install_npm(&self.npm_root(scope), name, version.as_deref(), false)
380                    .await?;
381            }
382            Source::Git {
383                repo, reference, ..
384            } => {
385                let dir = self.install_path(&parsed, scope);
386                let git_root = self.base(scope).join("git");
387                self.install_git(repo, reference.as_deref(), &dir, Some(&git_root))
388                    .await?;
389            }
390            Source::Local { path } => {
391                let resolved = source::local_path(path, &self.cwd);
392                if !resolved.exists() {
393                    return Err(PackageError::Message(format!(
394                        "Path does not exist: {}",
395                        resolved.display()
396                    )));
397                }
398            }
399        }
400        self.add_to_settings(source, scope)
401    }
402
403    /// Installs `name` (at `version`) into the npm root `root`, holding the
404    /// root's lock. With `stale_only`, a package that another process
405    /// installed meanwhile, so that it is no longer [`npm_stale`], is kept.
406    async fn install_npm(
407        &mut self,
408        root: &Path,
409        name: &str,
410        version: Option<&str>,
411        stale_only: bool,
412    ) -> Result<(), PackageError> {
413        let _lock = npm::lock_root(root).await?;
414        if stale_only && !npm_stale(&root.join("node_modules").join(name), version) {
415            return Ok(());
416        }
417        if let Some(command) = self.settings.settings().npm_command.clone() {
418            // pi's `npmCommand` runs a real package manager, scripts and all.
419            let (program, args) = command.split_first().ok_or_else(|| {
420                PackageError::Message(
421                    "Invalid npmCommand: first array entry must be a non-empty command".into(),
422                )
423            })?;
424            let spec =
425                version.map_or_else(|| name.to_owned(), |version| format!("{name}@{version}"));
426            // As pi when stdout carries JSON or RPC: npm's output goes to
427            // stderr, and npm never reads the client's input.
428            let status = tokio::process::Command::new(program)
429                .envs(crate::config::child_env())
430                .args(args)
431                .args(["install", &spec, "--prefix"])
432                .arg(root)
433                .arg("--legacy-peer-deps")
434                .stdin(std::process::Stdio::null())
435                .stdout(std::io::stderr())
436                .status()
437                .await
438                .map_err(|err| PackageError::Message(format!("{program}: {err}")))?;
439            if !status.success() {
440                return Err(PackageError::Message(format!(
441                    "{program} install {spec} failed with code {}",
442                    status.code().unwrap_or(-1)
443                )));
444            }
445            return Ok(());
446        }
447        npm::install(&mut self.npm, root, name, version).await?;
448        Ok(())
449    }
450
451    /// pi's `installGit`: clones `repo` into `dir` at `reference` and installs
452    /// its dependencies, removing the clone when any step fails. A new clone
453    /// first gets a `.gitignore` in `git_root`, the scope's `git` folder. An
454    /// existing checkout is updated in place instead and kept when that fails.
455    async fn install_git(
456        &mut self,
457        repo: &str,
458        reference: Option<&str>,
459        dir: &Path,
460        git_root: Option<&Path>,
461    ) -> Result<(), PackageError> {
462        if dir.exists() {
463            return self.update_git(dir, reference).await;
464        }
465        if let Some(git_root) = git_root {
466            npm::ensure_git_ignore(git_root)?;
467        }
468        if let Some(parent) = dir.parent() {
469            std::fs::create_dir_all(parent)
470                .map_err(|err| PackageError::Message(err.to_string()))?;
471        }
472        let target = dir.to_string_lossy().into_owned();
473        let installed: Result<(), PackageError> = async {
474            run_git(&["clone", "--", repo, &target], None).await?;
475            if let Some(reference) = reference {
476                run_git(&["checkout", reference], Some(dir)).await?;
477            }
478            npm::install_dependencies(&mut self.npm, dir).await?;
479            Ok(())
480        }
481        .await;
482        if installed.is_err() {
483            self.remove_dir(dir);
484        }
485        installed
486    }
487
488    /// Moves the checkout in `dir` to the latest commit of `reference`, else
489    /// of its upstream branch, and installs its dependencies. Fails unless
490    /// `dir` is the top of its own checkout, so git never acts on an enclosing
491    /// repository.
492    async fn update_git(
493        &mut self,
494        dir: &Path,
495        reference: Option<&str>,
496    ) -> Result<(), PackageError> {
497        let top = run_git(&["rev-parse", "--show-toplevel"], Some(dir)).await?;
498        let top = Path::new(top.trim()).canonicalize().ok();
499        if top.is_none() || top != dir.canonicalize().ok() {
500            return Err(PackageError::Message(format!(
501                "Refusing to update {}: it is not a git checkout of its own",
502                dir.display()
503            )));
504        }
505        match reference {
506            Some(reference) => {
507                run_git(&["fetch", "origin", reference], Some(dir)).await?;
508                run_git(&["checkout", "FETCH_HEAD"], Some(dir)).await?;
509            }
510            None => {
511                run_git(&["fetch", "--prune", "origin"], Some(dir)).await?;
512                run_git(&["reset", "--hard", "@{upstream}"], Some(dir)).await?;
513            }
514        }
515        npm::install_dependencies(&mut self.npm, dir).await?;
516        Ok(())
517    }
518
519    /// Removes `dir` and the empty directories above it, up to the git root
520    /// or the temporary packages folder.
521    fn remove_dir(&self, dir: &Path) {
522        let _ = std::fs::remove_dir_all(dir);
523        let temporary = self.agent_dir.join("tmp").join("extensions");
524        let mut current = dir.parent();
525        while let Some(parent) = current {
526            if parent.file_name().is_some_and(|name| name == "git")
527                || parent == temporary
528                || std::fs::remove_dir(parent).is_err()
529            {
530                break;
531            }
532            current = parent.parent();
533        }
534    }
535
536    /// pi's `removeAndPersist`: uninstalls `source` from the scope and drops
537    /// its settings entries. Returns `false` when settings did not list it.
538    pub async fn remove(&mut self, source: &str, local: bool) -> Result<bool, PackageError> {
539        let scope = Self::scope(local);
540        self.assert_writable(scope)?;
541        (self.progress)(&format!("Removing {source}..."));
542        let parsed = parse(source);
543        match &parsed {
544            Source::Npm { name, .. } => npm::uninstall(&self.npm_root(scope), name).await?,
545            Source::Git { .. } => {
546                let dir = self.install_path(&parsed, scope);
547                if dir.exists() {
548                    self.remove_dir(&dir);
549                }
550            }
551            Source::Local { .. } => {}
552        }
553        self.remove_from_settings(source, scope)
554    }
555
556    fn packages(&self, scope: Scope) -> Vec<Value> {
557        settings_packages(&self.settings, scope)
558    }
559
560    /// Whether settings entry `entry` of `scope` names the same package as
561    /// command-line `source`.
562    fn matches(&self, entry: &Value, source: &str, scope: Scope) -> bool {
563        entry_source(entry).is_some_and(|existing| {
564            parse(existing).identity(&self.base(scope)) == parse(source).identity(&self.cwd)
565        })
566    }
567
568    /// The source as settings store it: a local path relative to the scope's
569    /// directory.
570    fn settings_source(&self, source: &str, scope: Scope) -> String {
571        match parse(source) {
572            Source::Local { path } => {
573                let resolved = source::local_path(&path, &self.cwd);
574                let relative = crate::tools::path::relative(&self.base(scope), &resolved);
575                if relative.is_empty() {
576                    ".".into()
577                } else {
578                    relative
579                }
580            }
581            _ => source.to_owned(),
582        }
583    }
584
585    fn add_to_settings(&mut self, source: &str, scope: Scope) -> Result<(), PackageError> {
586        let normalized = self.settings_source(source, scope);
587        let mut packages = self.packages(scope);
588        match packages
589            .iter()
590            .position(|entry| self.matches(entry, source, scope))
591        {
592            Some(index) => {
593                if entry_source(&packages[index]) == Some(normalized.as_str()) {
594                    return Ok(());
595                }
596                if let Some(object) = packages[index].as_object_mut() {
597                    object.insert("source".into(), Value::String(normalized));
598                } else {
599                    packages[index] = Value::String(normalized);
600                }
601            }
602            None => packages.push(Value::String(normalized)),
603        }
604        self.settings
605            .set(scope, "packages", Some(Value::Array(packages)))?;
606        Ok(())
607    }
608
609    fn remove_from_settings(&mut self, source: &str, scope: Scope) -> Result<bool, PackageError> {
610        let packages = self.packages(scope);
611        let kept: Vec<Value> = packages
612            .iter()
613            .filter(|entry| !self.matches(entry, source, scope))
614            .cloned()
615            .collect();
616        if kept.len() == packages.len() {
617            return Ok(false);
618        }
619        self.settings
620            .set(scope, "packages", Some(Value::Array(kept)))?;
621        Ok(true)
622    }
623
624    /// The packages settings list: the user's, then the project's.
625    pub fn list(&self) -> Vec<Configured> {
626        [Scope::Global, Scope::Project]
627            .into_iter()
628            .flat_map(|scope| {
629                self.packages(scope).into_iter().filter_map(move |entry| {
630                    let source = entry_source(&entry)?.to_owned();
631                    let installed_path = self.installed_path(&source, scope);
632                    let filters: Option<FilteredPackage> = entry
633                        .is_object()
634                        .then(|| serde_json::from_value(entry.clone()).ok())
635                        .flatten();
636                    let extensions = installed_path
637                        .as_deref()
638                        .map(|root| {
639                            resolve::package_resources(
640                                root,
641                                filters.as_ref(),
642                                source::is_local(&source),
643                            )
644                            .enabled(resolve::ResourceType::Extensions)
645                            .map(|info| PathBuf::from(&info.path))
646                            .collect()
647                        })
648                        .unwrap_or_default();
649                    Some(Configured {
650                        installed_path,
651                        filtered: entry.is_object(),
652                        extensions,
653                        source,
654                        scope,
655                    })
656                })
657            })
658            .collect()
659    }
660
661    /// pi's `update`: reinstalls unpinned npm packages with newer matching
662    /// versions and fast-forwards git packages, for every configured package
663    /// or the one `source` names.
664    pub async fn update(&mut self, source: Option<&str>) -> Result<(), PackageError> {
665        let mut targets = Vec::new();
666        for scope in [Scope::Global, Scope::Project] {
667            for entry in self.packages(scope) {
668                let Some(configured) = entry_source(&entry).map(str::to_owned) else {
669                    continue;
670                };
671                if source.is_none_or(|source| self.matches(&entry, source, scope)) {
672                    targets.push((configured, scope));
673                }
674            }
675        }
676        if let Some(source) = source
677            && targets.is_empty()
678        {
679            return Err(PackageError::Message(format!(
680                "No matching package found for {source}"
681            )));
682        }
683        if crate::tools::external::offline() {
684            return Ok(());
685        }
686        let mut npm_targets = Vec::new();
687        let mut git_targets = Vec::new();
688        for (configured, scope) in targets {
689            match parse(&configured) {
690                Source::Npm {
691                    name,
692                    version,
693                    pinned: false,
694                } => npm_targets.push((configured, scope, name, version)),
695                // pi also reconciles checkouts pinned to a ref.
696                Source::Git {
697                    repo, reference, ..
698                } => git_targets.push((configured, scope, repo, reference)),
699                _ => {}
700            }
701        }
702        // pi updates a package whose versions it cannot compare.
703        let checks = npm_targets
704            .iter()
705            .map(|(configured, scope, name, version)| {
706                let root = self.install_path(&parse(configured), *scope);
707                self.npm_check(name.clone(), version.clone(), root)
708            });
709        let stale: Vec<bool> = futures_util::stream::iter(checks)
710            .buffered(UPDATE_CHECK_CONCURRENCY)
711            .map(|newer| newer.unwrap_or(true))
712            .collect()
713            .await;
714        for scope in [Scope::Global, Scope::Project] {
715            let batch: Vec<_> = npm_targets
716                .iter()
717                .zip(&stale)
718                .filter(|((_, target_scope, ..), stale)| *target_scope == scope && **stale)
719                .map(|(target, _)| target)
720                .collect();
721            match batch.as_slice() {
722                [] => continue,
723                [(configured, ..)] => (self.progress)(&format!("Updating {configured}...")),
724                _ => {
725                    let scope = if scope == Scope::Global {
726                        "user"
727                    } else {
728                        "project"
729                    };
730                    (self.progress)(&format!("Updating {scope} npm packages..."));
731                }
732            }
733            for (_, _, name, version) in batch {
734                self.install_npm(&self.npm_root(scope), name, version.as_deref(), false)
735                    .await?;
736            }
737        }
738        for (configured, scope, repo, reference) in git_targets {
739            (self.progress)(&format!("Updating {configured}..."));
740            let dir = self.install_path(&parse(&configured), scope);
741            // pi's `updateGit`: a missing checkout is installed.
742            let git_root = self.base(scope).join("git");
743            self.install_git(&repo, reference.as_deref(), &dir, Some(&git_root))
744                .await?;
745        }
746        Ok(())
747    }
748
749    /// pi's npm update check for package `name` from `version` (a range or
750    /// dist-tag, else `latest`) installed in `root`: whether the newest
751    /// version is later than the installed one. `None` when either is
752    /// unknown. The check owns what it needs, so several can run at once.
753    fn npm_check(
754        &self,
755        name: String,
756        version: Option<String>,
757        root: PathBuf,
758    ) -> impl Future<Output = Option<bool>> + Send + use<> {
759        let mut npm = self.npm.clone();
760        let command = self.settings.settings().npm_command.clone();
761        let cwd = self.cwd.clone();
762        async move {
763            let installed = npm::installed_version(&root)?;
764            let latest = latest_npm_version(
765                &mut npm,
766                command.as_deref(),
767                &cwd,
768                &name,
769                version.as_deref(),
770            )
771            .await?;
772            npm::newer(&latest, &installed)
773        }
774    }
775
776    /// pi's `checkForAvailableUpdates`: the installed npm and git packages
777    /// that settings list which have a later version than the one installed,
778    /// by name: the npm package's, or `<host>/<path>` for git. Local and
779    /// pinned sources are skipped, and so is a package whose check fails.
780    pub async fn available_updates(&self) -> Vec<String> {
781        let project = self.packages(Scope::Project);
782        let user = self.packages(Scope::Global);
783        let entries = resolve::dedupe_packages(&project, &user, |source, in_scope| {
784            parse(source).identity(&self.base(named_scope(in_scope)))
785        });
786        let checks = entries.into_iter().filter_map(|(entry, in_scope)| {
787            let parsed = parse(entry_source(&entry)?);
788            let root = self.install_path(&parsed, named_scope(in_scope));
789            if !root.exists() {
790                return None;
791            }
792            match parsed {
793                Source::Npm {
794                    name,
795                    version,
796                    pinned: false,
797                } => {
798                    let check = self.npm_check(name.clone(), version, root);
799                    Some(Either::Left(async move {
800                        (check.await == Some(true)).then_some(name)
801                    }))
802                }
803                Source::Git {
804                    host,
805                    path,
806                    reference: None,
807                    ..
808                } => Some(Either::Right(async move {
809                    git_newer(&root).await.then(|| format!("{host}/{path}"))
810                })),
811                _ => None,
812            }
813        });
814        let checks: Vec<_> = checks.collect();
815        futures_util::stream::iter(checks)
816            .buffered(UPDATE_CHECK_CONCURRENCY)
817            .filter_map(std::future::ready)
818            .collect()
819            .await
820    }
821
822    /// pi's temporary package for `-e`: where `source` is for this run. npm
823    /// and git sources live under `<agent>/tmp/extensions` and are not added
824    /// to settings. A missing package is installed, an npm package whose
825    /// installed version is outside the source's range is reinstalled, and an
826    /// unpinned git checkout is refreshed, kept as it is when that fails.
827    /// `offline` skips all three, and a missing package is then `None`.
828    pub async fn install_temporary(
829        &mut self,
830        source: &str,
831        offline: bool,
832    ) -> Result<Option<PathBuf>, PackageError> {
833        match parse(source) {
834            Source::Npm { name, version, .. } => {
835                let root = self.temporary_dir("npm", "", None)?;
836                let dir = root.join("node_modules").join(&name);
837                if npm_stale(&dir, version.as_deref()) {
838                    if offline {
839                        return Ok(None);
840                    }
841                    self.install_npm(&root, &name, version.as_deref(), true)
842                        .await?;
843                }
844                Ok(Some(dir))
845            }
846            Source::Git {
847                repo,
848                host,
849                path,
850                reference,
851            } => {
852                let dir =
853                    self.temporary_dir(&format!("git-{host}"), &path, reference.as_deref())?;
854                if !dir.exists() {
855                    if offline {
856                        return Ok(None);
857                    }
858                    self.install_git(&repo, reference.as_deref(), &dir, None)
859                        .await?;
860                } else if reference.is_none() && !offline {
861                    let _ = self.update_git(&dir, None).await;
862                }
863                Ok(Some(dir))
864            }
865            Source::Local { path } => Ok(Some(source::local_path(&path, &self.cwd))),
866        }
867    }
868
869    /// pi's `getTemporaryDir`: `<agent>/tmp/extensions/<prefix>/<hash>/<suffix>`,
870    /// where `hash` starts the SHA-256 of `<prefix>-<suffix>[@<reference>]`.
871    /// The `extensions` folder is readable by the user only.
872    fn temporary_dir(
873        &self,
874        prefix: &str,
875        suffix: &str,
876        reference: Option<&str>,
877    ) -> Result<PathBuf, PackageError> {
878        let folder = self.agent_dir.join("tmp").join("extensions");
879        let failed =
880            |err: std::io::Error| PackageError::Message(format!("{}: {err}", folder.display()));
881        std::fs::create_dir_all(&folder).map_err(failed)?;
882        #[cfg(unix)]
883        {
884            use std::os::unix::fs::PermissionsExt as _;
885            std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700))
886                .map_err(failed)?;
887        }
888        let key = match reference {
889            Some(reference) => format!("{prefix}-{suffix}@{reference}"),
890            None => format!("{prefix}-{suffix}"),
891        };
892        let hash = crate::time::hex(digest(&SHA256, key.as_ref()).as_ref());
893        let root = managed_path(&folder, Path::new(prefix))?;
894        managed_path(&root, &Path::new(&hash[..8]).join(suffix))
895    }
896
897    /// The installs of pi's `resolve`: the configured npm and git packages
898    /// that are missing, and the npm packages whose installed version is
899    /// outside the configured range, in the order and at the places of
900    /// [`resolve::locate_packages`]. `on_error` receives each failure, with
901    /// its source, and its `Break` ends the install and is returned.
902    pub async fn install_missing<B>(
903        &mut self,
904        mut on_error: impl FnMut(&str, String) -> ControlFlow<B>,
905    ) -> ControlFlow<B> {
906        let packages = resolve::locate_packages(
907            &self.packages(Scope::Project),
908            &self.packages(Scope::Global),
909            |source, in_scope| parse(source).identity(&self.base(named_scope(in_scope))),
910        );
911        let mut attempted = Vec::new();
912        for (_, _, source, in_scope) in packages {
913            // An `autoload: false` entry and the user's entry it adjusts
914            // install the same package.
915            if attempted.contains(&(source.clone(), in_scope)) {
916                continue;
917            }
918            attempted.push((source.clone(), in_scope));
919            let parsed = parse(&source);
920            let root = self.install_path(&parsed, named_scope(in_scope));
921            let installed = match &parsed {
922                Source::Npm { name, version, .. } => {
923                    if npm_stale(&root, version.as_deref()) {
924                        let npm_root = self.npm_root(named_scope(in_scope));
925                        self.install_npm(&npm_root, name, version.as_deref(), true)
926                            .await
927                    } else {
928                        Ok(())
929                    }
930                }
931                _ if root.exists() => Ok(()),
932                Source::Git {
933                    repo, reference, ..
934                } => {
935                    let git_root = self.base(named_scope(in_scope)).join("git");
936                    self.install_git(repo, reference.as_deref(), &root, Some(&git_root))
937                        .await
938                }
939                Source::Local { .. } => Ok(()),
940            };
941            if let Err(err) = installed {
942                on_error(&source, format!("Failed to install {source}: {err}"))?;
943            }
944        }
945        ControlFlow::Continue(())
946    }
947}
948
949/// Whether the npm package at `dir` must be installed: it is missing or its
950/// install never finished, or `range` is a version range its installed
951/// version does not satisfy. A
952/// dist-tag such as `latest` is no range, so it never reinstalls.
953fn npm_stale(dir: &Path, range: Option<&str>) -> bool {
954    npm::installed_version(dir).is_none_or(|installed| {
955        range.is_some_and(|range| npm::is_range(range) && !npm::satisfies(&installed, range))
956    })
957}
958
959#[cfg(test)]
960mod tests {
961    use super::*;
962
963    #[test]
964    fn temporary_dirs_are_pis() {
965        let agent = std::env::temp_dir().join(format!("yapi-temporary-{}", std::process::id()));
966        let manager = PackageManager::new(
967            agent.clone(),
968            agent.clone(),
969            SettingsManager::in_memory(),
970            Default::default(),
971        );
972        let extensions = agent.join("tmp/extensions");
973        let dir = |prefix: &str, suffix: &str, reference| {
974            manager
975                .temporary_dir(prefix, suffix, reference)
976                .map_err(|err| err.to_string())
977        };
978        // Hashes from pi's `getTemporaryDir`.
979        assert_eq!(
980            dir("npm", "", None).unwrap(),
981            extensions.join("npm/f35b2129")
982        );
983        assert_eq!(
984            dir("git-github.com", "user/repo", None).unwrap(),
985            extensions.join("git-github.com/338a1076/user/repo")
986        );
987        assert_eq!(
988            dir("git-github.com", "user/repo", Some("v1")).unwrap(),
989            extensions.join("git-github.com/18093e51/user/repo")
990        );
991        let escape = dir("git-github.com", "../../../outside", None).unwrap_err();
992        assert!(escape.starts_with("Refusing to use path outside package install root: "));
993        std::fs::remove_dir_all(&agent).unwrap();
994    }
995
996    /// A fresh directory for test `name`, and a manager whose agent
997    /// directory is in it and whose registry refuses connections.
998    fn git_scratch(name: &str) -> (PathBuf, PackageManager) {
999        let dir = std::env::temp_dir().join(format!("yapi-git-{name}-{}", std::process::id()));
1000        let _ = std::fs::remove_dir_all(&dir);
1001        std::fs::create_dir_all(&dir).unwrap();
1002        let registry = [("registry".to_owned(), "http://127.0.0.1:9/".to_owned())];
1003        let manager = PackageManager::new(
1004            dir.clone(),
1005            dir.join("agent"),
1006            SettingsManager::in_memory(),
1007            registry.into(),
1008        );
1009        (dir, manager)
1010    }
1011
1012    /// Commits everything in the repository `repo` and tags the commit.
1013    async fn commit(repo: &Path, tag: &str) {
1014        let config = ["-c", "user.name=yapi", "-c", "user.email=yapi@example.com"];
1015        let signing = ["-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false"];
1016        for args in [
1017            &["add", "-A"][..],
1018            &["commit", "--allow-empty", "-m", tag],
1019            &["tag", tag],
1020        ] {
1021            let args = [&config[..], &signing[..], args].concat();
1022            run_git(&args, Some(repo)).await.unwrap();
1023        }
1024    }
1025
1026    fn head(repo: &Path, reference: &str) -> String {
1027        let output = std::process::Command::new("git")
1028            .args(["rev-parse", reference])
1029            .current_dir(repo)
1030            .output()
1031            .unwrap();
1032        String::from_utf8(output.stdout).unwrap()
1033    }
1034
1035    /// pi's `installGit` on an existing checkout fetches the new ref, and
1036    /// keeps the checkout when that fails.
1037    #[tokio::test]
1038    async fn new_ref_updates_checkout() {
1039        let (dir, mut manager) = git_scratch("existing");
1040        let origin = dir.join("origin");
1041        run_git(&["init", "-b", "main", origin.to_str().unwrap()], None)
1042            .await
1043            .unwrap();
1044        commit(&origin, "v1").await;
1045        let checkout = dir.join("agent/git/github.com/user/repo");
1046        let repo = origin.to_str().unwrap();
1047        manager
1048            .install_git(repo, None, &checkout, None)
1049            .await
1050            .unwrap();
1051        commit(&origin, "v2").await;
1052
1053        manager
1054            .install_git(repo, Some("v2"), &checkout, None)
1055            .await
1056            .unwrap();
1057        assert_eq!(head(&checkout, "HEAD"), head(&origin, "v2"));
1058        let missing = manager.install_git(repo, Some("v3"), &checkout, None).await;
1059        assert!(missing.is_err());
1060        assert_eq!(head(&checkout, "HEAD"), head(&origin, "v2"));
1061        std::fs::remove_dir_all(&dir).unwrap();
1062    }
1063
1064    /// pi removes a clone whose dependencies fail to install.
1065    #[tokio::test]
1066    async fn failed_clone_removed() {
1067        let (dir, mut manager) = git_scratch("dependencies");
1068        let origin = dir.join("origin");
1069        run_git(&["init", "-b", "main", origin.to_str().unwrap()], None)
1070            .await
1071            .unwrap();
1072        let manifest = r#"{"name": "repo", "dependencies": {"left-pad": "^1.0.0"}}"#;
1073        std::fs::write(origin.join("package.json"), manifest).unwrap();
1074        commit(&origin, "v1").await;
1075        let checkout = dir.join("agent/git/github.com/user/repo");
1076        let installed = manager
1077            .install_git(origin.to_str().unwrap(), None, &checkout, None)
1078            .await;
1079        assert!(installed.is_err());
1080        assert!(!checkout.exists());
1081        assert!(dir.join("agent/git").exists());
1082        std::fs::remove_dir_all(&dir).unwrap();
1083    }
1084
1085    /// A checkout that links `node_modules`, or a scope in it, elsewhere gets
1086    /// no dependencies, so nothing is written through the link.
1087    #[cfg(unix)]
1088    #[tokio::test]
1089    async fn linked_node_modules_refused() {
1090        for link in ["node_modules", "node_modules/@scope"] {
1091            let (dir, mut manager) = git_scratch("linked");
1092            let outside = dir.join("outside");
1093            std::fs::create_dir_all(&outside).unwrap();
1094            let origin = dir.join("origin");
1095            run_git(&["init", "-b", "main", origin.to_str().unwrap()], None)
1096                .await
1097                .unwrap();
1098            let manifest = r#"{"name": "repo", "dependencies": {"@scope/dep": "^1.0.0"}}"#;
1099            std::fs::write(origin.join("package.json"), manifest).unwrap();
1100            let link = origin.join(link);
1101            std::fs::create_dir_all(link.parent().unwrap()).unwrap();
1102            std::os::unix::fs::symlink(&outside, &link).unwrap();
1103            commit(&origin, "v1").await;
1104            let checkout = dir.join("agent/git/github.com/user/repo");
1105            let error = manager
1106                .install_git(origin.to_str().unwrap(), None, &checkout, None)
1107                .await
1108                .unwrap_err();
1109            assert!(
1110                error
1111                    .to_string()
1112                    .starts_with("Refusing to install dependencies through the link "),
1113                "{}: {error}",
1114                link.display()
1115            );
1116            assert!(!checkout.exists());
1117            std::fs::remove_dir_all(&dir).unwrap();
1118        }
1119    }
1120}