1pub 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#[derive(Debug, thiserror::Error)]
28pub enum PackageError {
29 #[error("{0}")]
31 Message(String),
32 #[error("Project is not trusted. Use --approve to modify local package config.")]
34 Untrusted,
35 #[error(transparent)]
37 Npm(#[from] npm::NpmError),
38 #[error(transparent)]
40 Settings(#[from] SettingsError),
41}
42
43#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct Configured {
46 pub source: String,
48 pub scope: Scope,
50 pub filtered: bool,
52 pub installed_path: Option<PathBuf>,
54 pub extensions: Vec<PathBuf>,
57}
58
59pub 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
69pub 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
114pub 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
124pub 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
133pub fn entry_source(entry: &Value) -> Option<&str> {
135 entry.as_str().or_else(|| entry["source"].as_str())
136}
137
138pub 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
155pub(crate) async fn run_git(args: &[&str], dir: Option<&Path>) -> Result<String, PackageError> {
158 run_git_with(args, dir, &[]).await
159}
160
161pub(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
193fn named_scope(name: &str) -> Scope {
196 if name == "project" {
197 Scope::Project
198 } else {
199 Scope::Global
200 }
201}
202
203const NETWORK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
205
206const UPDATE_CHECK_CONCURRENCY: usize = 4;
208
209async 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
226async 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
235async 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
262async 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
297fn 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 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 pub fn on_progress(&mut self, progress: impl Fn(&str) + Send + Sync + 'static) {
331 self.progress = Box::new(progress);
332 }
333
334 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 fn install_path(&self, source: &Source, scope: Scope) -> PathBuf {
350 install_location(source, &self.base(scope))
351 }
352
353 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 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 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 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 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 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 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 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 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 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 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 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 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 Source::Git {
697 repo, reference, ..
698 } => git_targets.push((configured, scope, repo, reference)),
699 _ => {}
700 }
701 }
702 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 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 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 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 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 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 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 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
949fn 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 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 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 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 #[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 #[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 #[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}