1use std::ffi::{OsStr, OsString};
38use std::fs;
39use std::io::{self, Write as _};
40use std::path::{Path, PathBuf};
41
42pub const RUNNER_ENV_FILE: &str = "runner.env";
44
45pub const RESERVED_NAMES: [&str; 3] = ["TMPDIR", "TEMP", "TMP"];
48
49pub const RESERVED_PREFIX: &str = "ACTIONS_RUNNER_INPUT_";
53
54pub const MACOS_TOOL_DIRECTORIES: [&str; 3] =
57 ["/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin"];
58
59pub const MACOS_SYSTEM_PATH: &str = "/usr/bin:/bin:/usr/sbin:/sbin";
62
63pub const MACOS_DEFAULT_LANG: &str = "en_US.UTF-8";
67
68#[must_use]
70pub fn path_in(config_dir: &Path) -> PathBuf {
71 config_dir.join(RUNNER_ENV_FILE)
72}
73
74#[derive(Debug, thiserror::Error)]
77pub enum RunnerEnvError {
78 #[error("{}is not KEY=VALUE", at(*.line))]
79 NotAnAssignment { line: Option<usize> },
80 #[error(
81 "{}the name before `=` is not a valid environment variable name; use letters, \
82 digits and `_`, not starting with a digit",
83 at(*.line)
84 )]
85 InvalidName { line: Option<usize> },
86 #[error(
87 "{}`{name}` is set by runner-manager for every runner and cannot be changed",
88 at(*.line)
89 )]
90 Reserved { line: Option<usize>, name: String },
91 #[error("line {line} sets `{name}` a second time")]
92 Duplicate { line: usize, name: String },
93 #[error(
94 "{}the value of `{name}` contains a line break or a NUL character, which runner.env \
95 cannot hold",
96 at(*.line)
97 )]
98 LineBreak { line: Option<usize>, name: String },
99 #[error("cannot {action} {}: {source}", path.display())]
100 Io {
101 action: &'static str,
102 path: PathBuf,
103 #[source]
104 source: io::Error,
105 },
106}
107
108fn at(line: Option<usize>) -> String {
109 line.map_or_else(String::new, |line| format!("line {line}: "))
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
115enum Line {
116 Text(String),
117 Entry { name: String, value: String },
118}
119
120#[derive(Debug, Clone, Default, PartialEq, Eq)]
122pub struct RunnerEnv {
123 lines: Vec<Line>,
124}
125
126impl RunnerEnv {
127 pub fn parse(text: &str) -> Result<Self, RunnerEnvError> {
135 let mut env = Self::default();
136 for (index, raw) in text.lines().enumerate() {
137 let line = index + 1;
138 let raw = raw.strip_suffix('\r').unwrap_or(raw);
139 let trimmed = raw.trim_start();
140 if trimmed.is_empty() || trimmed.starts_with('#') {
141 env.lines.push(Line::Text(raw.to_owned()));
142 continue;
143 }
144 let (name, value) = trimmed
145 .split_once('=')
146 .ok_or(RunnerEnvError::NotAnAssignment { line: Some(line) })?;
147 let name = name.trim_end();
148 validate_name(name, Some(line))?;
149 if value.contains('\0') {
152 return Err(RunnerEnvError::LineBreak {
153 line: Some(line),
154 name: name.to_owned(),
155 });
156 }
157 if env.get(name).is_some() {
158 return Err(RunnerEnvError::Duplicate {
159 line,
160 name: name.to_owned(),
161 });
162 }
163 env.lines.push(Line::Entry {
164 name: name.to_owned(),
165 value: value.to_owned(),
166 });
167 }
168 Ok(env)
169 }
170
171 pub fn load(path: &Path) -> Result<Self, RunnerEnvError> {
177 match fs::read_to_string(path) {
178 Ok(text) => Self::parse(&text),
179 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
180 Err(source) => Err(RunnerEnvError::Io {
181 action: "read",
182 path: path.to_path_buf(),
183 source,
184 }),
185 }
186 }
187
188 pub fn save(&self, path: &Path) -> Result<(), RunnerEnvError> {
200 let failed = |action, source| RunnerEnvError::Io {
201 action,
202 path: path.to_path_buf(),
203 source,
204 };
205 let parent = path.parent().unwrap_or_else(|| Path::new("."));
206 fs::create_dir_all(parent).map_err(|source| failed("create the directory of", source))?;
207 let mut temporary =
208 tempfile::NamedTempFile::new_in(parent).map_err(|source| failed("write", source))?;
209 temporary
210 .write_all(self.render().as_bytes())
211 .and_then(|()| temporary.as_file().sync_all())
212 .map_err(|source| failed("write", source))?;
213 #[cfg(unix)]
214 {
215 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
216 let existing = fs::metadata(path).ok();
217 if let Some(owner) = existing.clone().or_else(|| fs::metadata(parent).ok()) {
218 let _ = std::os::unix::fs::chown(
221 temporary.path(),
222 Some(owner.uid()),
223 Some(owner.gid()),
224 );
225 }
226 if let Some(existing) = existing {
227 let mode = existing.permissions().mode() & 0o777;
228 temporary
229 .as_file()
230 .set_permissions(fs::Permissions::from_mode(mode))
231 .map_err(|source| failed("write", source))?;
232 }
233 }
234 temporary
235 .persist(path)
236 .map(|_| ())
237 .map_err(|error| failed("replace", error.error))
238 }
239
240 fn render(&self) -> String {
242 let mut text = String::new();
243 for line in &self.lines {
244 match line {
245 Line::Text(raw) => text.push_str(raw),
246 Line::Entry { name, value } => {
247 text.push_str(name);
248 text.push('=');
249 text.push_str(value);
250 }
251 }
252 text.push('\n');
253 }
254 text
255 }
256
257 pub fn set(&mut self, name: &str, value: &str) -> Result<bool, RunnerEnvError> {
263 validate_name(name, None)?;
264 if value.contains(['\n', '\r', '\0']) {
265 return Err(RunnerEnvError::LineBreak {
266 line: None,
267 name: name.to_owned(),
268 });
269 }
270 for line in &mut self.lines {
271 if let Line::Entry {
272 name: existing,
273 value: current,
274 } = line
275 && same_name(existing, name)
276 {
277 *current = value.to_owned();
278 return Ok(true);
279 }
280 }
281 self.lines.push(Line::Entry {
282 name: name.to_owned(),
283 value: value.to_owned(),
284 });
285 Ok(false)
286 }
287
288 pub fn unset(&mut self, name: &str) -> bool {
290 let before = self.lines.len();
291 self.lines.retain(
292 |line| !matches!(line, Line::Entry { name: existing, .. } if same_name(existing, name)),
293 );
294 self.lines.len() != before
295 }
296
297 #[must_use]
299 pub fn get(&self, name: &str) -> Option<&str> {
300 self.entries()
301 .find(|(existing, _)| same_name(existing, name))
302 .map(|(_, value)| value)
303 }
304
305 pub fn entries(&self) -> impl Iterator<Item = (&str, &str)> {
307 self.lines.iter().filter_map(|line| match line {
308 Line::Entry { name, value } => Some((name.as_str(), value.as_str())),
309 Line::Text(_) => None,
310 })
311 }
312
313 #[must_use]
315 pub fn len(&self) -> usize {
316 self.entries().count()
317 }
318
319 #[must_use]
321 pub fn is_empty(&self) -> bool {
322 self.len() == 0
323 }
324}
325
326pub fn split_assignment(raw: &str) -> Result<(&str, &str), RunnerEnvError> {
331 raw.split_once('=')
332 .ok_or(RunnerEnvError::NotAnAssignment { line: None })
333}
334
335fn validate_name(name: &str, line: Option<usize>) -> Result<(), RunnerEnvError> {
338 let mut chars = name.chars();
339 let valid = chars
340 .next()
341 .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
342 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
343 if !valid {
344 return Err(RunnerEnvError::InvalidName { line });
345 }
346 if is_reserved(name) {
347 return Err(RunnerEnvError::Reserved {
348 line,
349 name: name.to_owned(),
350 });
351 }
352 Ok(())
353}
354
355fn is_reserved(name: &str) -> bool {
358 let upper = name.to_ascii_uppercase();
359 upper.starts_with(RESERVED_PREFIX) || RESERVED_NAMES.contains(&upper.as_str())
360}
361
362pub(crate) fn same_name(a: &str, b: &str) -> bool {
364 if cfg!(windows) {
365 a.eq_ignore_ascii_case(b)
366 } else {
367 a == b
368 }
369}
370
371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
378pub enum RunnerPlatform {
379 Windows,
380 MacOs,
381 Linux,
382}
383
384impl RunnerPlatform {
385 #[must_use]
387 pub const fn current() -> Self {
388 if cfg!(windows) {
389 Self::Windows
390 } else if cfg!(target_os = "macos") {
391 Self::MacOs
392 } else {
393 Self::Linux
394 }
395 }
396}
397
398pub const POWERSHELL_TELEMETRY_OPTOUT: &str = "POWERSHELL_TELEMETRY_OPTOUT";
417
418pub const POWERSHELL_TELEMETRY_OPTOUT_VALUE: &str = "1";
421
422#[derive(Debug, Clone, Default)]
424pub struct Inherited {
425 pub path: Option<OsString>,
426 pub has_locale: bool,
428 pub has_powershell_telemetry_optout: bool,
430}
431
432impl Inherited {
433 #[must_use]
435 pub fn current() -> Self {
436 Self::from_env(|name| std::env::var_os(name))
437 }
438
439 fn from_env(var: impl Fn(&str) -> Option<OsString>) -> Self {
441 Self {
442 path: var("PATH"),
443 has_locale: var("LANG").is_some() || var("LC_ALL").is_some(),
444 has_powershell_telemetry_optout: var(POWERSHELL_TELEMETRY_OPTOUT)
445 .is_some_and(|value| !value.is_empty()),
446 }
447 }
448}
449
450pub const RUNNER_HOME_DIR: &str = "home";
452
453pub const WINDOWS_PROFILE_VARIABLES: [&str; 4] = ["USERPROFILE", "HOME", "APPDATA", "LOCALAPPDATA"];
456
457pub const DIRECTORY_VARIABLES: [&str; 2] = ["APPDATA", "LOCALAPPDATA"];
460
461#[must_use]
482pub fn platform_defaults(
483 platform: RunnerPlatform,
484 runtime: &Path,
485 inherited: &Inherited,
486 exists: impl Fn(&Path) -> bool,
487) -> Vec<(&'static str, OsString)> {
488 let mut defaults = match platform {
489 RunnerPlatform::Linux => Vec::new(),
490 RunnerPlatform::Windows => {
491 let home = runtime.join(RUNNER_HOME_DIR);
492 let app_data = home.join("AppData");
493 let [user_profile, home_name, roaming, local] = WINDOWS_PROFILE_VARIABLES;
494 vec![
495 (user_profile, home.clone().into_os_string()),
496 (home_name, home.into_os_string()),
497 (roaming, app_data.join("Roaming").into_os_string()),
498 (local, app_data.join("Local").into_os_string()),
499 ]
500 }
501 RunnerPlatform::MacOs => {
502 let mut defaults = Vec::new();
503 let listed: Vec<&OsStr> = inherited
504 .path
505 .as_deref()
506 .map(|path| split_colon_path(path))
507 .unwrap_or_default();
508 let missing: Vec<&str> = MACOS_TOOL_DIRECTORIES
509 .into_iter()
510 .filter(|dir| exists(Path::new(dir)) && !listed.contains(&OsStr::new(dir)))
511 .collect();
512 if !missing.is_empty() {
513 let mut path = OsString::from(missing.join(":"));
514 path.push(":");
515 match inherited.path.as_deref().filter(|p| !p.is_empty()) {
519 Some(inherited) => path.push(inherited),
520 None => path.push(MACOS_SYSTEM_PATH),
521 }
522 defaults.push(("PATH", path));
523 }
524 if !inherited.has_locale {
525 defaults.push(("LANG", OsString::from(MACOS_DEFAULT_LANG)));
526 }
527 defaults
528 }
529 };
530 if !inherited.has_powershell_telemetry_optout {
531 defaults.push((
532 POWERSHELL_TELEMETRY_OPTOUT,
533 OsString::from(POWERSHELL_TELEMETRY_OPTOUT_VALUE),
534 ));
535 }
536 defaults
537}
538
539fn split_colon_path(path: &OsStr) -> Vec<&OsStr> {
542 #[cfg(unix)]
543 {
544 use std::os::unix::ffi::OsStrExt;
545 path.as_bytes()
546 .split(|byte| *byte == b':')
547 .map(OsStr::from_bytes)
548 .collect()
549 }
550 #[cfg(not(unix))]
551 {
552 path.to_str()
553 .map(|text| text.split(':').map(OsStr::new).collect())
554 .unwrap_or_default()
555 }
556}
557
558#[must_use]
564pub fn runner_environment(
565 defaults: Vec<(&'static str, OsString)>,
566 caches: Vec<(&'static str, OsString)>,
567 file: &RunnerEnv,
568) -> Vec<(OsString, OsString)> {
569 defaults
570 .into_iter()
571 .chain(caches)
572 .map(|(name, value)| (OsString::from(name), value))
573 .chain(
574 file.entries()
575 .map(|(name, value)| (OsString::from(name), OsString::from(value))),
576 )
577 .collect()
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583
584 #[test]
585 fn a_file_parses_keeps_comments_and_round_trips() {
586 let text = "# caches off the boot volume\n\nELECTRON_CACHE=/Volumes/NVME/cache/electron\n\
587 npm_config_cache=/Volumes/NVME/cache/npm\nEMPTY=\nURL=https://x/?a=b\n";
588 let env = RunnerEnv::parse(text).unwrap();
589 assert_eq!(
590 env.entries().collect::<Vec<_>>(),
591 [
592 ("ELECTRON_CACHE", "/Volumes/NVME/cache/electron"),
593 ("npm_config_cache", "/Volumes/NVME/cache/npm"),
594 ("EMPTY", ""),
595 ("URL", "https://x/?a=b"),
596 ]
597 );
598 assert_eq!(env.render(), text);
599 assert_eq!(
600 RunnerEnv::parse("A=1\r\nB=2\r\n").unwrap().get("B"),
601 Some("2")
602 );
603 }
604
605 #[test]
606 fn a_bad_line_is_named_by_number_and_never_echoed() {
607 let secret = "ghp_notarealtokenbutshapedlikeone";
608 for (text, expected) in [
609 (format!("A=1\n{secret}\n"), "line 2: is not KEY=VALUE"),
610 (format!("A=1\n1BAD={secret}\n"), "line 2: the name"),
611 (
612 format!("TMP={secret}\n"),
613 "line 1: `TMP` is set by runner-manager",
614 ),
615 (
616 format!("actions_runner_input_url={secret}\n"),
617 "`actions_runner_input_url` is set by runner-manager",
618 ),
619 (
620 format!("A=1\nA={secret}\n"),
621 "line 2 sets `A` a second time",
622 ),
623 (
624 format!("A=1\nB={secret}\0\n"),
625 "line 2: the value of `B` contains a line break or a NUL",
626 ),
627 ] {
628 let error = RunnerEnv::parse(&text).unwrap_err().to_string();
629 assert!(error.contains(expected), "{error:?} lacks {expected:?}");
630 assert!(!error.contains(secret), "an error echoed a value: {error}");
631 }
632 }
633
634 #[test]
635 fn reserved_names_cover_the_temporary_directory_and_every_runner_input() {
636 for name in [
637 "TMPDIR",
638 "TEMP",
639 "TMP",
640 "Tmp",
641 "ACTIONS_RUNNER_INPUT_JITCONFIG",
642 "ACTIONS_RUNNER_INPUT_URL",
643 ] {
644 assert!(is_reserved(name), "{name}");
645 }
646 for name in ["TMPX", "PATH", "ACTIONS_RUNNER_HOOK_JOB_STARTED", "HOME"] {
647 assert!(!is_reserved(name), "{name}");
648 }
649 }
650
651 #[test]
652 fn set_replaces_in_place_and_unset_removes_only_the_entry() {
653 let mut env = RunnerEnv::parse("# keep me\nA=1\nB=2\n").unwrap();
654 assert!(env.set("A", "one").unwrap());
655 assert!(!env.set("C", "3").unwrap());
656 assert!(env.unset("B"));
657 assert!(!env.unset("B"));
658 assert_eq!(env.render(), "# keep me\nA=one\nC=3\n");
659 assert!(matches!(
660 env.set("D", "two\nlines"),
661 Err(RunnerEnvError::LineBreak { .. })
662 ));
663 assert!(matches!(
664 env.set("TEMP", "x"),
665 Err(RunnerEnvError::Reserved { .. })
666 ));
667 assert!(matches!(
668 env.set("NOT-A-NAME", "x"),
669 Err(RunnerEnvError::InvalidName { .. })
670 ));
671 }
672
673 #[cfg(unix)]
674 #[test]
675 fn saving_keeps_the_mode_of_the_file_it_replaces() {
676 use std::os::unix::fs::PermissionsExt as _;
677 let directory = tempfile::tempdir().unwrap();
678 let path = path_in(directory.path());
679 let mut env = RunnerEnv::default();
680 env.set("A", "1").unwrap();
681 env.save(&path).unwrap();
682 let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
683 assert_eq!(mode(&path), 0o600, "a new file is owner-only");
684
685 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
686 env.set("B", "2").unwrap();
687 env.save(&path).unwrap();
688 assert_eq!(mode(&path), 0o644, "the replacement reset the file's mode");
689 }
690
691 #[test]
692 fn a_missing_file_is_empty_and_a_saved_file_loads_back() {
693 let directory = tempfile::tempdir().unwrap();
694 let path = path_in(directory.path());
695 assert!(RunnerEnv::load(&path).unwrap().is_empty());
696
697 let mut env = RunnerEnv::default();
698 env.set("npm_config_cache", "/cache/npm").unwrap();
699 env.save(&path).unwrap();
700 assert_eq!(RunnerEnv::load(&path).unwrap(), env);
701 assert_eq!(
702 fs::read_to_string(&path).unwrap(),
703 "npm_config_cache=/cache/npm\n"
704 );
705 }
706
707 fn defaults(platform: RunnerPlatform, inherited: &Inherited) -> Vec<(&'static str, OsString)> {
708 platform_defaults(platform, Path::new("/rman/a1"), inherited, |dir| {
709 dir != Path::new("/usr/local/bin")
710 })
711 }
712
713 #[test]
714 fn macos_puts_existing_homebrew_directories_ahead_of_the_service_path() {
715 let launchd = Inherited {
716 path: Some("/usr/bin:/bin:/usr/sbin:/sbin".into()),
717 has_locale: false,
718 ..Inherited::default()
719 };
720 assert_eq!(
721 defaults(RunnerPlatform::MacOs, &launchd),
722 [
723 (
724 "PATH",
725 OsString::from(
726 "/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin"
727 )
728 ),
729 ("LANG", OsString::from(MACOS_DEFAULT_LANG)),
730 telemetry_optout(),
731 ]
732 );
733
734 let login = Inherited {
737 path: Some("/opt/homebrew/bin:/opt/homebrew/sbin:/usr/bin".into()),
738 has_locale: true,
739 ..Inherited::default()
740 };
741 assert_eq!(
742 defaults(RunnerPlatform::MacOs, &login),
743 [telemetry_optout()]
744 );
745
746 let bare = Inherited {
748 path: None,
749 has_locale: true,
750 ..Inherited::default()
751 };
752 assert_eq!(
753 defaults(RunnerPlatform::MacOs, &bare),
754 [
755 (
756 "PATH",
757 OsString::from(format!(
758 "/opt/homebrew/bin:/opt/homebrew/sbin:{MACOS_SYSTEM_PATH}"
759 ))
760 ),
761 telemetry_optout(),
762 ]
763 );
764 }
765
766 #[test]
767 fn windows_gives_each_runner_a_profile_inside_its_attempt() {
768 let windows = defaults(RunnerPlatform::Windows, &Inherited::default());
769 let home = Path::new("/rman/a1").join(RUNNER_HOME_DIR);
770 assert_eq!(
771 windows,
772 [
773 ("USERPROFILE", home.clone().into_os_string()),
774 ("HOME", home.clone().into_os_string()),
775 (
776 "APPDATA",
777 home.join("AppData").join("Roaming").into_os_string()
778 ),
779 (
780 "LOCALAPPDATA",
781 home.join("AppData").join("Local").into_os_string()
782 ),
783 telemetry_optout(),
784 ]
785 );
786 assert_eq!(
787 defaults(RunnerPlatform::Linux, &Inherited::default()),
788 [telemetry_optout()]
789 );
790 }
791
792 fn telemetry_optout() -> (&'static str, OsString) {
793 (
794 POWERSHELL_TELEMETRY_OPTOUT,
795 OsString::from(POWERSHELL_TELEMETRY_OPTOUT_VALUE),
796 )
797 }
798
799 #[test]
802 fn the_daemon_opt_out_is_read_from_its_environment_and_empty_is_unset() {
803 let with = |value: Option<&str>| {
804 Inherited::from_env(|name| {
805 (name == POWERSHELL_TELEMETRY_OPTOUT)
806 .then_some(value)
807 .flatten()
808 .map(OsString::from)
809 })
810 .has_powershell_telemetry_optout
811 };
812 assert!(with(Some("0")));
813 assert!(with(Some("1")));
814 assert!(!with(Some("")));
815 assert!(!with(None));
816 }
817
818 #[test]
821 fn a_daemon_powershell_telemetry_setting_is_left_alone() {
822 let daemon_decided = Inherited {
823 has_powershell_telemetry_optout: true,
824 ..Inherited::default()
825 };
826 for platform in [
827 RunnerPlatform::Windows,
828 RunnerPlatform::MacOs,
829 RunnerPlatform::Linux,
830 ] {
831 assert!(
832 defaults(platform, &daemon_decided)
833 .iter()
834 .all(|(name, _)| *name != POWERSHELL_TELEMETRY_OPTOUT),
835 "{platform:?} set the opt-out over the daemon's own value"
836 );
837 }
838 }
839
840 #[test]
841 fn runner_env_replaces_a_default_of_the_same_name_and_adds_the_rest() {
842 let file = RunnerEnv::parse("PATH=/custom/bin\nELECTRON_CACHE=/cache/electron\n").unwrap();
843 let defaults = vec![
844 ("PATH", OsString::from("/opt/homebrew/bin:/usr/bin")),
845 ("LANG", OsString::from(MACOS_DEFAULT_LANG)),
846 ];
847 assert_eq!(
848 runner_environment(defaults, Vec::new(), &file),
849 [
850 (
851 OsString::from("PATH"),
852 OsString::from("/opt/homebrew/bin:/usr/bin")
853 ),
854 (OsString::from("LANG"), OsString::from(MACOS_DEFAULT_LANG)),
855 (OsString::from("PATH"), OsString::from("/custom/bin")),
856 (
857 OsString::from("ELECTRON_CACHE"),
858 OsString::from("/cache/electron")
859 ),
860 ]
861 );
862 }
863
864 #[test]
867 fn caches_sit_between_the_platform_defaults_and_runner_env() {
868 let file = RunnerEnv::parse("npm_config_cache=/operator/npm\n").unwrap();
869 let defaults = vec![("HOME", OsString::from("/attempt/home"))];
870 let caches = vec![
871 ("npm_config_cache", OsString::from("/cache/o/r/npm")),
872 ("HOME", OsString::from("/cache-would-never-set-this")),
873 ];
874 let applied = runner_environment(defaults, caches, &file);
875 let last = |name: &str| {
876 applied
877 .iter()
878 .rev()
879 .find(|(n, _)| n == name)
880 .map(|(_, v)| v.clone())
881 };
882 assert_eq!(last("npm_config_cache"), Some("/operator/npm".into()));
883 assert_eq!(last("HOME"), Some("/cache-would-never-set-this".into()));
884 let position = |value: &str| applied.iter().position(|(_, v)| v == value).unwrap();
885 assert!(position("/attempt/home") < position("/cache/o/r/npm"));
886 assert!(position("/cache/o/r/npm") < position("/operator/npm"));
887 }
888}