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(
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
132pub 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
142pub 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
151pub fn entry_source(entry: &Value) -> Option<&str> {
153 entry.as_str().or_else(|| entry["source"].as_str())
154}
155
156pub 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 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
175pub(crate) async fn run_git(args: &[&str], dir: Option<&Path>) -> Result<String, PackageError> {
178 run_git_with(args, dir, &[]).await
179}
180
181pub(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
213fn named_scope(name: &str) -> Scope {
216 if name == "project" {
217 Scope::Project
218 } else {
219 Scope::Global
220 }
221}
222
223const NETWORK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
225
226const UPDATE_CHECK_CONCURRENCY: usize = 4;
228
229async 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
246async 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
255async 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
282async 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
317fn 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 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 pub fn project_only(&mut self, keep: resolve::ProjectOnly) {
353 self.project_only = Some(keep);
354 }
355
356 pub fn on_progress(&mut self, progress: impl Fn(&str) + Send + Sync + 'static) {
358 self.progress = Box::new(progress);
359 }
360
361 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 fn install_path(&self, source: &Source, scope: Scope) -> PathBuf {
377 install_location(source, &self.base(scope))
378 }
379
380 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 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 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 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 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 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 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 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 drop(lock);
528 self.remove_dir(dir);
529 }
530 installed
531 }
532
533 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 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 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 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 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 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 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 Source::Git {
742 repo, reference, ..
743 } => git_targets.push((configured, scope, repo, reference)),
744 _ => {}
745 }
746 }
747 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 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 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 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 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 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 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 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 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
1003fn 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
1011fn 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 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 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 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 #[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 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 #[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 #[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}