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