oro_script/
lib.rs

1//! Execute package run-scripts and lifecycle scripts.
2
3use std::ffi::{OsStr, OsString};
4use std::path::{Path, PathBuf};
5use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, Output, Stdio};
6
7pub use error::OroScriptError;
8use error::{IoContext, Result};
9use oro_common::BuildManifest;
10use regex::Regex;
11
12mod error;
13
14#[derive(Debug)]
15pub struct OroScript<'a> {
16    manifest: Option<&'a BuildManifest>,
17    event: String,
18    package_path: PathBuf,
19    paths: Vec<PathBuf>,
20    cmd: Command,
21    workspace_path: Option<PathBuf>,
22}
23
24impl<'a> OroScript<'a> {
25    pub fn new(package_path: impl AsRef<Path>, event: impl AsRef<str>) -> Result<Self> {
26        let package_path = package_path.as_ref();
27        let package_path = dunce::canonicalize(package_path).io_context(|| format!("Failed to canonicalize package path at {} while preparing to run a package script.", package_path.display()))?;
28        let shell = if cfg!(target_os = "windows") {
29            if let Some(com_spec) = std::env::var_os("ComSpec") {
30                com_spec
31            } else {
32                OsString::from("cmd")
33            }
34        } else {
35            OsString::from("sh")
36        };
37        let shell_str = shell.to_string_lossy();
38        let shell_is_cmd = Regex::new(r"(?:^|\\)cmd(?:\.exe)?$")
39            .unwrap()
40            .is_match(&shell_str);
41        let mut cmd = Command::new(&shell);
42        if shell_is_cmd {
43            cmd.arg("/d");
44            cmd.arg("/s");
45            cmd.arg("/c");
46        } else {
47            cmd.arg("-c");
48        }
49        cmd.current_dir(&package_path);
50        cmd.stdin(Stdio::null());
51        cmd.stdout(Stdio::piped());
52        cmd.stderr(Stdio::piped());
53        Ok(Self {
54            event: event.as_ref().into(),
55            manifest: None,
56            package_path,
57            paths: Self::get_existing_paths(),
58            workspace_path: None,
59            cmd,
60        })
61    }
62
63    /// If specified, `node_modules/.bin` directories above this path will not
64    /// be added to the $PATH variable when running the script.
65    pub fn workspace_path(mut self, path: impl AsRef<Path>) -> Self {
66        self.workspace_path = Some(path.as_ref().to_path_buf());
67        self
68    }
69
70    /// Set an environment variable.
71    pub fn env(mut self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
72        self.cmd.env(key.as_ref(), value.as_ref());
73        self
74    }
75
76    /// Set the [`Stdio`] that the script will use as its
77    /// standard output stream.
78    pub fn stdout(mut self, stdout: impl Into<Stdio>) -> Self {
79        self.cmd.stdout(stdout.into());
80        self
81    }
82
83    /// Set the [`Stdio`] that the script will use as its
84    /// standard error stream.
85    pub fn stderr(mut self, stderr: impl Into<Stdio>) -> Self {
86        self.cmd.stderr(stderr.into());
87        self
88    }
89
90    /// Set the [`Stdio`] that the script will use as its
91    /// standard input stream.
92    ///
93    /// NOTE: This defaults to [`Stdio::null`], which is
94    /// appropriate when running lifecycle scripts, but regular run-scripts
95    /// and such cases can use [`Stdio::inherit`].
96    pub fn stdin(mut self, stdin: impl Into<Stdio>) -> Self {
97        self.cmd.stdin(stdin.into());
98        self
99    }
100
101    /// Execute script, collecting all its output.
102    pub fn output(self) -> Result<Output> {
103        self.set_all_paths()?
104            .set_script()?
105            .cmd
106            .output()
107            .map_err(OroScriptError::ScriptProcessError)
108            .and_then(|out| {
109                if out.status.success() {
110                    Ok(out)
111                } else {
112                    Err(OroScriptError::ScriptError(
113                        out.status,
114                        Some(out.stdout),
115                        Some(out.stderr),
116                    ))
117                }
118            })
119    }
120
121    /// Spawn script as a child process.
122    pub fn spawn(self) -> Result<ScriptChild> {
123        self.set_all_paths()?
124            .set_script()?
125            .cmd
126            .spawn()
127            .map(ScriptChild::new)
128            .map_err(OroScriptError::SpawnError)
129    }
130
131    fn set_script(mut self) -> Result<Self> {
132        let event = &self.event;
133        if let Some(pkg) = self.manifest {
134            let script = pkg
135                .scripts
136                .get(event)
137                .ok_or_else(|| OroScriptError::MissingEvent(event.to_string()))?;
138            tracing::trace!(
139                "Executing script for event '{event}' for package at {}: {script}",
140                self.package_path.display()
141            );
142            #[cfg(windows)]
143            {
144                use std::os::windows::process::CommandExt;
145                self.cmd.raw_arg(script);
146            }
147            #[cfg(not(windows))]
148            self.cmd.arg(script);
149        } else {
150            let package_path = &self.package_path;
151            let json = package_path.join("package.json");
152            let pkg = BuildManifest::from_path(&json).io_context(|| {
153                format!(
154                    "Failed to read BuildManifest from path at {} while running package script.",
155                    json.display()
156                )
157            })?;
158            let script = pkg
159                .scripts
160                .get(event)
161                .ok_or_else(|| OroScriptError::MissingEvent(event.to_string()))?;
162            tracing::trace!(
163                "Executing script for event '{event}' for package at {}: {script}",
164                self.package_path.display()
165            );
166            #[cfg(windows)]
167            {
168                use std::os::windows::process::CommandExt;
169                self.cmd.raw_arg(script);
170            }
171            #[cfg(not(windows))]
172            self.cmd.arg(script);
173        }
174        Ok(self)
175    }
176
177    fn set_all_paths(mut self) -> Result<Self> {
178        for dir in self.package_path.ancestors() {
179            self.paths
180                .push(dir.join("node_modules").join(".bin").to_path_buf());
181            if let Some(workspace_path) = &self.workspace_path {
182                if dir == workspace_path {
183                    break;
184                }
185            }
186        }
187        let paths = format!("{}", std::env::join_paths(&self.paths)?.to_string_lossy());
188        for (var, _) in Self::current_paths() {
189            self = self.env(format!("{}", var.to_string_lossy()), paths.clone());
190        }
191        Ok(self)
192    }
193
194    fn current_paths() -> impl Iterator<Item = (OsString, Vec<PathBuf>)> {
195        std::env::vars_os().filter_map(|(var, val)| {
196            if var.to_string_lossy().to_lowercase() == "path" {
197                Some((var, std::env::split_paths(&val).collect::<Vec<PathBuf>>()))
198            } else {
199                None
200            }
201        })
202    }
203
204    fn get_existing_paths() -> Vec<PathBuf> {
205        Self::current_paths()
206            .map(|(_, paths)| paths)
207            .reduce(|mut a, mut b| {
208                a.append(&mut b);
209                a
210            })
211            .unwrap_or_default()
212    }
213}
214
215/// Child process executing a script.
216pub struct ScriptChild {
217    child: Child,
218    pub stdin: Option<ChildStdin>,
219    pub stdout: Option<ChildStdout>,
220    pub stderr: Option<ChildStderr>,
221}
222
223impl ScriptChild {
224    fn new(mut child: Child) -> Self {
225        Self {
226            stdin: child.stdin.take(),
227            stdout: child.stdout.take(),
228            stderr: child.stderr.take(),
229            child,
230        }
231    }
232
233    /// Returns the OS-assigned process identifier associated with this child.
234    pub fn id(&self) -> u32 {
235        self.child.id()
236    }
237
238    /// Forces the script process to exit.
239    pub fn kill(mut self) -> Result<()> {
240        self.child
241            .kill()
242            .map_err(OroScriptError::ScriptProcessError)
243    }
244
245    /// Waits for the script to exit completely. If the script exits with a
246    /// non-zero status, [`OroScriptError::ScriptError`] is returned.
247    pub fn wait(mut self) -> Result<()> {
248        self.child
249            .wait()
250            .map_err(OroScriptError::ScriptProcessError)
251            .and_then(|status| {
252                if status.success() {
253                    Ok(())
254                } else {
255                    Err(OroScriptError::ScriptError(status, None, None))
256                }
257            })
258    }
259}