Skip to main content

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    // Strip credentials that aube itself owns before we spawn any
119    // lifecycle script. AUBE_AUTH_TOKEN is aube's own registry login
120    // token. No transitive postinstall has any business reading it.
121    // NPM_TOKEN and NODE_AUTH_TOKEN stay untouched because release
122    // flows ("npm publish" in a postpublish script) genuinely need
123    // them. Matches what pnpm does today.
124    cmd.env_remove("AUBE_AUTH_TOKEN");
125    if let Some(node_options) = settings.node_options.as_deref() {
126        cmd.env("NODE_OPTIONS", node_options);
127    }
128    if let Some(unsafe_perm) = settings.unsafe_perm {
129        cmd.env(
130            "npm_config_unsafe_perm",
131            if unsafe_perm { "true" } else { "false" },
132        );
133    }
134    if settings.shell_emulator {
135        cmd.env("npm_config_shell_emulator", "true");
136    }
137}
138
139/// Lifecycle hooks that `aube install` runs against the root package's
140/// `scripts` field, in this order: `preinstall` → (dependencies link) →
141/// `install` → `postinstall` → `prepare`. Matches pnpm / npm.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum LifecycleHook {
144    PreInstall,
145    Install,
146    PostInstall,
147    Prepare,
148}
149
150impl LifecycleHook {
151    pub fn script_name(self) -> &'static str {
152        match self {
153            Self::PreInstall => "preinstall",
154            Self::Install => "install",
155            Self::PostInstall => "postinstall",
156            Self::Prepare => "prepare",
157        }
158    }
159}
160
161/// Dependency lifecycle hooks, in the order aube runs them for each
162/// allowlisted package. `prepare` is intentionally omitted — it's meant
163/// for the root package and git-dep preparation, not installed tarballs.
164pub const DEP_LIFECYCLE_HOOKS: [LifecycleHook; 3] = [
165    LifecycleHook::PreInstall,
166    LifecycleHook::Install,
167    LifecycleHook::PostInstall,
168];
169
170/// Holds the real stderr fd saved before `aube` redirects fd 2 to
171/// `/dev/null` under `--silent`. Child processes spawned through
172/// `child_stderr()` get a fresh dup of this fd so their stderr still
173/// reaches the user's terminal — `--silent` only silences aube's own
174/// output, not the scripts / binaries it invokes (matches `pnpm
175/// --loglevel silent`). A value of `-1` means silent mode is off and
176/// children should inherit stderr normally.
177#[cfg(unix)]
178static SAVED_STDERR_FD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1);
179
180/// Called once by `aube` after it saves + redirects fd 2. Passing
181/// the caller-owned saved fd here means child processes spawned via
182/// `child_stderr()` will write to the real terminal stderr instead of
183/// `/dev/null`.
184#[cfg(unix)]
185pub fn set_saved_stderr_fd(fd: std::os::fd::RawFd) {
186    SAVED_STDERR_FD.store(fd, std::sync::atomic::Ordering::SeqCst);
187}
188
189/// Windows has no equivalent fd-based silencing plumbing: aube's
190/// `SilentStderrGuard` is `libc::dup`/`libc::dup2` on fd 2, and those
191/// calls are gated to unix in `aube`. The stub keeps the public
192/// API shape identical so call sites compile unchanged.
193#[cfg(not(unix))]
194pub fn set_saved_stderr_fd(_fd: i32) {}
195
196/// Returns a `Stdio` suitable for a child process's stderr. When silent
197/// mode is active, this dups the saved real-stderr fd so the child
198/// bypasses the `/dev/null` redirect on fd 2. Otherwise returns
199/// `Stdio::inherit()`.
200#[cfg(unix)]
201pub fn child_stderr() -> std::process::Stdio {
202    let fd = SAVED_STDERR_FD.load(std::sync::atomic::Ordering::SeqCst);
203    if fd < 0 {
204        return std::process::Stdio::inherit();
205    }
206    // SAFETY: `fd` was registered by `set_saved_stderr_fd` from a live
207    // `dup` that `aube`'s `SilentStderrGuard` keeps open for the
208    // duration of main. `BorrowedFd` only borrows, so this does not
209    // transfer ownership.
210    let borrowed = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
211    match borrowed.try_clone_to_owned() {
212        Ok(owned) => std::process::Stdio::from(owned),
213        Err(_) => std::process::Stdio::inherit(),
214    }
215}
216
217#[cfg(not(unix))]
218pub fn child_stderr() -> std::process::Stdio {
219    std::process::Stdio::inherit()
220}
221
222/// Run a single npm-style script line through `sh -c` with the usual
223/// environment (`$PATH` extended with `node_modules/.bin`, `INIT_CWD`,
224/// `npm_lifecycle_event`, `npm_package_name`, `npm_package_version`).
225///
226/// `extra_bin_dir` is an optional directory prepended to `PATH` *before*
227/// the project-level `.bin`. Dep lifecycle scripts pass the dep's own
228/// sibling `node_modules/.bin/` so transitive binaries (e.g.
229/// `prebuild-install`, `node-gyp`) declared in the dep's
230/// `dependencies` are reachable. Root scripts pass `None` — their
231/// transitive bins are already hoisted into the project-level `.bin`.
232///
233/// Inherits stdio from the parent so the user sees script output live.
234/// Returns Err on non-zero exit so install fails fast if a lifecycle
235/// script breaks, matching pnpm.
236pub async fn run_script(
237    script_dir: &Path,
238    project_root: &Path,
239    modules_dir_name: &str,
240    manifest: &PackageJson,
241    script_name: &str,
242    script_cmd: &str,
243    extra_bin_dir: Option<&Path>,
244) -> Result<(), Error> {
245    // PATH prepends (most-local-first): optional `extra_bin_dir` for
246    // dep-local transitive bins, then the project root's
247    // `<modules_dir>/.bin`. For root scripts `script_dir ==
248    // project_root` and `extra_bin_dir` is `None`, which matches the
249    // old behavior. `modules_dir_name` honors pnpm's `modulesDir`
250    // setting — defaults to `"node_modules"` at the call site, but a
251    // workspace may have configured something else.
252    let project_bin = project_root.join(modules_dir_name).join(".bin");
253    let path = std::env::var_os("PATH").unwrap_or_default();
254    let mut entries: Vec<PathBuf> = Vec::new();
255    if let Some(dir) = extra_bin_dir {
256        entries.push(dir.to_path_buf());
257    }
258    entries.push(project_bin);
259    entries.extend(std::env::split_paths(&path));
260    let new_path = std::env::join_paths(entries).unwrap_or(path);
261
262    let mut cmd = spawn_shell(script_cmd);
263    cmd.current_dir(script_dir)
264        .stderr(child_stderr())
265        .env("PATH", &new_path)
266        .env("npm_lifecycle_event", script_name);
267
268    // Pass INIT_CWD the way npm/pnpm do — the directory the user
269    // invoked the package manager from, *not* the script's own cwd.
270    // Native-module build tooling (node-gyp, prebuild-install, etc.)
271    // reads INIT_CWD to locate the project root when caching binaries.
272    // Preserve if already set by a parent aube invocation so nested
273    // scripts see the outermost cwd.
274    if std::env::var_os("INIT_CWD").is_none() {
275        cmd.env("INIT_CWD", project_root);
276    }
277
278    if let Some(ref name) = manifest.name {
279        cmd.env("npm_package_name", name);
280    }
281    if let Some(ref version) = manifest.version {
282        cmd.env("npm_package_version", version);
283    }
284
285    tracing::debug!("lifecycle: {script_name} → {script_cmd}");
286    let status = cmd
287        .status()
288        .await
289        .map_err(|e| Error::Spawn(script_name.to_string(), e.to_string()))?;
290
291    if !status.success() {
292        return Err(Error::NonZeroExit {
293            script: script_name.to_string(),
294            code: status.code(),
295        });
296    }
297
298    Ok(())
299}
300
301/// Run a lifecycle hook against the root package, if a script for it is
302/// defined. Returns `Ok(false)` if the hook wasn't defined (no-op),
303/// `Ok(true)` if it ran successfully.
304///
305/// The caller is responsible for gating on `--ignore-scripts`.
306pub async fn run_root_hook(
307    project_dir: &Path,
308    modules_dir_name: &str,
309    manifest: &PackageJson,
310    hook: LifecycleHook,
311) -> Result<bool, Error> {
312    let name = hook.script_name();
313    let Some(script_cmd) = manifest.scripts.get(name) else {
314        return Ok(false);
315    };
316    run_script(
317        project_dir,
318        project_dir,
319        modules_dir_name,
320        manifest,
321        name,
322        script_cmd,
323        None,
324    )
325    .await?;
326    Ok(true)
327}
328
329/// Single source of truth for the implicit `node-gyp rebuild`
330/// fallback: returns `Some("node-gyp rebuild")` when the package ships
331/// a `binding.gyp` at its root AND the manifest leaves both `install`
332/// and `preinstall` empty (either one is the author's explicit
333/// opt-out from the default).
334///
335/// `has_binding_gyp` is passed by the caller so this helper is
336/// agnostic to *how* presence was detected — the install pipeline
337/// stats the materialized package dir, while `aube ignored-builds`
338/// reads the store `PackageIndex` since the package may not be
339/// linked into `node_modules` yet. Both paths must agree on the gate
340/// condition, so they both go through this.
341pub fn implicit_install_script(
342    manifest: &PackageJson,
343    has_binding_gyp: bool,
344) -> Option<&'static str> {
345    if !has_binding_gyp {
346        return None;
347    }
348    if manifest
349        .scripts
350        .contains_key(LifecycleHook::Install.script_name())
351        || manifest
352            .scripts
353            .contains_key(LifecycleHook::PreInstall.script_name())
354    {
355        return None;
356    }
357    Some("node-gyp rebuild")
358}
359
360/// Default `install` command for a materialized dependency directory.
361/// Thin wrapper around [`implicit_install_script`] that supplies
362/// `has_binding_gyp` by stat'ing `<package_dir>/binding.gyp`.
363pub fn default_install_script(package_dir: &Path, manifest: &PackageJson) -> Option<&'static str> {
364    implicit_install_script(manifest, package_dir.join("binding.gyp").is_file())
365}
366
367/// True if [`run_dep_hook`] would actually execute something for this
368/// package across any of the dependency lifecycle hooks. Callers use
369/// this to skip fan-out work for packages that have nothing to run —
370/// including the implicit `node-gyp rebuild` default.
371pub fn has_dep_lifecycle_work(package_dir: &Path, manifest: &PackageJson) -> bool {
372    if DEP_LIFECYCLE_HOOKS
373        .iter()
374        .any(|h| manifest.scripts.contains_key(h.script_name()))
375    {
376        return true;
377    }
378    default_install_script(package_dir, manifest).is_some()
379}
380
381/// Run a lifecycle hook against an installed dependency's package
382/// directory. Mirrors [`run_root_hook`] but spawns inside `package_dir`
383/// (the actual linked package directory, e.g.
384/// `node_modules/.aube/<dep_path>/node_modules/<name>`). The manifest
385/// is the dependency's own `package.json`, *not* the project root's.
386///
387/// `dep_modules_dir` is the dep's sibling `node_modules/` — i.e.
388/// `package_dir`'s parent for unscoped packages, or `package_dir`'s
389/// grandparent for scoped (`@scope/name`). `<dep_modules_dir>/.bin`
390/// is prepended to `PATH` so the dep's postinstall can spawn tools
391/// declared in its own `dependencies` (the transitive-bin case —
392/// `prebuild-install`, `node-gyp`, `napi-postinstall`). The install
393/// driver writes shims there via `link_dep_bins`; `rebuild` mirrors
394/// the same pass.
395///
396/// For the `install` hook specifically, if the manifest leaves both
397/// `install` and `preinstall` empty but the package has a top-level
398/// `binding.gyp`, this falls back to running `node-gyp rebuild` — the
399/// node-gyp default that npm and pnpm both honor so native modules
400/// without a prebuilt binary still compile on install.
401///
402/// The caller is responsible for gating on `BuildPolicy` and
403/// `--ignore-scripts`. Returns `Ok(false)` if the hook wasn't defined.
404pub async fn run_dep_hook(
405    package_dir: &Path,
406    dep_modules_dir: &Path,
407    project_root: &Path,
408    modules_dir_name: &str,
409    manifest: &PackageJson,
410    hook: LifecycleHook,
411) -> Result<bool, Error> {
412    let name = hook.script_name();
413    let script_cmd: &str = match manifest.scripts.get(name) {
414        Some(s) => s.as_str(),
415        None => match hook {
416            LifecycleHook::Install => match default_install_script(package_dir, manifest) {
417                Some(s) => s,
418                None => return Ok(false),
419            },
420            _ => return Ok(false),
421        },
422    };
423    let dep_bin_dir = dep_modules_dir.join(".bin");
424    run_script(
425        package_dir,
426        project_root,
427        modules_dir_name,
428        manifest,
429        name,
430        script_cmd,
431        Some(&dep_bin_dir),
432    )
433    .await?;
434    Ok(true)
435}
436
437#[derive(Debug, thiserror::Error)]
438pub enum Error {
439    #[error("failed to spawn script {0}: {1}")]
440    Spawn(String, String),
441    #[error("script `{script}` exited with code {code:?}")]
442    NonZeroExit { script: String, code: Option<i32> },
443}