1use std::fs;
26use std::io::Read;
27use std::path::{Component, Path, PathBuf};
28use std::process::{Command, Stdio};
29use std::sync::Mutex;
30use std::time::Duration;
31
32use semver::{Version, VersionReq};
33use serde_json::Value;
34use thiserror::Error;
35
36use crate::core::config::{CONFIG_DIR_NAME, PathInputOptions, resolve_path, resolve_path_with};
37use crate::core::resources::discovery::{
38 PackagePathResolver, PackageResolveError, ParsedSource, ResolvedPaths, parse_package_source,
39 temporary_dir_hash,
40};
41use crate::core::settings::{
42 PackageSource, PackageSourceFilter, SettingsManager, SettingsManagerError,
43};
44
45pub const NETWORK_TIMEOUT_MS: u64 = 10_000;
47
48const GITIGNORE_CONTENT: &str = "*\n!.gitignore\n";
50const NPM_PROJECT_PACKAGE_JSON: &str = "{\n \"name\": \"pi-extensions\",\n \"private\": true\n}";
52
53#[must_use]
59pub fn parse_source(source: &str) -> ParsedSource {
60 parse_package_source(source)
61}
62
63#[derive(Debug, Error)]
65pub enum PackageManagerError {
66 #[error("Project is not trusted; refusing to access project package storage")]
68 ProjectNotTrusted,
69 #[error("Path does not exist: {0}")]
71 PathNotFound(String),
72 #[error("Refusing to use path outside package install root: {0}")]
74 PathEscape(String),
75 #[error("Missing git install root")]
77 MissingGitInstallRoot,
78 #[error("Invalid npmCommand: first array entry must be a non-empty command")]
80 InvalidNpmCommand,
81 #[error("Unsupported install source: {0}")]
83 UnsupportedInstallSource(String),
84 #[error("Unsupported remove source: {0}")]
86 UnsupportedRemoveSource(String),
87 #[error("No matching package found for {0}")]
89 NoMatchingPackage(String),
90 #[error("No matching package found for {0}. Did you mean {1}?")]
92 NoMatchingPackageWithSuggestion(String, String),
93 #[error("{0}")]
95 Runner(String),
96 #[error(transparent)]
98 Settings(SettingsManagerError),
99 #[error(transparent)]
101 Resolve(#[from] PackageResolveError),
102}
103
104impl From<SettingsManagerError> for PackageManagerError {
105 fn from(error: SettingsManagerError) -> Self {
106 match error {
107 SettingsManagerError::ProjectNotTrusted => Self::ProjectNotTrusted,
108 error @ SettingsManagerError::InvalidSetting { .. } => Self::Settings(error),
109 }
110 }
111}
112
113pub type ProgressCallback = Box<dyn Fn(&ProgressEvent) + Send + Sync>;
115
116#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
121pub enum Scope {
122 User,
124 Project,
126}
127
128impl Scope {
129 #[must_use]
131 pub const fn as_str(self) -> &'static str {
132 match self {
133 Self::User => "user",
134 Self::Project => "project",
135 }
136 }
137}
138
139#[derive(Clone, Copy, Debug, Eq, PartialEq)]
141pub enum MissingSourceAction {
142 Install,
144 Skip,
146 Error,
148}
149
150#[derive(Clone, Debug, Eq, PartialEq)]
152pub struct ProgressEvent {
153 pub kind: ProgressKind,
155 pub action: ProgressAction,
157 pub source: String,
159 pub message: Option<String>,
161}
162
163#[derive(Clone, Copy, Debug, Eq, PartialEq)]
165pub enum ProgressKind {
166 Start,
168 Complete,
170 Error,
172}
173
174#[derive(Clone, Copy, Debug, Eq, PartialEq)]
176pub enum ProgressAction {
177 Install,
179 Remove,
181 Update,
183 Pull,
185}
186
187#[derive(Clone, Debug, Eq, PartialEq)]
189pub struct ConfiguredPackage {
190 pub source: String,
192 pub scope: Scope,
194 pub filtered: bool,
196 pub installed_path: Option<PathBuf>,
198}
199
200#[derive(Clone, Debug, Eq, PartialEq)]
202pub struct RunRequest {
203 pub command: String,
205 pub args: Vec<String>,
207 pub cwd: Option<PathBuf>,
209 pub timeout_ms: Option<u64>,
211 pub env: Vec<(String, String)>,
213}
214
215impl RunRequest {
216 #[must_use]
218 pub fn new(command: impl Into<String>, args: Vec<String>) -> Self {
219 Self {
220 command: command.into(),
221 args,
222 cwd: None,
223 timeout_ms: None,
224 env: Vec::new(),
225 }
226 }
227
228 #[must_use]
230 pub fn cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
231 self.cwd = Some(cwd.into());
232 self
233 }
234
235 #[must_use]
237 pub fn timeout_ms(mut self, timeout_ms: u64) -> Self {
238 self.timeout_ms = Some(timeout_ms);
239 self
240 }
241
242 #[must_use]
244 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
245 self.env.push((key.into(), value.into()));
246 self
247 }
248
249 #[must_use]
252 pub fn label(&self) -> String {
253 let mut label = self.command.clone();
254 for arg in &self.args {
255 label.push(' ');
256 label.push_str(arg);
257 }
258 label
259 }
260}
261
262#[derive(Debug, Error)]
264pub enum RunError {
265 #[error("{0}")]
267 Failed(String),
268 #[error("{0}")]
270 TimedOut(String),
271}
272
273pub trait Runner: Send + Sync {
280 fn run(&self, req: &RunRequest) -> Result<(), RunError>;
287
288 fn capture(&self, req: &RunRequest) -> Result<String, RunError>;
295}
296
297#[derive(Clone, Copy, Debug, Default)]
304pub struct SystemRunner;
305
306impl Runner for SystemRunner {
307 fn run(&self, req: &RunRequest) -> Result<(), RunError> {
308 let mut cmd = build_command(req);
309 cmd.stdin(Stdio::null());
310 cmd.stdout(Stdio::inherit());
311 cmd.stderr(Stdio::inherit());
312 configure_platform(&mut cmd);
313 let mut child = cmd
314 .spawn()
315 .map_err(|e| RunError::Failed(format!("{}: {e}", req.label())))?;
316 wait_with_timeout(&mut child, req)
317 }
318
319 fn capture(&self, req: &RunRequest) -> Result<String, RunError> {
320 let mut cmd = build_command(req);
321 cmd.stdin(Stdio::null());
322 cmd.stdout(Stdio::piped());
323 cmd.stderr(Stdio::piped());
324 configure_platform(&mut cmd);
325 let mut child = cmd
326 .spawn()
327 .map_err(|e| RunError::Failed(format!("{}: {e}", req.label())))?;
328 let stdout = child.stdout.take();
329 let stderr = child.stderr.take();
330 let stdout_thread = std::thread::spawn(move || read_pipe(stdout));
331 let stderr_thread = std::thread::spawn(move || read_pipe(stderr));
332
333 match wait_with_timeout(&mut child, req) {
334 Ok(()) => {
335 let stdout_out = stdout_thread.join().unwrap_or_default();
336 Ok(stdout_out.trim().to_owned())
337 }
338 Err(RunError::Failed(_)) => {
339 let stdout_out = stdout_thread.join().unwrap_or_default();
340 let stderr_out = stderr_thread.join().unwrap_or_default();
341 let detail = if stderr_out.is_empty() {
342 stdout_out
343 } else {
344 stderr_out
345 };
346 Err(RunError::Failed(format!(
347 "{} failed with: {detail}",
348 req.label()
349 )))
350 }
351 Err(other) => Err(other),
352 }
353 }
354}
355
356fn build_command(req: &RunRequest) -> Command {
359 let mut cmd = Command::new(&req.command);
360 cmd.args(&req.args);
361 if let Some(cwd) = &req.cwd {
362 cmd.current_dir(cwd);
363 }
364 for (key, value) in &req.env {
365 cmd.env(key, value);
366 }
367 cmd
368}
369
370fn configure_platform(cmd: &mut Command) {
373 #[cfg(unix)]
374 {
375 use std::os::unix::process::CommandExt as _;
376 cmd.process_group(0);
377 }
378 #[cfg(windows)]
379 {
380 use std::os::windows::process::CommandExt as _;
381 const CREATE_NO_WINDOW: u32 = 0x0800_0000;
382 cmd.creation_flags(CREATE_NO_WINDOW);
383 }
384}
385
386fn wait_with_timeout(child: &mut std::process::Child, req: &RunRequest) -> Result<(), RunError> {
388 use wait_timeout::ChildExt as _;
389 let Some(timeout_ms) = req.timeout_ms else {
390 let status = child
391 .wait()
392 .map_err(|e| RunError::Failed(format!("{}: {e}", req.label())))?;
393 return exit_status_result(&req.label(), status);
394 };
395 let duration = Duration::from_millis(timeout_ms);
396 match child.wait_timeout(duration) {
397 Ok(Some(status)) => exit_status_result(&req.label(), status),
398 Ok(None) => {
399 kill_process_tree(child.id());
400 let _ = child.wait();
401 Err(RunError::TimedOut(format!(
402 "{} timed out after {timeout_ms}ms",
403 req.label()
404 )))
405 }
406 Err(error) => Err(RunError::Failed(format!("{}: {error}", req.label()))),
407 }
408}
409
410fn exit_status_result(label: &str, status: std::process::ExitStatus) -> Result<(), RunError> {
412 if status.success() {
413 Ok(())
414 } else {
415 let exit_status = match status.code() {
416 Some(code) => format!("code {code}"),
417 None => "signal".to_owned(),
418 };
419 Err(RunError::Failed(format!(
420 "{label} failed with {exit_status}"
421 )))
422 }
423}
424
425fn read_pipe<R: Read>(mut pipe: Option<R>) -> String {
427 let Some(inner) = pipe.as_mut() else {
428 return String::new();
429 };
430 let mut buf = String::new();
431 let _ = inner.read_to_string(&mut buf);
432 buf
433}
434
435fn kill_process_tree(pid: u32) {
437 #[cfg(unix)]
438 {
439 use nix::sys::signal::{Signal, kill, killpg};
440 use nix::unistd::Pid;
441 if let Ok(raw) = i32::try_from(pid) {
442 let group = Pid::from_raw(raw);
443 if killpg(group, Signal::SIGKILL).is_err() {
444 let _ = kill(group, Signal::SIGKILL);
445 }
446 }
447 }
448 #[cfg(not(unix))]
449 {
450 let _ = Command::new("taskkill")
451 .args(["/F", "/T", "/PID", &pid.to_string()])
452 .stdin(Stdio::null())
453 .stdout(Stdio::null())
454 .stderr(Stdio::null())
455 .spawn();
456 }
457}
458
459#[derive(Clone, Debug)]
461pub struct PackageManagerOptions {
462 pub cwd: PathBuf,
464 pub agent_dir: PathBuf,
466 pub home_dir: Option<PathBuf>,
468}
469
470impl PackageManagerOptions {
471 #[must_use]
473 pub fn new(cwd: impl Into<PathBuf>, agent_dir: impl Into<PathBuf>) -> Self {
474 Self {
475 cwd: cwd.into(),
476 agent_dir: agent_dir.into(),
477 home_dir: None,
478 }
479 }
480
481 #[must_use]
483 pub fn home_dir(mut self, home_dir: impl Into<PathBuf>) -> Self {
484 self.home_dir = Some(home_dir.into());
485 self
486 }
487}
488
489pub struct PackageManager<R: Runner = SystemRunner> {
494 cwd: PathBuf,
495 agent_dir: PathBuf,
496 home_dir: Option<PathBuf>,
497 runner: R,
498 progress: Option<ProgressCallback>,
499 offline: Option<bool>,
501 global_npm_root: Mutex<Option<(String, String)>>,
503}
504
505impl PackageManager<SystemRunner> {
506 #[must_use]
508 pub fn new(options: PackageManagerOptions) -> Self {
509 Self::with_runner(options, SystemRunner)
510 }
511}
512
513impl<R: Runner> PackageManager<R> {
514 #[must_use]
516 pub fn with_runner(options: PackageManagerOptions, runner: R) -> Self {
517 let cwd = resolve_path_with(
518 &options.cwd.to_string_lossy(),
519 Path::new("."),
520 path_options(options.home_dir.as_deref()).trim(true),
521 );
522 let agent_dir = resolve_path_with(
523 &options.agent_dir.to_string_lossy(),
524 Path::new("."),
525 path_options(options.home_dir.as_deref()).trim(true),
526 );
527 Self {
528 cwd,
529 agent_dir,
530 home_dir: options.home_dir,
531 runner,
532 progress: None,
533 offline: None,
534 global_npm_root: Mutex::new(None),
535 }
536 }
537
538 #[must_use]
541 pub fn with_offline(mut self, offline: bool) -> Self {
542 self.offline = Some(offline);
543 self
544 }
545
546 pub fn set_progress_callback(&mut self, callback: Option<ProgressCallback>) {
548 self.progress = callback;
549 }
550
551 fn is_offline(&self) -> bool {
553 self.offline.unwrap_or_else(pi_offline_enabled)
554 }
555
556 pub fn get_installed_path(
563 &self,
564 settings: &SettingsManager,
565 source: &str,
566 scope: Scope,
567 ) -> Result<Option<PathBuf>, PackageManagerError> {
568 let parsed = parse_source(source);
569 match parsed {
570 ParsedSource::Npm { name, .. } => {
571 let path = self.npm_install_path(&name, scope, settings)?;
572 Ok(path.exists().then_some(path))
573 }
574 ParsedSource::Git { host, path, .. } => {
575 let path = self.git_install_path(&host, &path, scope)?;
576 Ok(path.exists().then_some(path))
577 }
578 ParsedSource::Local { path } => {
579 let base = self.base_dir_for_scope(scope);
580 let resolved = self.resolve_path_from_base(&path, &base);
581 Ok(resolved.exists().then_some(resolved))
582 }
583 }
584 }
585
586 pub fn install(
597 &self,
598 settings: &SettingsManager,
599 source: &str,
600 scope: Scope,
601 ) -> Result<(), PackageManagerError> {
602 let parsed = parse_source(source);
603 Self::assert_project_trusted(scope, settings)?;
604 let settings_ref = settings;
605 self.with_progress(
606 ProgressAction::Install,
607 source,
608 format!("Installing {source}..."),
609 || match &parsed {
610 ParsedSource::Npm { spec, .. } => {
611 self.install_npm(settings_ref, spec, scope, false)
612 }
613 ParsedSource::Git { .. } => self.install_git(settings_ref, &parsed, scope),
614 ParsedSource::Local { path } => {
615 let resolved = self.resolve_path(path);
616 if resolved.exists() {
617 Ok(())
618 } else {
619 Err(PackageManagerError::PathNotFound(path_string(&resolved)))
620 }
621 }
622 },
623 )
624 }
625
626 pub fn install_and_persist(
632 &self,
633 settings: &mut SettingsManager,
634 source: &str,
635 scope: Scope,
636 ) -> Result<(), PackageManagerError> {
637 self.install(settings, source, scope)?;
638 self.add_source_to_settings(settings, source, scope)?;
639 Ok(())
640 }
641
642 pub fn remove(
651 &self,
652 settings: &SettingsManager,
653 source: &str,
654 scope: Scope,
655 ) -> Result<(), PackageManagerError> {
656 let parsed = parse_source(source);
657 Self::assert_project_trusted(scope, settings)?;
658 let settings_ref = settings;
659 self.with_progress(
660 ProgressAction::Remove,
661 source,
662 format!("Removing {source}..."),
663 || match &parsed {
664 ParsedSource::Npm { .. } => self.uninstall_npm(settings_ref, &parsed, scope),
665 ParsedSource::Git { .. } => self.remove_git(&parsed, scope),
666 ParsedSource::Local { .. } => Ok(()),
667 },
668 )
669 }
670
671 pub fn remove_and_persist(
679 &self,
680 settings: &mut SettingsManager,
681 source: &str,
682 scope: Scope,
683 ) -> Result<bool, PackageManagerError> {
684 self.remove(settings, source, scope)?;
685 self.remove_source_from_settings(settings, source, scope)
686 }
687
688 pub fn update_extensions(
700 &self,
701 settings: &SettingsManager,
702 source: Option<&str>,
703 ) -> Result<(), PackageManagerError> {
704 if self.is_offline() {
705 return Ok(());
706 }
707 let global = settings.get_global_settings();
708 let project = settings.get_project_settings();
709 let identity = source.map(|s| self.package_identity(s, None));
710 let mut matched = false;
711 let mut targets: Vec<(String, Scope)> = Vec::new();
712 for pkg in global.packages.clone().unwrap_or_default() {
713 let src = package_source_string(&pkg);
714 if identity
715 .as_ref()
716 .is_some_and(|id| self.package_identity(&src, Some(Scope::User)) != *id)
717 {
718 continue;
719 }
720 matched = true;
721 targets.push((src, Scope::User));
722 }
723 for pkg in project.packages.clone().unwrap_or_default() {
724 let src = package_source_string(&pkg);
725 if identity
726 .as_ref()
727 .is_some_and(|id| self.package_identity(&src, Some(Scope::Project)) != *id)
728 {
729 continue;
730 }
731 matched = true;
732 targets.push((src, Scope::Project));
733 }
734 if source.is_some() && !matched {
735 let configured: Vec<PackageSource> = global
736 .packages
737 .clone()
738 .unwrap_or_default()
739 .into_iter()
740 .chain(project.packages.clone().unwrap_or_default())
741 .collect();
742 return Err(no_matching_package_error(source.unwrap_or(""), &configured));
743 }
744 self.update_configured_targets(settings, &targets)?;
745 Ok(())
746 }
747
748 pub fn list_configured_packages(
755 &self,
756 settings: &SettingsManager,
757 ) -> Result<Vec<ConfiguredPackage>, PackageManagerError> {
758 let global = settings.get_global_settings();
759 let project = settings.get_project_settings();
760 let mut out = Vec::new();
761 for pkg in global.packages.clone().unwrap_or_default() {
762 let source = package_source_string(&pkg);
763 let filtered = matches!(pkg, PackageSource::Filtered(_));
764 let installed_path = self.get_installed_path(settings, &source, Scope::User)?;
765 out.push(ConfiguredPackage {
766 source,
767 scope: Scope::User,
768 filtered,
769 installed_path,
770 });
771 }
772 for pkg in project.packages.clone().unwrap_or_default() {
773 let source = package_source_string(&pkg);
774 let filtered = matches!(pkg, PackageSource::Filtered(_));
775 let installed_path = self.get_installed_path(settings, &source, Scope::Project)?;
776 out.push(ConfiguredPackage {
777 source,
778 scope: Scope::Project,
779 filtered,
780 installed_path,
781 });
782 }
783 Ok(out)
784 }
785
786 pub fn resolve(
796 &self,
797 settings: &SettingsManager,
798 ) -> Result<ResolvedPaths, PackageManagerError> {
799 let resolver = PackagePathResolver::new(&self.cwd, &self.agent_dir, settings);
800 Ok(resolver.resolve()?)
801 }
802
803 pub fn add_source_to_settings(
813 &self,
814 settings: &mut SettingsManager,
815 source: &str,
816 scope: Scope,
817 ) -> Result<bool, PackageManagerError> {
818 let current = Self::scope_packages(settings, scope);
819 let normalized = self.normalize_source_for_settings(source, scope);
820 if let Some(index) = self.find_match(¤t, source, scope) {
821 let existing = ¤t[index];
822 if package_source_string(existing) == normalized {
823 return Ok(false);
824 }
825 let mut next = current.clone();
826 next[index] = replace_source(existing, normalized);
827 Self::set_scope_packages(settings, scope, &next)?;
828 return Ok(true);
829 }
830 let mut next = current;
831 next.push(PackageSource::Source(normalized));
832 Self::set_scope_packages(settings, scope, &next)?;
833 Ok(true)
834 }
835
836 pub fn remove_source_from_settings(
845 &self,
846 settings: &mut SettingsManager,
847 source: &str,
848 scope: Scope,
849 ) -> Result<bool, PackageManagerError> {
850 let current = Self::scope_packages(settings, scope);
851 let mut next = Vec::new();
852 for pkg in ¤t {
853 if !self.sources_match(pkg, source, scope) {
854 next.push(pkg.clone());
855 }
856 }
857 if next.len() == current.len() {
858 return Ok(false);
859 }
860 Self::set_scope_packages(settings, scope, &next)?;
861 Ok(true)
862 }
863
864 fn with_progress<F>(
867 &self,
868 action: ProgressAction,
869 source: &str,
870 message: String,
871 op: F,
872 ) -> Result<(), PackageManagerError>
873 where
874 F: FnOnce() -> Result<(), PackageManagerError>,
875 {
876 self.emit(&ProgressEvent {
877 kind: ProgressKind::Start,
878 action,
879 source: source.to_owned(),
880 message: Some(message),
881 });
882 match op() {
883 Ok(()) => {
884 self.emit(&ProgressEvent {
885 kind: ProgressKind::Complete,
886 action,
887 source: source.to_owned(),
888 message: None,
889 });
890 Ok(())
891 }
892 Err(error) => {
893 self.emit(&ProgressEvent {
894 kind: ProgressKind::Error,
895 action,
896 source: source.to_owned(),
897 message: Some(error.to_string()),
898 });
899 Err(error)
900 }
901 }
902 }
903
904 fn emit(&self, event: &ProgressEvent) {
905 if let Some(callback) = &self.progress {
906 callback(event);
907 }
908 }
909
910 fn assert_project_trusted(
913 scope: Scope,
914 settings: &SettingsManager,
915 ) -> Result<(), PackageManagerError> {
916 if scope == Scope::Project && !settings.is_project_trusted() {
917 return Err(PackageManagerError::ProjectNotTrusted);
918 }
919 Ok(())
920 }
921
922 fn scope_packages(settings: &SettingsManager, scope: Scope) -> Vec<PackageSource> {
923 match scope {
924 Scope::Project => settings
925 .get_project_settings()
926 .packages
927 .clone()
928 .unwrap_or_default(),
929 Scope::User => settings
930 .get_global_settings()
931 .packages
932 .clone()
933 .unwrap_or_default(),
934 }
935 }
936
937 fn set_scope_packages(
938 settings: &mut SettingsManager,
939 scope: Scope,
940 packages: &[PackageSource],
941 ) -> Result<(), PackageManagerError> {
942 match scope {
943 Scope::Project => settings.set_project_packages(packages)?,
944 Scope::User => settings.set_packages(packages),
945 }
946 Ok(())
947 }
948
949 fn find_match(&self, packages: &[PackageSource], input: &str, scope: Scope) -> Option<usize> {
950 let right = self.source_match_key_for_input(input);
951 for (index, pkg) in packages.iter().enumerate() {
952 let left = self.source_match_key_for_settings(&package_source_string(pkg), scope);
953 if left == right {
954 return Some(index);
955 }
956 }
957 None
958 }
959
960 fn sources_match(&self, existing: &PackageSource, input: &str, scope: Scope) -> bool {
961 self.find_match(std::slice::from_ref(existing), input, scope)
962 .is_some()
963 }
964
965 fn package_identity(&self, source: &str, scope: Option<Scope>) -> String {
968 match parse_source(source) {
969 ParsedSource::Npm { name, .. } => format!("npm:{name}"),
970 ParsedSource::Git { host, path, .. } => format!("git:{host}/{path}"),
971 ParsedSource::Local { path } => match scope {
972 Some(scope) => {
973 let base = self.base_dir_for_scope(scope);
974 format!(
975 "local:{}",
976 path_string(&self.resolve_path_from_base(&path, &base))
977 )
978 }
979 None => format!("local:{}", path_string(&self.resolve_path(&path))),
980 },
981 }
982 }
983
984 fn source_match_key_for_input(&self, source: &str) -> String {
985 match parse_source(source) {
986 ParsedSource::Npm { name, .. } => format!("npm:{name}"),
987 ParsedSource::Git { host, path, .. } => format!("git:{host}/{path}"),
988 ParsedSource::Local { path } => {
989 format!("local:{}", path_string(&self.resolve_path(&path)))
990 }
991 }
992 }
993
994 fn source_match_key_for_settings(&self, source: &str, scope: Scope) -> String {
995 match parse_source(source) {
996 ParsedSource::Npm { name, .. } => format!("npm:{name}"),
997 ParsedSource::Git { host, path, .. } => format!("git:{host}/{path}"),
998 ParsedSource::Local { path } => {
999 let base = self.base_dir_for_scope(scope);
1000 format!(
1001 "local:{}",
1002 path_string(&self.resolve_path_from_base(&path, &base))
1003 )
1004 }
1005 }
1006 }
1007
1008 fn normalize_source_for_settings(&self, source: &str, scope: Scope) -> String {
1009 match parse_source(source) {
1010 ParsedSource::Local { path } => {
1011 let base = self.base_dir_for_scope(scope);
1012 let resolved = self.resolve_path(&path);
1013 let rel = relative_path(&base, &resolved);
1014 rel.to_string_lossy().into_owned()
1015 }
1016 _ => source.to_owned(),
1017 }
1018 }
1019
1020 fn npm_command(
1023 settings: &SettingsManager,
1024 ) -> Result<(String, Vec<String>), PackageManagerError> {
1025 let configured = settings.get_npm_command().unwrap_or_default();
1026 if configured.is_empty() {
1027 return Ok(("npm".to_owned(), Vec::new()));
1028 }
1029 let mut iter = configured.into_iter();
1030 let command = iter.next().ok_or(PackageManagerError::InvalidNpmCommand)?;
1031 if command.is_empty() {
1032 return Err(PackageManagerError::InvalidNpmCommand);
1033 }
1034 Ok((command, iter.collect()))
1035 }
1036
1037 fn package_manager_name(command: &str, args: &[String]) -> String {
1038 let mut parts = vec![command.to_owned()];
1039 parts.extend(args.iter().cloned());
1040 let pm = match parts.iter().rposition(|p| p == "--") {
1041 Some(idx) => parts.get(idx + 1).cloned().unwrap_or_default(),
1042 None => command.to_owned(),
1043 };
1044 basename_no_exe(&pm)
1045 }
1046
1047 fn npm_install_dialect(manager: &str, specs: &[String], install_root: &Path) -> Vec<String> {
1049 match manager {
1050 "bun" => {
1051 let mut out = vec!["install".to_owned()];
1052 out.extend(specs.iter().cloned());
1053 out.push("--cwd".to_owned());
1054 out.push(path_string(install_root));
1055 out.push("--omit=peer".to_owned());
1056 out
1057 }
1058 "pnpm" => {
1059 let mut out = vec!["install".to_owned()];
1060 out.extend(specs.iter().cloned());
1061 out.push("--prefix".to_owned());
1062 out.push(path_string(install_root));
1063 out.push("--config.auto-install-peers=false".to_owned());
1064 out.push("--config.strict-peer-dependencies=false".to_owned());
1065 out.push("--config.strict-dep-builds=false".to_owned());
1066 out
1067 }
1068 _ => {
1069 let mut out = vec!["install".to_owned()];
1070 out.extend(specs.iter().cloned());
1071 out.push("--prefix".to_owned());
1072 out.push(path_string(install_root));
1073 out.push("--legacy-peer-deps".to_owned());
1074 out
1075 }
1076 }
1077 }
1078
1079 fn npm_uninstall_dialect(manager: &str, name: &str, install_root: &Path) -> Vec<String> {
1081 match manager {
1082 "bun" => vec![
1083 "uninstall".to_owned(),
1084 name.to_owned(),
1085 "--cwd".to_owned(),
1086 path_string(install_root),
1087 ],
1088 "pnpm" => vec![
1089 "uninstall".to_owned(),
1090 name.to_owned(),
1091 "--prefix".to_owned(),
1092 path_string(install_root),
1093 ],
1094 _ => vec![
1095 "uninstall".to_owned(),
1096 name.to_owned(),
1097 "--prefix".to_owned(),
1098 path_string(install_root),
1099 "--legacy-peer-deps".to_owned(),
1100 ],
1101 }
1102 }
1103
1104 fn git_dependency_dialect(settings: &SettingsManager) -> Vec<String> {
1106 if settings.get_npm_command().is_some_and(|c| !c.is_empty()) {
1107 vec!["install".to_owned()]
1108 } else {
1109 vec!["install".to_owned(), "--omit=dev".to_owned()]
1110 }
1111 }
1112
1113 fn run_npm(
1115 &self,
1116 settings: &SettingsManager,
1117 dialect: Vec<String>,
1118 cwd: Option<&Path>,
1119 ) -> Result<(), PackageManagerError> {
1120 let (command, prefix) = Self::npm_command(settings)?;
1121 let mut full = prefix;
1122 full.extend(dialect);
1123 let mut req = RunRequest::new(command, full).timeout_ms(NETWORK_TIMEOUT_MS);
1124 if let Some(cwd) = cwd {
1125 req = req.cwd(cwd);
1126 }
1127 self.runner.run(&req).map_err(|error| runner_error(&error))
1128 }
1129
1130 fn install_npm(
1131 &self,
1132 settings: &SettingsManager,
1133 spec: &str,
1134 scope: Scope,
1135 temporary: bool,
1136 ) -> Result<(), PackageManagerError> {
1137 let install_root = self.npm_install_root(scope, temporary)?;
1138 Self::ensure_npm_project(&install_root)?;
1139 let (command, prefix) = Self::npm_command(settings)?;
1140 let manager = Self::package_manager_name(&command, &prefix);
1141 let spec_owned = spec.to_owned();
1142 let dialect =
1143 Self::npm_install_dialect(&manager, std::slice::from_ref(&spec_owned), &install_root);
1144 let mut full = prefix;
1145 full.extend(dialect);
1146 self.runner
1147 .run(&RunRequest::new(command, full).timeout_ms(NETWORK_TIMEOUT_MS))
1148 .map_err(|error| runner_error(&error))
1149 }
1150
1151 fn uninstall_npm(
1152 &self,
1153 settings: &SettingsManager,
1154 parsed: &ParsedSource,
1155 scope: Scope,
1156 ) -> Result<(), PackageManagerError> {
1157 let ParsedSource::Npm { name, .. } = parsed else {
1158 return Ok(());
1159 };
1160 let install_root = self.npm_install_root(scope, false)?;
1161 if !install_root.exists() {
1162 return Ok(());
1163 }
1164 let (command, prefix) = Self::npm_command(settings)?;
1165 let manager = Self::package_manager_name(&command, &prefix);
1166 let dialect = Self::npm_uninstall_dialect(&manager, name, &install_root);
1167 let mut full = prefix;
1168 full.extend(dialect);
1169 self.runner
1170 .run(&RunRequest::new(command, full).timeout_ms(NETWORK_TIMEOUT_MS))
1171 .map_err(|error| runner_error(&error))
1172 }
1173
1174 fn install_git(
1177 &self,
1178 settings: &SettingsManager,
1179 parsed: &ParsedSource,
1180 scope: Scope,
1181 ) -> Result<(), PackageManagerError> {
1182 let ParsedSource::Git { repo, ref_name, .. } = parsed else {
1183 return Ok(());
1184 };
1185 let target = self.git_install_path_from_parsed(parsed, scope)?;
1186 if target.exists() {
1187 if let Some(reference) = ref_name {
1190 self.ensure_git_ref(
1191 settings,
1192 &target,
1193 &["fetch".to_owned(), "origin".to_owned(), reference.clone()],
1194 "FETCH_HEAD",
1195 )?;
1196 } else {
1197 let update_target = self.local_git_update_target(&target);
1198 self.ensure_git_ref(
1199 settings,
1200 &target,
1201 &update_target.fetch_args,
1202 &update_target.reference,
1203 )?;
1204 }
1205 return Ok(());
1206 }
1207 let root = self.git_install_root(scope);
1208 Self::ensure_git_ignore(&root)?;
1209 if let Some(parent) = target.parent() {
1210 fs::create_dir_all(parent).map_err(|error| io_escape(&error))?;
1211 }
1212 self.runner
1213 .run(
1214 &RunRequest::new(
1215 "git",
1216 vec!["clone".to_owned(), repo.clone(), path_string(&target)],
1217 )
1218 .timeout_ms(NETWORK_TIMEOUT_MS),
1219 )
1220 .map_err(|error| runner_error(&error))?;
1221 if let Some(reference) = ref_name {
1223 self.runner
1224 .run(
1225 &RunRequest::new("git", vec!["checkout".to_owned(), reference.clone()])
1226 .cwd(&target)
1227 .timeout_ms(NETWORK_TIMEOUT_MS),
1228 )
1229 .map_err(|error| runner_error(&error))?;
1230 }
1231 if target.join("package.json").exists() {
1232 self.run_npm(
1233 settings,
1234 Self::git_dependency_dialect(settings),
1235 Some(&target),
1236 )?;
1237 }
1238 Ok(())
1239 }
1240
1241 fn update_git(
1242 &self,
1243 settings: &SettingsManager,
1244 parsed: &ParsedSource,
1245 scope: Scope,
1246 ) -> Result<(), PackageManagerError> {
1247 let target = self.git_install_path_from_parsed(parsed, scope)?;
1248 if !target.exists() {
1249 return self.install_git(settings, parsed, scope);
1250 }
1251 let ParsedSource::Git { ref_name, .. } = parsed else {
1252 return Ok(());
1253 };
1254 if let Some(reference) = ref_name {
1256 self.ensure_git_ref(
1257 settings,
1258 &target,
1259 &["fetch".to_owned(), "origin".to_owned(), reference.clone()],
1260 "FETCH_HEAD",
1261 )?;
1262 } else {
1263 let update_target = self.local_git_update_target(&target);
1264 self.ensure_git_ref(
1265 settings,
1266 &target,
1267 &update_target.fetch_args,
1268 &update_target.reference,
1269 )?;
1270 }
1271 Ok(())
1272 }
1273
1274 fn ensure_git_ref(
1278 &self,
1279 settings: &SettingsManager,
1280 target: &Path,
1281 fetch_args: &[String],
1282 reference: &str,
1283 ) -> Result<(), PackageManagerError> {
1284 self.runner
1285 .run(
1286 &RunRequest::new("git", fetch_args.to_vec())
1287 .cwd(target)
1288 .timeout_ms(NETWORK_TIMEOUT_MS),
1289 )
1290 .map_err(|error| runner_error(&error))?;
1291 let local_head = self
1292 .runner
1293 .capture(
1294 &RunRequest::new("git", vec!["rev-parse".to_owned(), "HEAD".to_owned()])
1295 .cwd(target)
1296 .timeout_ms(NETWORK_TIMEOUT_MS),
1297 )
1298 .map_err(|error| runner_error(&error))?
1299 .trim()
1300 .to_owned();
1301 let commit_ref = format!("{reference}^{{commit}}");
1302 let target_head = self
1303 .runner
1304 .capture(
1305 &RunRequest::new("git", vec!["rev-parse".to_owned(), commit_ref.clone()])
1306 .cwd(target)
1307 .timeout_ms(NETWORK_TIMEOUT_MS),
1308 )
1309 .map_err(|error| runner_error(&error))?
1310 .trim()
1311 .to_owned();
1312 if local_head == target_head {
1313 return Ok(());
1314 }
1315 self.runner
1316 .run(
1317 &RunRequest::new(
1318 "git",
1319 vec!["reset".to_owned(), "--hard".to_owned(), commit_ref],
1320 )
1321 .cwd(target)
1322 .timeout_ms(NETWORK_TIMEOUT_MS),
1323 )
1324 .map_err(|error| runner_error(&error))?;
1325 self.runner
1327 .run(
1328 &RunRequest::new("git", vec!["clean".to_owned(), "-fdx".to_owned()])
1329 .cwd(target)
1330 .timeout_ms(NETWORK_TIMEOUT_MS),
1331 )
1332 .map_err(|error| runner_error(&error))?;
1333 if target.join("package.json").exists() {
1334 self.run_npm(
1335 settings,
1336 Self::git_dependency_dialect(settings),
1337 Some(target),
1338 )?;
1339 }
1340 Ok(())
1341 }
1342
1343 fn remove_git(&self, parsed: &ParsedSource, scope: Scope) -> Result<(), PackageManagerError> {
1344 let target = self.git_install_path_from_parsed(parsed, scope)?;
1345 if !target.exists() {
1346 return Ok(());
1347 }
1348 remove_all(&target)?;
1349 let root = self.git_install_root(scope);
1350 Self::prune_empty_git_parents(&target, &root);
1351 Ok(())
1352 }
1353
1354 fn local_git_update_target(&self, target: &Path) -> GitUpdateTarget {
1356 let upstream = self.runner.capture(
1357 &RunRequest::new(
1358 "git",
1359 vec![
1360 "rev-parse".to_owned(),
1361 "--abbrev-ref".to_owned(),
1362 "@{upstream}".to_owned(),
1363 ],
1364 )
1365 .cwd(target)
1366 .timeout_ms(NETWORK_TIMEOUT_MS),
1367 );
1368 if let Ok(upstream) = upstream {
1369 let trimmed = upstream.trim();
1370 if let Some(branch) = trimmed.strip_prefix("origin/")
1371 && !branch.is_empty()
1372 {
1373 return GitUpdateTarget {
1374 reference: "@{upstream}".to_owned(),
1375 fetch_args: git_fetch_args(branch),
1376 };
1377 }
1378 }
1379 let _ = self.runner.run(
1380 &RunRequest::new(
1381 "git",
1382 vec![
1383 "remote".to_owned(),
1384 "set-head".to_owned(),
1385 "origin".to_owned(),
1386 "-a".to_owned(),
1387 ],
1388 )
1389 .cwd(target)
1390 .timeout_ms(NETWORK_TIMEOUT_MS),
1391 );
1392 let head_ref = self
1393 .runner
1394 .capture(
1395 &RunRequest::new(
1396 "git",
1397 vec![
1398 "symbolic-ref".to_owned(),
1399 "refs/remotes/origin/HEAD".to_owned(),
1400 ],
1401 )
1402 .cwd(target)
1403 .timeout_ms(NETWORK_TIMEOUT_MS),
1404 )
1405 .unwrap_or_default()
1406 .trim()
1407 .trim_start_matches("refs/remotes/origin/")
1408 .to_owned();
1409 if head_ref.is_empty() {
1410 return GitUpdateTarget {
1412 reference: "origin/HEAD".to_owned(),
1413 fetch_args: git_head_fallback_fetch_args(),
1414 };
1415 }
1416 GitUpdateTarget {
1417 reference: "origin/HEAD".to_owned(),
1418 fetch_args: git_fetch_args(&head_ref),
1419 }
1420 }
1421
1422 fn update_configured_targets(
1425 &self,
1426 settings: &SettingsManager,
1427 targets: &[(String, Scope)],
1428 ) -> Result<(), PackageManagerError> {
1429 if targets.is_empty() {
1430 return Ok(());
1431 }
1432 for (source, scope) in targets {
1435 let parsed = parse_source(source);
1436 match &parsed {
1437 ParsedSource::Npm {
1438 spec,
1439 name,
1440 version,
1441 ..
1442 } => {
1443 if is_exact_npm_version(version.as_deref()) {
1444 continue;
1445 }
1446 if !self.should_update_npm(settings, name, spec, version.as_deref(), *scope)? {
1447 continue;
1448 }
1449 let update_spec = spec_if_version(spec, name, version.as_deref());
1451 let scope = *scope;
1452 self.with_progress(
1453 ProgressAction::Update,
1454 source,
1455 format!("Updating {source}..."),
1456 || self.install_npm(settings, &update_spec, scope, false),
1457 )?;
1458 }
1459 ParsedSource::Git { .. } => {
1460 let scope = *scope;
1461 let parsed = parsed.clone();
1462 self.with_progress(
1463 ProgressAction::Update,
1464 source,
1465 format!("Updating {source}..."),
1466 || self.update_git(settings, &parsed, scope),
1467 )?;
1468 }
1469 ParsedSource::Local { .. } => {}
1470 }
1471 }
1472 Ok(())
1473 }
1474
1475 fn should_update_npm(
1476 &self,
1477 settings: &SettingsManager,
1478 name: &str,
1479 spec: &str,
1480 version: Option<&str>,
1481 scope: Scope,
1482 ) -> Result<bool, PackageManagerError> {
1483 let installed_path = self.managed_npm_install_path(name, scope)?;
1484 let Some(installed_version) = read_installed_version(&installed_path) else {
1485 return Ok(true);
1486 };
1487 let view_spec = if version.is_some() {
1489 spec.to_owned()
1490 } else {
1491 name.to_owned()
1492 };
1493 let target = self.latest_npm_version(settings, view_spec, version)?;
1494 Ok(target.as_deref() != Some(installed_version.as_str()))
1495 }
1496
1497 fn latest_npm_version(
1498 &self,
1499 settings: &SettingsManager,
1500 package_spec: String,
1501 range: Option<&str>,
1502 ) -> Result<Option<String>, PackageManagerError> {
1503 let (command, prefix) = Self::npm_command(settings)?;
1504 let mut args = prefix;
1505 args.push("view".to_owned());
1506 args.push(package_spec);
1507 args.push("version".to_owned());
1508 args.push("--json".to_owned());
1509 let req = RunRequest::new(command, args)
1510 .cwd(&self.cwd)
1511 .timeout_ms(NETWORK_TIMEOUT_MS);
1512 let Ok(raw) = self.runner.capture(&req) else {
1513 return Ok(None);
1514 };
1515 let trimmed = raw.trim();
1516 if trimmed.is_empty() {
1517 return Ok(None);
1518 }
1519 Ok(parse_npm_view_version(trimmed, range))
1520 }
1521
1522 fn base_dir_for_scope(&self, scope: Scope) -> PathBuf {
1525 match scope {
1526 Scope::Project => self.cwd.join(CONFIG_DIR_NAME),
1527 Scope::User => self.agent_dir.clone(),
1528 }
1529 }
1530
1531 fn npm_install_root(
1532 &self,
1533 scope: Scope,
1534 temporary: bool,
1535 ) -> Result<PathBuf, PackageManagerError> {
1536 if temporary {
1537 return self.temporary_dir("npm", None);
1538 }
1539 match scope {
1540 Scope::Project => Ok(self.cwd.join(CONFIG_DIR_NAME).join("npm")),
1541 Scope::User => Ok(self.agent_dir.join("npm")),
1542 }
1543 }
1544
1545 fn git_install_root(&self, scope: Scope) -> PathBuf {
1546 match scope {
1547 Scope::Project => self.cwd.join(CONFIG_DIR_NAME).join("git"),
1548 Scope::User => self.agent_dir.join("git"),
1549 }
1550 }
1551
1552 fn managed_npm_install_path(
1553 &self,
1554 name: &str,
1555 scope: Scope,
1556 ) -> Result<PathBuf, PackageManagerError> {
1557 Ok(self
1558 .npm_install_root(scope, false)?
1559 .join("node_modules")
1560 .join(name))
1561 }
1562
1563 fn git_install_path(
1564 &self,
1565 host: &str,
1566 path: &str,
1567 scope: Scope,
1568 ) -> Result<PathBuf, PackageManagerError> {
1569 let root = self.git_install_root(scope);
1570 resolve_managed_path(&root, &[host, path])
1571 }
1572
1573 fn git_install_path_from_parsed(
1574 &self,
1575 parsed: &ParsedSource,
1576 scope: Scope,
1577 ) -> Result<PathBuf, PackageManagerError> {
1578 let ParsedSource::Git { host, path, .. } = parsed else {
1579 return Err(PackageManagerError::UnsupportedInstallSource(
1580 "non-git".to_owned(),
1581 ));
1582 };
1583 self.git_install_path(host, path, scope)
1584 }
1585
1586 fn npm_install_path(
1587 &self,
1588 name: &str,
1589 scope: Scope,
1590 settings: &SettingsManager,
1591 ) -> Result<PathBuf, PackageManagerError> {
1592 let managed = self.managed_npm_install_path(name, scope)?;
1593 if scope != Scope::User || managed.exists() {
1594 return Ok(managed);
1595 }
1596 if let Some(legacy) = self.legacy_global_npm_install_path(name, settings)?
1597 && legacy.exists()
1598 {
1599 return Ok(legacy);
1600 }
1601 Ok(managed)
1602 }
1603
1604 fn legacy_global_npm_install_path(
1605 &self,
1606 name: &str,
1607 settings: &SettingsManager,
1608 ) -> Result<Option<PathBuf>, PackageManagerError> {
1609 if let Some(path) = self.pnpm_global_package_path(name, settings)? {
1610 return Ok(Some(path));
1611 }
1612 let root = self.global_npm_root(settings)?;
1613 if root.is_empty() {
1614 return Ok(None);
1615 }
1616 Ok(Some(Path::new(&root).join(name)))
1617 }
1618
1619 fn pnpm_global_package_path(
1620 &self,
1621 name: &str,
1622 settings: &SettingsManager,
1623 ) -> Result<Option<PathBuf>, PackageManagerError> {
1624 let (command, args) = Self::npm_command(settings)?;
1625 if Self::package_manager_name(&command, &args) != "pnpm" {
1626 return Ok(None);
1627 }
1628 let mut full = args;
1629 full.extend([
1630 "list".to_owned(),
1631 "-g".to_owned(),
1632 "--depth".to_owned(),
1633 "0".to_owned(),
1634 "--json".to_owned(),
1635 ]);
1636 let Ok(output) = self
1637 .runner
1638 .capture(&RunRequest::new(command, full).timeout_ms(NETWORK_TIMEOUT_MS))
1639 else {
1640 return Ok(None);
1641 };
1642 Ok(parse_pnpm_global_path(&output, name))
1643 }
1644
1645 fn global_npm_root(&self, settings: &SettingsManager) -> Result<String, PackageManagerError> {
1646 let (command, args) = Self::npm_command(settings)?;
1647 let key = command_key(&command, &args);
1648 if let Some(entry) = self
1649 .global_npm_root
1650 .lock()
1651 .ok()
1652 .and_then(|guard| guard.clone())
1653 && entry.0 == key
1654 {
1655 return Ok(entry.1);
1656 }
1657 let manager = Self::package_manager_name(&command, &args);
1658 let root = if manager == "bun" {
1659 let mut a = args.clone();
1660 a.extend(["pm".to_owned(), "bin".to_owned(), "-g".to_owned()]);
1661 let bin_dir = self
1662 .runner
1663 .capture(&RunRequest::new(command.clone(), a).timeout_ms(NETWORK_TIMEOUT_MS))
1664 .unwrap_or_default();
1665 let parent = Path::new(bin_dir.trim())
1666 .parent()
1667 .map_or_else(|| bin_dir.trim().to_owned(), path_string);
1668 format!(
1669 "{}/install/global/node_modules",
1670 parent.trim_end_matches('/')
1671 )
1672 } else {
1673 let mut a = args.clone();
1674 a.extend(["root".to_owned(), "-g".to_owned()]);
1675 self.runner
1676 .capture(&RunRequest::new(command.clone(), a).timeout_ms(NETWORK_TIMEOUT_MS))
1677 .unwrap_or_default()
1678 .trim()
1679 .to_owned()
1680 };
1681 if let Ok(mut guard) = self.global_npm_root.lock() {
1682 *guard = Some((key, root.clone()));
1683 }
1684 Ok(root)
1685 }
1686
1687 fn temporary_dir(
1688 &self,
1689 prefix: &str,
1690 suffix: Option<&str>,
1691 ) -> Result<PathBuf, PackageManagerError> {
1692 let root = resolve_managed_path(&extension_temp_folder(&self.agent_dir), &[prefix])?;
1693 let suffix_str = suffix.unwrap_or("");
1694 let hash = temporary_dir_hash(prefix, suffix_str);
1695 if suffix_str.is_empty() {
1696 resolve_managed_path(&root, &[&hash])
1697 } else {
1698 resolve_managed_path(&root, &[&hash, suffix_str])
1699 }
1700 }
1701
1702 fn ensure_npm_project(install_root: &Path) -> Result<(), PackageManagerError> {
1703 if !install_root.exists() {
1704 fs::create_dir_all(install_root).map_err(|error| io_escape(&error))?;
1705 }
1706 Self::ensure_git_ignore(install_root)?;
1707 let package_json = install_root.join("package.json");
1708 if !package_json.exists() {
1709 fs::write(&package_json, NPM_PROJECT_PACKAGE_JSON)
1710 .map_err(|error| io_escape(&error))?;
1711 }
1712 Ok(())
1713 }
1714
1715 fn ensure_git_ignore(dir: &Path) -> Result<(), PackageManagerError> {
1716 if !dir.exists() {
1717 fs::create_dir_all(dir).map_err(|error| io_escape(&error))?;
1718 }
1719 let ignore_path = dir.join(".gitignore");
1720 if !ignore_path.exists() {
1721 fs::write(&ignore_path, GITIGNORE_CONTENT).map_err(|error| io_escape(&error))?;
1722 }
1723 Ok(())
1724 }
1725
1726 fn prune_empty_git_parents(target: &Path, install_root: &Path) {
1727 let resolved_root = resolve_path(path_string(install_root));
1728 let mut current = match target.parent() {
1729 Some(parent) => parent.to_path_buf(),
1730 None => return,
1731 };
1732 while current.starts_with(&resolved_root) && current != resolved_root {
1733 if !current.exists() {
1734 current = match current.parent() {
1735 Some(parent) => parent.to_path_buf(),
1736 None => return,
1737 };
1738 continue;
1739 }
1740 let is_empty = fs::read_dir(¤t).is_ok_and(|mut it| it.next().is_none());
1741 if !is_empty {
1742 break;
1743 }
1744 let _ = fs::remove_dir_all(¤t);
1745 current = match current.parent() {
1746 Some(parent) => parent.to_path_buf(),
1747 None => return,
1748 };
1749 }
1750 }
1751
1752 fn resolve_path(&self, input: &str) -> PathBuf {
1755 resolve_path_with(
1756 input,
1757 &self.cwd,
1758 path_options(self.home_dir.as_deref()).trim(true),
1759 )
1760 }
1761
1762 fn resolve_path_from_base(&self, input: &str, base: &Path) -> PathBuf {
1763 resolve_path_with(
1764 input,
1765 base,
1766 path_options(self.home_dir.as_deref()).trim(true),
1767 )
1768 }
1769}
1770
1771fn extension_temp_folder(agent_dir: &Path) -> PathBuf {
1777 agent_dir.join("tmp").join("extensions")
1778}
1779
1780fn resolve_managed_path(root: &Path, parts: &[&str]) -> Result<PathBuf, PackageManagerError> {
1782 let resolved_root = resolve_path(path_string(root));
1783 let mut resolved = resolved_root.clone();
1784 for part in parts {
1785 for component in Path::new(part).components() {
1786 match component {
1787 Component::Normal(seg) => resolved.push(seg),
1788 Component::CurDir => {}
1789 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
1790 return Err(PackageManagerError::PathEscape(path_string(&resolved)));
1791 }
1792 }
1793 }
1794 }
1795 if resolved != resolved_root && !resolved.starts_with(&resolved_root) {
1796 return Err(PackageManagerError::PathEscape(path_string(&resolved)));
1797 }
1798 Ok(resolved)
1799}
1800
1801fn relative_path(base: &Path, resolved: &Path) -> PathBuf {
1803 match resolved.strip_prefix(base) {
1804 Ok(rel) if rel.as_os_str().is_empty() => PathBuf::from("."),
1805 Ok(rel) => rel.to_path_buf(),
1806 Err(_) => resolved.to_path_buf(),
1807 }
1808}
1809
1810fn path_string(path: &Path) -> String {
1811 path.to_string_lossy().into_owned()
1812}
1813
1814fn path_options(home_dir: Option<&Path>) -> PathInputOptions<'_> {
1815 PathInputOptions::new().home_dir(home_dir)
1816}
1817
1818fn basename_no_exe(name: &str) -> String {
1819 let stem = Path::new(name)
1820 .file_stem()
1821 .map_or_else(|| name.to_owned(), |s| s.to_string_lossy().into_owned());
1822 stem.trim_end_matches(".cmd")
1823 .trim_end_matches(".exe")
1824 .to_owned()
1825}
1826
1827fn command_key(command: &str, args: &[String]) -> String {
1828 let mut key = command.to_owned();
1829 for arg in args {
1830 key.push('\0');
1831 key.push_str(arg);
1832 }
1833 key
1834}
1835
1836fn git_fetch_args(branch: &str) -> Vec<String> {
1837 vec![
1838 "fetch".to_owned(),
1839 "--prune".to_owned(),
1840 "--no-tags".to_owned(),
1841 "origin".to_owned(),
1842 format!("+refs/heads/{branch}:refs/remotes/origin/{branch}"),
1843 ]
1844}
1845
1846fn git_head_fallback_fetch_args() -> Vec<String> {
1848 vec![
1849 "fetch".to_owned(),
1850 "--prune".to_owned(),
1851 "--no-tags".to_owned(),
1852 "origin".to_owned(),
1853 "+HEAD:refs/remotes/origin/HEAD".to_owned(),
1854 ]
1855}
1856
1857struct GitUpdateTarget {
1859 reference: String,
1860 fetch_args: Vec<String>,
1861}
1862
1863fn is_exact_npm_version(version: Option<&str>) -> bool {
1864 version.is_some_and(|v| Version::parse(v).is_ok())
1865}
1866
1867fn spec_if_version(spec: &str, name: &str, version: Option<&str>) -> String {
1868 if version.is_some() {
1869 spec.to_owned()
1870 } else {
1871 format!("{name}@latest")
1872 }
1873}
1874
1875fn read_installed_version(install_path: &Path) -> Option<String> {
1876 let package_json = install_path.join("package.json");
1877 let content = fs::read_to_string(package_json).ok()?;
1878 let value: Value = serde_json::from_str(&content).ok()?;
1879 value
1880 .get("version")
1881 .and_then(Value::as_str)
1882 .map(str::to_owned)
1883}
1884
1885fn parse_npm_view_version(raw: &str, range: Option<&str>) -> Option<String> {
1886 let value: Value = serde_json::from_str(raw).ok()?;
1887 if let Some(s) = value.as_str() {
1888 return Some(s.to_owned());
1889 }
1890 let arr = value.as_array()?;
1891 let mut versions: Vec<String> = arr
1892 .iter()
1893 .filter_map(|v| v.as_str().filter(|s| !s.is_empty()).map(str::to_owned))
1894 .collect();
1895 if let Some(range) = range
1896 && let Ok(req) = VersionReq::parse(range)
1897 {
1898 versions.retain(|v| Version::parse(v).is_ok_and(|parsed| req.matches(&parsed)));
1899 }
1900 semantic_max(versions)
1901}
1902
1903fn semantic_max(versions: Vec<String>) -> Option<String> {
1905 versions
1906 .into_iter()
1907 .filter_map(|v| Version::parse(&v).ok().map(|parsed| (v, parsed)))
1908 .max_by(|a, b| a.1.cmp(&b.1))
1909 .map(|(v, _)| v)
1910}
1911
1912fn parse_pnpm_global_path(raw: &str, name: &str) -> Option<PathBuf> {
1913 let value: Value = serde_json::from_str(raw).ok()?;
1914 let entries = value.as_array()?;
1915 for entry in entries {
1916 if let Some(deps) = entry.get("dependencies").and_then(Value::as_object)
1917 && let Some(path) = deps
1918 .get(name)
1919 .and_then(|d| d.get("path"))
1920 .and_then(Value::as_str)
1921 {
1922 return Some(PathBuf::from(path));
1923 }
1924 }
1925 None
1926}
1927
1928fn package_source_string(pkg: &PackageSource) -> String {
1929 match pkg {
1930 PackageSource::Source(source) => source.clone(),
1931 PackageSource::Filtered(filter) => filter.source.clone(),
1932 }
1933}
1934
1935fn replace_source(existing: &PackageSource, normalized: String) -> PackageSource {
1936 match existing {
1937 PackageSource::Source(_) => PackageSource::Source(normalized),
1938 PackageSource::Filtered(filter) => PackageSource::Filtered(PackageSourceFilter {
1939 source: normalized,
1940 autoload: filter.autoload,
1941 extensions: filter.extensions.clone(),
1942 skills: filter.skills.clone(),
1943 prompts: filter.prompts.clone(),
1944 themes: filter.themes.clone(),
1945 extra: filter.extra.clone(),
1946 }),
1947 }
1948}
1949
1950fn no_matching_package_error(source: &str, configured: &[PackageSource]) -> PackageManagerError {
1951 match find_suggested_source(source, configured) {
1952 Some(suggestion) => {
1953 PackageManagerError::NoMatchingPackageWithSuggestion(source.to_owned(), suggestion)
1954 }
1955 None => PackageManagerError::NoMatchingPackage(source.to_owned()),
1956 }
1957}
1958
1959fn find_suggested_source(source: &str, configured: &[PackageSource]) -> Option<String> {
1960 let trimmed = source.trim();
1961 for pkg in configured {
1962 let src = package_source_string(pkg);
1963 match parse_source(&src) {
1964 ParsedSource::Npm { name, spec, .. } => {
1965 if trimmed == name || trimmed == spec {
1966 return Some(src);
1967 }
1968 }
1969 ParsedSource::Git {
1970 host,
1971 path,
1972 ref_name,
1973 ..
1974 } => {
1975 let shorthand = format!("{host}/{path}");
1976 let with_ref = ref_name.as_ref().map(|r| format!("{shorthand}@{r}"));
1977 if trimmed == shorthand || with_ref.is_some_and(|w| trimmed == w) {
1978 return Some(src);
1979 }
1980 }
1981 ParsedSource::Local { .. } => {}
1982 }
1983 }
1984 None
1985}
1986
1987fn runner_error(error: &RunError) -> PackageManagerError {
1988 PackageManagerError::Runner(error.to_string())
1989}
1990
1991fn io_escape(error: &std::io::Error) -> PackageManagerError {
1992 PackageManagerError::Runner(error.to_string())
1993}
1994
1995fn remove_all(path: &Path) -> Result<(), PackageManagerError> {
1996 if path.is_dir() {
1997 fs::remove_dir_all(path).map_err(|error| io_escape(&error))
1998 } else if path.exists() {
1999 fs::remove_file(path).map_err(|error| io_escape(&error))
2000 } else {
2001 Ok(())
2002 }
2003}
2004
2005fn pi_offline_enabled() -> bool {
2006 match std::env::var("PI_OFFLINE") {
2007 Ok(value) => {
2008 value == "1" || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes")
2009 }
2010 Err(_) => false,
2011 }
2012}
2013
2014#[cfg(test)]
2015mod tests;