aube_scripts/lib.rs
1//! Lifecycle script runner for aube.
2//!
3//! **Security model**:
4//! - Scripts from the **root package** (the project's own `package.json`)
5//! run by default. They're written by the user, so they're trusted the
6//! same way a user trusts `aube run <script>`.
7//! - Scripts from **installed dependencies** (e.g. `node-gyp` postinstall
8//! from a native module) are SKIPPED by default. A package runs its
9//! lifecycle scripts only if the active [`BuildPolicy`] allows it —
10//! configured via `pnpm.allowBuilds` in `package.json`, `allowBuilds`
11//! in `pnpm-workspace.yaml`, or the escape-hatch
12//! `--dangerously-allow-all-builds` flag.
13//! - `--ignore-scripts` forces everything off, matching pnpm/npm.
14
15pub mod policy;
16
17pub use policy::{AllowDecision, BuildPolicy, BuildPolicyError};
18
19use aube_manifest::PackageJson;
20use std::path::{Path, PathBuf};
21
22/// Settings that affect every package-script shell aube spawns.
23#[derive(Debug, Clone, Default)]
24pub struct ScriptSettings {
25 pub node_options: Option<String>,
26 pub script_shell: Option<PathBuf>,
27 pub unsafe_perm: Option<bool>,
28 pub shell_emulator: bool,
29}
30
31static SCRIPT_SETTINGS: std::sync::OnceLock<std::sync::RwLock<ScriptSettings>> =
32 std::sync::OnceLock::new();
33
34fn script_settings_lock() -> &'static std::sync::RwLock<ScriptSettings> {
35 SCRIPT_SETTINGS.get_or_init(|| std::sync::RwLock::new(ScriptSettings::default()))
36}
37
38/// Replace the process-wide script settings snapshot. CLI commands call
39/// this after resolving `.npmrc` / workspace settings for the active
40/// project.
41pub fn set_script_settings(settings: ScriptSettings) {
42 *script_settings_lock()
43 .write()
44 .expect("script settings lock poisoned") = settings;
45}
46
47fn script_settings() -> ScriptSettings {
48 script_settings_lock()
49 .read()
50 .expect("script settings lock poisoned")
51 .clone()
52}
53
54/// Prepend `bin_dir` to the current `PATH` using the platform's path
55/// separator (`:` on Unix, `;` on Windows).
56pub fn prepend_path(bin_dir: &Path) -> std::ffi::OsString {
57 let path = std::env::var_os("PATH").unwrap_or_default();
58 let mut entries = vec![bin_dir.to_path_buf()];
59 entries.extend(std::env::split_paths(&path));
60 std::env::join_paths(entries).unwrap_or(path)
61}
62
63/// Spawn a shell command line. On Unix we go through `sh -c`, on
64/// Windows through `cmd.exe /d /s /c` — matching what npm passes in
65/// `@npmcli/run-script`.
66///
67/// On Windows, the script command line is appended with
68/// [`std::os::windows::process::CommandExt::raw_arg`] instead of
69/// the normal `.arg()` path. `.arg()` would run the string through
70/// Rust's `CommandLineToArgvW`-oriented encoder, which wraps it in
71/// `"..."` and escapes interior `"` as `\"` — but `cmd.exe` parses
72/// command lines with a different set of rules and does not
73/// understand `\"`, so a script like
74/// `node -e "require('is-odd')(3)"` arrives mangled. `raw_arg`
75/// hands the command line to `CreateProcessW` verbatim, so we
76/// control the exact bytes cmd.exe sees. We wrap the whole script
77/// in an outer pair of double quotes, which `/s` tells cmd.exe to
78/// strip (just those outer quotes — the rest of the string is
79/// preserved literally). This is the same trick
80/// `@npmcli/run-script` and `node-cross-spawn` use.
81pub fn spawn_shell(script_cmd: &str) -> tokio::process::Command {
82 let settings = script_settings();
83 #[cfg(unix)]
84 {
85 let mut cmd = tokio::process::Command::new(
86 settings
87 .script_shell
88 .as_deref()
89 .unwrap_or_else(|| Path::new("sh")),
90 );
91 cmd.arg("-c").arg(script_cmd);
92 apply_script_settings_env(&mut cmd, &settings);
93 cmd
94 }
95 #[cfg(windows)]
96 {
97 let mut cmd = tokio::process::Command::new(
98 settings
99 .script_shell
100 .as_deref()
101 .unwrap_or_else(|| Path::new("cmd.exe")),
102 );
103 if settings.script_shell.is_some() {
104 cmd.arg("-c").arg(script_cmd);
105 } else {
106 // `/d` skips AutoRun, `/s` flips the quote-stripping rule
107 // so only the *outer* `"..."` pair is removed, `/c` runs
108 // the command and exits. Build the raw argv tail manually
109 // so cmd.exe sees the original script bytes.
110 cmd.raw_arg("/d /s /c \"").raw_arg(script_cmd).raw_arg("\"");
111 }
112 apply_script_settings_env(&mut cmd, &settings);
113 cmd
114 }
115}
116
117fn apply_script_settings_env(cmd: &mut tokio::process::Command, settings: &ScriptSettings) {
118 if let Some(node_options) = settings.node_options.as_deref() {
119 cmd.env("NODE_OPTIONS", node_options);
120 }
121 if let Some(unsafe_perm) = settings.unsafe_perm {
122 cmd.env(
123 "npm_config_unsafe_perm",
124 if unsafe_perm { "true" } else { "false" },
125 );
126 }
127 if settings.shell_emulator {
128 cmd.env("npm_config_shell_emulator", "true");
129 }
130}
131
132/// Lifecycle hooks that `aube install` runs against the root package's
133/// `scripts` field, in this order: `preinstall` → (dependencies link) →
134/// `install` → `postinstall` → `prepare`. Matches pnpm / npm.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum LifecycleHook {
137 PreInstall,
138 Install,
139 PostInstall,
140 Prepare,
141}
142
143impl LifecycleHook {
144 pub fn script_name(self) -> &'static str {
145 match self {
146 Self::PreInstall => "preinstall",
147 Self::Install => "install",
148 Self::PostInstall => "postinstall",
149 Self::Prepare => "prepare",
150 }
151 }
152}
153
154/// Dependency lifecycle hooks, in the order aube runs them for each
155/// allowlisted package. `prepare` is intentionally omitted — it's meant
156/// for the root package and git-dep preparation, not installed tarballs.
157pub const DEP_LIFECYCLE_HOOKS: [LifecycleHook; 3] = [
158 LifecycleHook::PreInstall,
159 LifecycleHook::Install,
160 LifecycleHook::PostInstall,
161];
162
163/// Holds the real stderr fd saved before `aube` redirects fd 2 to
164/// `/dev/null` under `--silent`. Child processes spawned through
165/// `child_stderr()` get a fresh dup of this fd so their stderr still
166/// reaches the user's terminal — `--silent` only silences aube's own
167/// output, not the scripts / binaries it invokes (matches `pnpm
168/// --loglevel silent`). A value of `-1` means silent mode is off and
169/// children should inherit stderr normally.
170#[cfg(unix)]
171static SAVED_STDERR_FD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
172
173/// Called once by `aube` after it saves + redirects fd 2. Passing
174/// the caller-owned saved fd here means child processes spawned via
175/// `child_stderr()` will write to the real terminal stderr instead of
176/// `/dev/null`.
177#[cfg(unix)]
178pub fn set_saved_stderr_fd(fd: std::os::fd::RawFd) {
179 SAVED_STDERR_FD.store(fd, std::sync::atomic::Ordering::SeqCst);
180}
181
182/// Windows has no equivalent fd-based silencing plumbing: aube's
183/// `SilentStderrGuard` is `libc::dup`/`libc::dup2` on fd 2, and those
184/// calls are gated to unix in `aube`. The stub keeps the public
185/// API shape identical so call sites compile unchanged.
186#[cfg(not(unix))]
187pub fn set_saved_stderr_fd(_fd: i32) {}
188
189/// Returns a `Stdio` suitable for a child process's stderr. When silent
190/// mode is active, this dups the saved real-stderr fd so the child
191/// bypasses the `/dev/null` redirect on fd 2. Otherwise returns
192/// `Stdio::inherit()`.
193#[cfg(unix)]
194pub fn child_stderr() -> std::process::Stdio {
195 let fd = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
196 if fd < 0 {
197 return std::process::Stdio::inherit();
198 }
199 // SAFETY: `fd` was registered by `set_saved_stderr_fd` from a live
200 // `dup` that `aube`'s `SilentStderrGuard` keeps open for the
201 // duration of main. `BorrowedFd` only borrows, so this does not
202 // transfer ownership.
203 let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
204 match borrowed.try_clone_to_owned() {
205 Ok(owned) => std::process::Stdio::from(owned),
206 Err(_) => std::process::Stdio::inherit(),
207 }
208}
209
210#[cfg(not(unix))]
211pub fn child_stderr() -> std::process::Stdio {
212 std::process::Stdio::inherit()
213}
214
215/// Run a single npm-style script line through `sh -c` with the usual
216/// environment (`$PATH` extended with `node_modules/.bin`, `INIT_CWD`,
217/// `npm_lifecycle_event`, `npm_package_name`, `npm_package_version`).
218///
219/// Inherits stdio from the parent so the user sees script output live.
220/// Returns Err on non-zero exit so install fails fast if a lifecycle
221/// script breaks, matching pnpm.
222pub async fn run_script(
223 script_dir: &Path,
224 project_root: &Path,
225 modules_dir_name: &str,
226 manifest: &PackageJson,
227 script_name: &str,
228 script_cmd: &str,
229) -> Result<(), Error> {
230 // PATH always gets the project root's `<modules_dir>/.bin`. For
231 // root scripts `script_dir == project_root`, so this matches the
232 // old behavior; for dep scripts the dep's own `node_modules/.bin`
233 // doesn't exist inside the virtual store, so we need the real
234 // project-level one where tools like `node-gyp` live.
235 // `modules_dir_name` honors pnpm's `modulesDir` setting — defaults
236 // to `"node_modules"` at the call site, but a workspace may have
237 // configured something else.
238 let bin_dir = project_root.join(modules_dir_name).join(".bin");
239 let new_path = prepend_path(&bin_dir);
240
241 let mut cmd = spawn_shell(script_cmd);
242 cmd.current_dir(script_dir)
243 .stderr(child_stderr())
244 .env("PATH", &new_path)
245 .env("npm_lifecycle_event", script_name);
246
247 // Pass INIT_CWD the way npm/pnpm do — the directory the user
248 // invoked the package manager from, *not* the script's own cwd.
249 // Native-module build tooling (node-gyp, prebuild-install, etc.)
250 // reads INIT_CWD to locate the project root when caching binaries.
251 // Preserve if already set by a parent aube invocation so nested
252 // scripts see the outermost cwd.
253 if std::env::var_os("INIT_CWD").is_none() {
254 cmd.env("INIT_CWD", project_root);
255 }
256
257 if let Some(ref name) = manifest.name {
258 cmd.env("npm_package_name", name);
259 }
260 if let Some(ref version) = manifest.version {
261 cmd.env("npm_package_version", version);
262 }
263
264 tracing::debug!("lifecycle: {script_name} → {script_cmd}");
265 let status = cmd
266 .status()
267 .await
268 .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
269
270 if !status.success() {
271 return Err(Error::NonZeroExit {
272 script: script_name.to_string(),
273 code: status.code(),
274 });
275 }
276
277 Ok(())
278}
279
280/// Run a lifecycle hook against the root package, if a script for it is
281/// defined. Returns `Ok(false)` if the hook wasn't defined (no-op),
282/// `Ok(true)` if it ran successfully.
283///
284/// The caller is responsible for gating on `--ignore-scripts`.
285pub async fn run_root_hook(
286 project_dir: &Path,
287 modules_dir_name: &str,
288 manifest: &PackageJson,
289 hook: LifecycleHook,
290) -> Result<bool, Error> {
291 let name = hook.script_name();
292 let Some(script_cmd) = manifest.scripts.get(name) else {
293 return Ok(false);
294 };
295 run_script(
296 project_dir,
297 project_dir,
298 modules_dir_name,
299 manifest,
300 name,
301 script_cmd,
302 )
303 .await?;
304 Ok(true)
305}
306
307/// Single source of truth for the implicit `node-gyp rebuild`
308/// fallback: returns `Some("node-gyp rebuild")` when the package ships
309/// a `binding.gyp` at its root AND the manifest leaves both `install`
310/// and `preinstall` empty (either one is the author's explicit
311/// opt-out from the default).
312///
313/// `has_binding_gyp` is passed by the caller so this helper is
314/// agnostic to *how* presence was detected — the install pipeline
315/// stats the materialized package dir, while `aube ignored-builds`
316/// reads the store `PackageIndex` since the package may not be
317/// linked into `node_modules` yet. Both paths must agree on the gate
318/// condition, so they both go through this.
319pub fn implicit_install_script(
320 manifest: &PackageJson,
321 has_binding_gyp: bool,
322) -> Option<&'static str> {
323 if !has_binding_gyp {
324 return None;
325 }
326 if manifest
327 .scripts
328 .contains_key(LifecycleHook::Install.script_name())
329 || manifest
330 .scripts
331 .contains_key(LifecycleHook::PreInstall.script_name())
332 {
333 return None;
334 }
335 Some("node-gyp rebuild")
336}
337
338/// Default `install` command for a materialized dependency directory.
339/// Thin wrapper around [`implicit_install_script`] that supplies
340/// `has_binding_gyp` by stat'ing `<package_dir>/binding.gyp`.
341pub fn default_install_script(package_dir: &Path, manifest: &PackageJson) -> Option<&'static str> {
342 implicit_install_script(manifest, package_dir.join("binding.gyp").is_file())
343}
344
345/// True if [`run_dep_hook`] would actually execute something for this
346/// package across any of the dependency lifecycle hooks. Callers use
347/// this to skip fan-out work for packages that have nothing to run —
348/// including the implicit `node-gyp rebuild` default.
349pub fn has_dep_lifecycle_work(package_dir: &Path, manifest: &PackageJson) -> bool {
350 if DEP_LIFECYCLE_HOOKS
351 .iter()
352 .any(|h| manifest.scripts.contains_key(h.script_name()))
353 {
354 return true;
355 }
356 default_install_script(package_dir, manifest).is_some()
357}
358
359/// Run a lifecycle hook against an installed dependency's package
360/// directory. Mirrors [`run_root_hook`] but spawns inside `package_dir`
361/// (the actual linked package directory, e.g.
362/// `node_modules/.aube/<dep_path>/node_modules/<name>`). The manifest
363/// is the dependency's own `package.json`, *not* the project root's.
364///
365/// For the `install` hook specifically, if the manifest leaves both
366/// `install` and `preinstall` empty but the package has a top-level
367/// `binding.gyp`, this falls back to running `node-gyp rebuild` — the
368/// node-gyp default that npm and pnpm both honor so native modules
369/// without a prebuilt binary still compile on install.
370///
371/// The caller is responsible for gating on `BuildPolicy` and
372/// `--ignore-scripts`. Returns `Ok(false)` if the hook wasn't defined.
373pub async fn run_dep_hook(
374 package_dir: &Path,
375 project_root: &Path,
376 modules_dir_name: &str,
377 manifest: &PackageJson,
378 hook: LifecycleHook,
379) -> Result<bool, Error> {
380 let name = hook.script_name();
381 let script_cmd: &str = match manifest.scripts.get(name) {
382 Some(s) => s.as_str(),
383 None => match hook {
384 LifecycleHook::Install => match default_install_script(package_dir, manifest) {
385 Some(s) => s,
386 None => return Ok(false),
387 },
388 _ => return Ok(false),
389 },
390 };
391 run_script(
392 package_dir,
393 project_root,
394 modules_dir_name,
395 manifest,
396 name,
397 script_cmd,
398 )
399 .await?;
400 Ok(true)
401}
402
403#[derive(Debug, thiserror::Error)]
404pub enum Error {
405 #[error("failed to spawn script {0}: {1}")]
406 Spawn(String, String),
407 #[error("script `{script}` exited with code {code:?}")]
408 NonZeroExit { script: String, code: Option<i32> },
409}