From 27ee04faba0abd7aeb77d9d7bde004c50cc788bd Mon Sep 17 00:00:00 2001 From: Jeff Dickey Date: Thu, 11 Jun 2026 15:29:30 -0500 Subject: [PATCH 1/7] feat(runtime): node version switching and aube self-version management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects can now pin Node (devEngines.runtime / .node-version / .nvmrc) and aube itself (packageManager / devEngines.packageManager); aube applies both with no shims or shell activation — running through aube is the switch. Node runtime switching: - resolution order: PATH node, installed versions (mise installs are reused read-only; aube's own dir), then download per the new runtimeInstaller setting (auto = delegate to mise when present) - resolved bin dir injected at every spawn site: run/aubr, exec shebangs, aubx/dlx, lifecycle scripts, and the build jail - devEngines pins are recorded in the lockfile using pnpm 10.14+'s node@runtime: shape; parsing those entries also fixes reading pnpm-11 lockfiles that carry them - engines checks validate against the switched node; nodeVersion stays validation-only (pnpm semantics) - new `aube runtime set|list` (pnpm 11 parity), doctor rows, and runtimeOnFail as the air-gapped opt-out aube self-version switching (corepack semantics): - managePackageManagerVersions (previously a documented no-op, default true) now locates or installs the pinned aube — mise installs reused, GitHub release downloads otherwise — and re-execs it with the same argv, preserving the aube/aubr/aubx multicall name - a guard env degrades broken installs to a warning, never a loop - release workflow now publishes a .sha256 per asset; self-downloads verify against it Co-Authored-By: Claude Fable 5 --- .rules | 3 +- Cargo.lock | 25 + Cargo.toml | 1 + README.md | 17 +- aube.usage.kdl | 15 + crates/aube-codes/src/errors.rs | 52 ++ crates/aube-codes/src/warnings.rs | 25 + crates/aube-lockfile/src/drift.rs | 54 +- crates/aube-lockfile/src/lib.rs | 98 ++++ crates/aube-lockfile/src/merge.rs | 18 + crates/aube-lockfile/src/pnpm/format.rs | 26 +- crates/aube-lockfile/src/pnpm/raw.rs | 49 ++ crates/aube-lockfile/src/pnpm/read.rs | 118 ++++- crates/aube-lockfile/src/pnpm/tests.rs | 253 +++++++++ crates/aube-lockfile/src/pnpm/write.rs | 149 ++++++ crates/aube-lockfile/src/yarn/tests.rs | 1 + crates/aube-manifest/src/dev_engines.rs | 299 +++++++++++ crates/aube-manifest/src/lib.rs | 15 + crates/aube-manifest/src/workspace/config.rs | 24 + crates/aube-resolver/src/peer_context.rs | 6 + crates/aube-resolver/src/resolve.rs | 1 + crates/aube-resolver/src/tests.rs | 2 + crates/aube-runtime/Cargo.toml | 33 ++ crates/aube-runtime/src/discover.rs | 247 +++++++++ crates/aube-runtime/src/error.rs | 82 +++ crates/aube-runtime/src/extract.rs | 302 +++++++++++ crates/aube-runtime/src/http.rs | 163 ++++++ crates/aube-runtime/src/index.rs | 317 ++++++++++++ crates/aube-runtime/src/installer.rs | 240 +++++++++ crates/aube-runtime/src/lib.rs | 181 +++++++ crates/aube-runtime/src/mise.rs | 97 ++++ crates/aube-runtime/src/paths.rs | 92 ++++ crates/aube-runtime/src/platform.rs | 191 +++++++ crates/aube-runtime/src/progress.rs | 22 + crates/aube-runtime/src/resolver.rs | 473 +++++++++++++++++ crates/aube-runtime/src/self_install.rs | 476 +++++++++++++++++ crates/aube-runtime/src/shasums.rs | 142 +++++ crates/aube-runtime/src/sources.rs | 209 ++++++++ crates/aube-runtime/src/spec.rs | 198 +++++++ crates/aube-scripts/src/lib.rs | 32 +- crates/aube-settings/settings.toml | 89 +++- crates/aube/Cargo.toml | 1 + crates/aube/src/commands/dlx.rs | 27 +- crates/aube/src/commands/doctor.rs | 50 +- crates/aube/src/commands/exec.rs | 21 +- crates/aube/src/commands/install/fetch.rs | 2 +- crates/aube/src/commands/install/mod.rs | 35 +- crates/aube/src/commands/install/resolve.rs | 8 + crates/aube/src/commands/mod.rs | 1 + crates/aube/src/commands/run.rs | 15 +- crates/aube/src/commands/runtime.rs | 379 ++++++++++++++ crates/aube/src/commands/script_settings.rs | 8 + crates/aube/src/commands/security_scanner.rs | 6 +- crates/aube/src/engines.rs | 22 + crates/aube/src/main.rs | 11 + crates/aube/src/pnpmfile.rs | 4 +- crates/aube/src/runtime.rs | 518 +++++++++++++++++++ crates/aube/src/self_version.rs | 345 ++++++++++++ crates/aube/src/startup.rs | 22 +- docs/.vitepress/config.mts | 1 + docs/cli/commands.json | 124 +++++ docs/cli/index.md | 3 + docs/cli/runtime.md | 12 + docs/cli/runtime/list.md | 7 + docs/cli/runtime/set.md | 32 ++ docs/error-codes.data.json | 63 ++- docs/package-manager/lockfiles.md | 11 + docs/package-manager/node-runtime.md | 147 ++++++ docs/pnpm-users.md | 29 +- docs/settings/index.md | 90 +++- test/guardrails.bats | 4 + test/runtime.bats | 294 +++++++++++ test/runtime_download.bats | 200 +++++++ test/self_version.bats | 165 ++++++ 74 files changed, 7422 insertions(+), 72 deletions(-) create mode 100644 crates/aube-manifest/src/dev_engines.rs create mode 100644 crates/aube-runtime/Cargo.toml create mode 100644 crates/aube-runtime/src/discover.rs create mode 100644 crates/aube-runtime/src/error.rs create mode 100644 crates/aube-runtime/src/extract.rs create mode 100644 crates/aube-runtime/src/http.rs create mode 100644 crates/aube-runtime/src/index.rs create mode 100644 crates/aube-runtime/src/installer.rs create mode 100644 crates/aube-runtime/src/lib.rs create mode 100644 crates/aube-runtime/src/mise.rs create mode 100644 crates/aube-runtime/src/paths.rs create mode 100644 crates/aube-runtime/src/platform.rs create mode 100644 crates/aube-runtime/src/progress.rs create mode 100644 crates/aube-runtime/src/resolver.rs create mode 100644 crates/aube-runtime/src/self_install.rs create mode 100644 crates/aube-runtime/src/shasums.rs create mode 100644 crates/aube-runtime/src/sources.rs create mode 100644 crates/aube-runtime/src/spec.rs create mode 100644 crates/aube/src/commands/runtime.rs create mode 100644 crates/aube/src/runtime.rs create mode 100644 crates/aube/src/self_version.rs create mode 100644 docs/cli/runtime.md create mode 100644 docs/cli/runtime/list.md create mode 100644 docs/cli/runtime/set.md create mode 100644 docs/package-manager/node-runtime.md create mode 100644 test/runtime.bats create mode 100644 test/runtime_download.bats create mode 100644 test/self_version.bats diff --git a/.rules b/.rules index 43933a010..3118d6020 100644 --- a/.rules +++ b/.rules @@ -23,7 +23,7 @@ lockfile: precedence[6]: aube-lock.yaml, pnpm-lock.yaml, bun.lock, yarn.lock, npm-shrinkwrap.json, package-lock.json frozen_fast_path: install/frozen.rs short-circuits when lockfile + install state match -crates[11]{name,role}: +crates[12]{name,role}: aube,CLI binary, clap dispatch, all command impls, progress UI, state tracking aube-codes,stable ERR_AUBE_*/WARN_AUBE_* code constants + EXIT_TABLE, no runtime deps, every other crate depends on it aube-settings,build.rs codegen from settings.toml, typed accessors, ResolveCtx (cli > env > npmrc > workspace > default) @@ -35,6 +35,7 @@ crates[11]{name,role}: aube-manifest,package.json parser, workspace glob support, extra: BTreeMap for upstream forward-compat aube-scripts,root lifecycle runner, BuildPolicy allowlist for dep scripts (default deny), side-effects cache aube-workspace,pnpm-workspace.yaml discovery, workspace: protocol resolution + aube-runtime,Node version resolution/discovery/install (devEngines.runtime + .nvmrc/.node-version, mise delegation, nodejs.org downloads to $XDG_DATA_HOME/aube/nodejs/, AUBE_RUNTIME_DIR test override) pipeline: install: cli -> resolver (+ lockfile read) -> registry (fetch tarballs) -> store (CAS import) -> linker (virtual store + symlinks) -> scripts (root only by default) diff --git a/Cargo.lock b/Cargo.lock index 59fb2319c..0d2817e9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -232,6 +232,7 @@ dependencies = [ "aube-manifest", "aube-registry", "aube-resolver", + "aube-runtime", "aube-scripts", "aube-settings", "aube-store", @@ -416,6 +417,30 @@ dependencies = [ "zstd", ] +[[package]] +name = "aube-runtime" +version = "1.18.2" +dependencies = [ + "aube-codes", + "aube-manifest", + "aube-util", + "base64", + "flate2", + "hex", + "node-semver", + "reqwest", + "serde", + "serde_json", + "sha2 0.11.0", + "tar", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "xx", + "zip", +] + [[package]] name = "aube-scripts" version = "1.18.2" diff --git a/Cargo.toml b/Cargo.toml index a2f1c9b46..781326144 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -157,6 +157,7 @@ aube-store = { path = "crates/aube-store", version = "1.18.2" } aube-linker = { path = "crates/aube-linker", version = "1.18.2" } aube-lockfile = { path = "crates/aube-lockfile", version = "1.18.2" } aube-manifest = { path = "crates/aube-manifest", version = "1.18.2" } +aube-runtime = { path = "crates/aube-runtime", version = "1.18.2" } aube-scripts = { path = "crates/aube-scripts", version = "1.18.2" } aube-workspace = { path = "crates/aube-workspace", version = "1.18.2" } aube-util = { path = "crates/aube-util", version = "1.18.2" } diff --git a/README.md b/README.md index a4cdddd92..20f2089f6 100644 --- a/README.md +++ b/README.md @@ -48,14 +48,13 @@ The recommended path is mise: mise use -g aube ``` -mise can also manage the [Node.js versions](https://mise.jdx.dev/lang/node.html) -your projects need. If your projects already pin Node through `package.json` -(`devEngines.runtime`), `.nvmrc`, or `.node-version`, enable mise's -idiomatic version-file support for Node: - -```sh -mise settings add idiomatic_version_file_enable_tools node -``` +aube switches [Node.js versions](https://aube.jdx.dev/package-manager/node-runtime) +itself: if a project pins Node through `package.json` +(`devEngines.runtime`), `.nvmrc`, or `.node-version`, every script and +binary run through aube gets that version — no shims or shell hooks. +When a pinned version is missing, aube delegates the install to mise if +you have it (one shared Node store on disk) and downloads from +nodejs.org otherwise. Check that it is on your `PATH`: @@ -259,7 +258,7 @@ aube store path aube store prune ``` -Some pnpm commands are intentionally out of scope. Runtime-management commands such as `env`, `runtime`, `setup`, and `self-update` belong in tools like mise. Registry account helpers such as `whoami`, `token`, `owner`, `search`, `pkg`, and `set-script` are compatibility stubs that point you to the npm command instead. +aube also matches pnpm 11's runtime surface: `aube runtime set node ` pins a Node version in `devEngines.runtime` and the lockfile, and `aube runtime list` shows what a project resolves to. Some pnpm commands are intentionally out of scope: `setup` and `self-update` belong in tools like mise, and registry account helpers such as `whoami`, `token`, `owner`, `search`, `pkg`, and `set-script` are compatibility stubs that point you to the npm command instead. ## Learn More diff --git a/aube.usage.kdl b/aube.usage.kdl index 9c5d3b046..e32563861 100644 --- a/aube.usage.kdl +++ b/aube.usage.kdl @@ -1584,6 +1584,21 @@ cmd run help="Run a script defined in package.json" { arg "[SCRIPT]" help="Script or local binary name" help_long="Script or local binary name.\n\nOmit on an interactive TTY to pick from `package.json` scripts. If no script matches, aube falls back to `node_modules/.bin/`." required=#false arg "[ARGS]…" help="Arguments to pass to the script" required=#false double_dash=automatic var=#true } +cmd runtime subcommand_required=#true help="Manage the project's Node.js runtime (pin, install, inspect)" { + alias rt + cmd list help="Show the resolved runtime and installed versions" { + alias ls + } + cmd set help="Pin a runtime in package.json devEngines.runtime and install it" { + flag "-g --global" help="Install for the user instead of the project (delegates to `mise use -g node@` when mise manages installs)" + flag --on-fail help="`onFail` policy written to devEngines.runtime" default=download { + arg + } + flag --save-exact help="Pin the exact resolved version instead of a caret range" + arg help="Runtime name (only `node` is supported)" + arg help="Version request: an exact version, a range (`^24`, `22`), `lts`, `latest`, or an LTS codename (`lts/jod`)" + } +} cmd sbom help="Generate a Software Bill of Materials (CycloneDX or SPDX)" { flag "-D --dev" help="Show only devDependencies" flag --format help="Output format: `cyclonedx` (default) or `spdx`" default=cyclonedx { diff --git a/crates/aube-codes/src/errors.rs b/crates/aube-codes/src/errors.rs index 2034059f4..d865c24ff 100644 --- a/crates/aube-codes/src/errors.rs +++ b/crates/aube-codes/src/errors.rs @@ -88,6 +88,15 @@ pub const ERR_AUBE_REMOVE_PRIOR_INSTALL_DIR: &str = "ERR_AUBE_REMOVE_PRIOR_INSTA pub const ERR_AUBE_CONFIG_NESTED_AUBE_KEY: &str = "ERR_AUBE_CONFIG_NESTED_AUBE_KEY"; pub const ERR_AUBE_CONFLICTING_BUILD_FLAGS: &str = "ERR_AUBE_CONFLICTING_BUILD_FLAGS"; +// ── node runtime (devEngines.runtime / .node-version / .nvmrc) ────── +#[rustfmt::skip] pub const ERR_AUBE_RUNTIME_VERSION_UNSATISFIED: &str = "ERR_AUBE_RUNTIME_VERSION_UNSATISFIED"; +#[rustfmt::skip] pub const ERR_AUBE_RUNTIME_NO_MATCHING_VERSION: &str = "ERR_AUBE_RUNTIME_NO_MATCHING_VERSION"; +pub const ERR_AUBE_RUNTIME_DOWNLOAD_FAILED: &str = "ERR_AUBE_RUNTIME_DOWNLOAD_FAILED"; +#[rustfmt::skip] pub const ERR_AUBE_RUNTIME_CHECKSUM_MISMATCH: &str = "ERR_AUBE_RUNTIME_CHECKSUM_MISMATCH"; +pub const ERR_AUBE_RUNTIME_EXTRACT_FAILED: &str = "ERR_AUBE_RUNTIME_EXTRACT_FAILED"; +#[rustfmt::skip] pub const ERR_AUBE_RUNTIME_MISE_INSTALL_FAILED: &str = "ERR_AUBE_RUNTIME_MISE_INSTALL_FAILED"; +#[rustfmt::skip] pub const ERR_AUBE_RUNTIME_UNSUPPORTED_PLATFORM: &str = "ERR_AUBE_RUNTIME_UNSUPPORTED_PLATFORM"; + // ── misc tracing::error! sites (non-fatal but high-severity) ──────── pub const ERR_AUBE_PATCHES_TRACKING_WRITE: &str = "ERR_AUBE_PATCHES_TRACKING_WRITE"; pub const ERR_AUBE_UNSAFE_SHEBANG_INTERPRETER: &str = "ERR_AUBE_UNSAFE_SHEBANG_INTERPRETER"; @@ -447,6 +456,49 @@ pub const ALL: &[CodeMeta] = &[ description: "`aube add` was passed the same package name in both `--allow-build` and `--deny-build`.", exit_code: None, }, + // Node runtime (devEngines.runtime / .node-version / .nvmrc) + CodeMeta { + name: ERR_AUBE_RUNTIME_VERSION_UNSATISFIED, + category: category::ENGINE_CLI, + description: "The project requires a Node.js version that isn't available and policy forbids fetching it (`devEngines.runtime` with `onFail: \"error\"`, `runtimeOnFail=error`, or offline mode with nothing installed).", + exit_code: Some(83), + }, + CodeMeta { + name: ERR_AUBE_RUNTIME_NO_MATCHING_VERSION, + category: category::ENGINE_CLI, + description: "No Node.js release in the dist index satisfies the requested version (bad range, unknown LTS codename, or no build for this platform).", + exit_code: Some(84), + }, + CodeMeta { + name: ERR_AUBE_RUNTIME_DOWNLOAD_FAILED, + category: category::ENGINE_CLI, + description: "Fetching the Node.js dist index, checksum file, or release archive failed after retries.", + exit_code: Some(85), + }, + CodeMeta { + name: ERR_AUBE_RUNTIME_CHECKSUM_MISMATCH, + category: category::ENGINE_CLI, + description: "A downloaded Node.js archive's SHA-256 didn't match the lockfile pin or `SHASUMS256.txt`. The archive is discarded and never retried automatically.", + exit_code: Some(86), + }, + CodeMeta { + name: ERR_AUBE_RUNTIME_EXTRACT_FAILED, + category: category::ENGINE_CLI, + description: "A Node.js release archive was corrupt or contained unsafe entry paths.", + exit_code: Some(87), + }, + CodeMeta { + name: ERR_AUBE_RUNTIME_MISE_INSTALL_FAILED, + category: category::ENGINE_CLI, + description: "Delegating a Node.js install to mise failed — `mise install node@` exited non-zero or the install wasn't discoverable afterwards. Fatal only under `runtimeInstaller=mise`; `auto` falls back to aube's own download.", + exit_code: Some(88), + }, + CodeMeta { + name: ERR_AUBE_RUNTIME_UNSUPPORTED_PLATFORM, + category: category::ENGINE_CLI, + description: "No official Node.js build exists for this OS/architecture/libc. Set `nodeDownloadMirrors` to a mirror that carries one, or install Node via mise/system.", + exit_code: Some(89), + }, // Misc / safety CodeMeta { name: ERR_AUBE_UNSAFE_INDEX_KEY, diff --git a/crates/aube-codes/src/warnings.rs b/crates/aube-codes/src/warnings.rs index 78d1e0352..4760d3176 100644 --- a/crates/aube-codes/src/warnings.rs +++ b/crates/aube-codes/src/warnings.rs @@ -98,6 +98,11 @@ pub const WARN_AUBE_OSV_MIRROR_REFRESH_FAILED: &str = "WARN_AUBE_OSV_MIRROR_REFR pub const WARN_AUBE_OSV_BLOOM_REFRESH_FAILED: &str = "WARN_AUBE_OSV_BLOOM_REFRESH_FAILED"; pub const WARN_AUBE_SECURITY_SCANNER_FINDING: &str = "WARN_AUBE_SECURITY_SCANNER_FINDING"; +// ── node runtime ──────────────────────────────────────────────────── +#[rustfmt::skip] pub const WARN_AUBE_RUNTIME_VERSION_MISMATCH: &str = "WARN_AUBE_RUNTIME_VERSION_MISMATCH"; +pub const WARN_AUBE_RUNTIME_MISE_FALLBACK: &str = "WARN_AUBE_RUNTIME_MISE_FALLBACK"; +#[rustfmt::skip] pub const WARN_AUBE_RUNTIME_PIN_NOT_RECORDED: &str = "WARN_AUBE_RUNTIME_PIN_NOT_RECORDED"; + /// Stable category labels that group codes in the generated docs. /// Public so the docs generator can iterate them deterministically. pub mod category { @@ -115,6 +120,7 @@ pub mod category { pub const PROGRESS_UI: &str = "Progress UI"; pub const WORKSPACE_RECURSION: &str = "Workspace recursion"; pub const SUPPLY_CHAIN: &str = "Supply chain (add-time)"; + pub const NODE_RUNTIME: &str = "Node runtime"; } /// Registry of every warning code with its category and description. @@ -507,4 +513,23 @@ pub const ALL: &[CodeMeta] = &[ description: "User-configured `securityScanner` returned a `warn`-level advisory. Install continues — only `fatal`-level advisories block.", exit_code: None, }, + // Node runtime + CodeMeta { + name: WARN_AUBE_RUNTIME_VERSION_MISMATCH, + category: category::NODE_RUNTIME, + description: "The active Node.js doesn't satisfy the project's runtime requirement and `onFail: \"warn\"` keeps execution on the unsatisfying version.", + exit_code: None, + }, + CodeMeta { + name: WARN_AUBE_RUNTIME_MISE_FALLBACK, + category: category::NODE_RUNTIME, + description: "`runtimeInstaller=auto` tried delegating a Node.js install to mise but mise failed; aube fell back to its own nodejs.org download.", + exit_code: None, + }, + CodeMeta { + name: WARN_AUBE_RUNTIME_PIN_NOT_RECORDED, + category: category::NODE_RUNTIME, + description: "`devEngines.runtime` resolved to an exact Node.js version but the project's lockfile format (npm/yarn/bun) has no runtime entry shape, so the pin wasn't recorded. Subsequent runs re-resolve the range.", + exit_code: None, + }, ]; diff --git a/crates/aube-lockfile/src/drift.rs b/crates/aube-lockfile/src/drift.rs index da35409f9..062767c18 100644 --- a/crates/aube-lockfile/src/drift.rs +++ b/crates/aube-lockfile/src/drift.rs @@ -241,11 +241,16 @@ impl LockfileGraph { workspace_catalogs, ); let locked = resolve_catalog_refs_in_overrides(&self.overrides, workspace_catalogs); - overrides_drift_reason(&locked, &effective).or_else(|| { - let mut effective_ignored = manifest.pnpm_ignored_optional_dependencies(); - effective_ignored.extend(workspace_ignored_optional.iter().cloned()); - ignored_optional_drift_reason(&self.ignored_optional_dependencies, &effective_ignored) - }) + overrides_drift_reason(&locked, &effective) + .or_else(|| { + let mut effective_ignored = manifest.pnpm_ignored_optional_dependencies(); + effective_ignored.extend(workspace_ignored_optional.iter().cloned()); + ignored_optional_drift_reason( + &self.ignored_optional_dependencies, + &effective_ignored, + ) + }) + .or_else(|| runtime_drift_reason(&self.runtimes, manifest)) } /// Compare this lockfile's catalog snapshot against the current @@ -661,6 +666,44 @@ fn ignored_optional_drift_reason( None } +/// Compare recorded runtime pins against the manifest's +/// `devEngines.runtime` declarations. +/// +/// Only an *existing* pin can drift here: the requested range changed, +/// or the manifest dropped the devEngines entry the pin came from. The +/// inverse case — devEngines present but no pin recorded yet — is +/// deliberately not drift, because formats that can't record runtime +/// pins (npm/yarn/bun) would read as permanently stale. The install +/// driver adds the missing pin on formats that support it. +fn runtime_drift_reason( + runtimes: &BTreeMap, + manifest: &aube_manifest::PackageJson, +) -> Option { + for (name, pin) in runtimes { + let declared = manifest + .dev_engines + .as_ref() + .and_then(|d| d.runtime.iter().find(|r| r.name == *name)) + .and_then(|r| r.version.as_deref()); + match declared { + None => { + return Some(format!( + "devEngines.runtime: manifest no longer pins {name} (lockfile records {})", + pin.version + )); + } + Some(range) if range != pin.specifier => { + return Some(format!( + "devEngines.runtime: {name} changed ({} → {range})", + pin.specifier + )); + } + Some(_) => {} + } + } + None +} + /// Result of comparing a lockfile against a manifest. #[derive(Debug, Clone, PartialEq, Eq)] pub enum DriftStatus { @@ -696,6 +739,7 @@ mod drift_tests { update_config: None, scripts: BTreeMap::new(), engines: BTreeMap::new(), + dev_engines: None, workspaces: None, bundled_dependencies: None, extra: BTreeMap::new(), diff --git a/crates/aube-lockfile/src/lib.rs b/crates/aube-lockfile/src/lib.rs index c93ba6b32..82bdacc06 100644 --- a/crates/aube-lockfile/src/lib.rs +++ b/crates/aube-lockfile/src/lib.rs @@ -115,6 +115,17 @@ pub struct LockfileGraph { /// parser is responsible for deduping if the source lockfile /// carried a duplicate. pub trusted_dependencies: Vec, + /// Pinned runtimes (pnpm 10.14+ `devEngines.runtime` recording), + /// keyed by runtime name (`node`). pnpm models a pinned runtime as + /// a synthetic importer dep whose specifier/version carry a + /// `runtime:` prefix plus a `packages:` entry keyed + /// `@runtime:` holding a `variations` resolution + /// with one downloadable artifact per platform. aube lifts that + /// encoding into this typed map on parse and re-emits the pnpm + /// shape on write (aube-lock.yaml and pnpm-lock.yaml share the + /// writer). Foreign formats (npm/yarn/bun) have no runtime shape: + /// their parsers leave this empty and their writers skip it. + pub runtimes: BTreeMap, /// Top-level lockfile fields that aren't explicitly modeled on /// `LockfileGraph`. Populated by per-format parsers on best-effort /// basis so the writer can re-emit blocks a future lockfile @@ -138,6 +149,81 @@ pub struct CatalogEntry { pub version: String, } +/// A pinned runtime (Node.js) recorded in the lockfile. Mirrors pnpm +/// 10.14+'s `devEngines.runtime` encoding: the manifest's requested +/// range plus the exact resolved version, and one downloadable +/// artifact per supported platform so any machine reading the +/// lockfile can fetch the same release without re-resolving. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RuntimePin { + /// The requested range from `devEngines.runtime.version`, without + /// the `runtime:` prefix pnpm adds in the importer entry + /// (`"^24.4.0"`). + pub specifier: String, + /// Exact resolved version (`"24.4.1"`). + pub version: String, + /// Whether the importer entry sits under `devDependencies` + /// (devEngines-sourced pins do; pnpm only emits this form today). + pub dev: bool, + /// `hasBin` flag on the packages entry — always true for real + /// runtime pins; round-tripped for byte fidelity. + pub has_bin: bool, + /// Per-platform artifacts from the `variations` resolution. + pub variants: Vec, +} + +impl RuntimePin { + /// The variant whose target list matches `(os, cpu, libc)`. `libc` + /// follows pnpm's convention: `Some("musl")` matches only + /// musl-tagged targets; `None` matches targets without a libc tag. + pub fn variant_for(&self, os: &str, cpu: &str, libc: Option<&str>) -> Option<&RuntimeVariant> { + self.variants.iter().find(|v| { + v.targets + .iter() + .any(|t| t.os == os && t.cpu == cpu && t.libc.as_deref() == libc) + }) + } +} + +/// One platform-specific artifact inside a runtime pin's `variations` +/// resolution. Field set mirrors pnpm's `BinaryResolution` + +/// `PlatformAssetResolution` pair. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RuntimeVariant { + /// Platforms this artifact serves (usually exactly one). + pub targets: Vec, + /// `"tarball"` or `"zip"`. + pub archive: String, + /// Download URL for the artifact. + pub url: String, + /// SRI integrity (`sha256-` — Node publishes SHA-256 + /// checksums for release artifacts). + pub integrity: String, + /// Executable map. pnpm writes either a bare string (`bin/node`, + /// meaning the `node` bin) or a `name → path` map; both parse into + /// this struct and the original shape round-trips via + /// [`Self::bin_is_bare_string`]. + pub bin: BTreeMap, + /// True when the source lockfile wrote `bin:` as a bare string; + /// preserved so a parse/write cycle stays byte-identical. + pub bin_is_bare_string: bool, + /// Top-level directory to strip when extracting (pnpm sets this on + /// zip archives, whose entries are rooted at + /// `node-v-win-/`). + pub prefix: Option, +} + +/// One `(os, cpu, libc)` triple a runtime variant targets. Values use +/// Node's `process.platform` / `process.arch` vocabulary (`win32`, +/// `darwin`, `linux`; `x64`, `arm64`), with `libc: Some("musl")` only +/// on musl builds. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RuntimeTarget { + pub os: String, + pub cpu: String, + pub libc: Option, +} + /// Per-graph settings that mirror pnpm v9's `settings:` header. /// Extend as more knobs become round-trip-aware. #[derive(Debug, Clone, PartialEq, Eq)] @@ -506,6 +592,9 @@ impl LockfileGraph { bun_config_version: self.bun_config_version, patched_dependencies: self.patched_dependencies.clone(), trusted_dependencies: self.trusted_dependencies.clone(), + // Runtime pins are graph-wide resolution intent, same as + // overrides/catalogs — structural filters carry them. + runtimes: self.runtimes.clone(), extra_fields: self.extra_fields.clone(), workspace_extra_fields: self.workspace_extra_fields.clone(), } @@ -566,6 +655,7 @@ impl LockfileGraph { bun_config_version: self.bun_config_version, patched_dependencies: self.patched_dependencies.clone(), trusted_dependencies: self.trusted_dependencies.clone(), + runtimes: self.runtimes.clone(), extra_fields: self.extra_fields.clone(), workspace_extra_fields: self.workspace_extra_fields.clone(), }) @@ -618,6 +708,14 @@ impl LockfileGraph { if self.trusted_dependencies.is_empty() { self.trusted_dependencies = prior.trusted_dependencies.clone(); } + // Runtime pins can't be recovered from a fresh package resolve + // (they come from devEngines resolution, a separate pass), so a + // re-resolved graph that hasn't re-pinned yet inherits the + // prior pin. The install driver overwrites it when the + // devEngines range drifted. + if self.runtimes.is_empty() { + self.runtimes = prior.runtimes.clone(); + } if self.extra_fields.is_empty() { self.extra_fields = prior.extra_fields.clone(); } diff --git a/crates/aube-lockfile/src/merge.rs b/crates/aube-lockfile/src/merge.rs index 2fc7078ad..08ad0f197 100644 --- a/crates/aube-lockfile/src/merge.rs +++ b/crates/aube-lockfile/src/merge.rs @@ -264,6 +264,24 @@ fn merge_into(dst: &mut LockfileGraph, src: LockfileGraph, report: &mut MergeRep } } } + for (name, incoming) in src.runtimes { + use std::collections::btree_map::Entry; + match dst.runtimes.entry(name) { + Entry::Vacant(slot) => { + slot.insert(incoming); + } + Entry::Occupied(slot) => { + if slot.get().version != incoming.version { + report.conflicts.push(format!( + "runtime `{}`: kept {} over {}", + slot.key(), + slot.get().version, + incoming.version + )); + } + } + } + } let mut seen: aube_util::collections::FxSet = dst.trusted_dependencies.iter().cloned().collect(); for name in src.trusted_dependencies { diff --git a/crates/aube-lockfile/src/pnpm/format.rs b/crates/aube-lockfile/src/pnpm/format.rs index be3ba792d..2ff016502 100644 --- a/crates/aube-lockfile/src/pnpm/format.rs +++ b/crates/aube-lockfile/src/pnpm/format.rs @@ -28,21 +28,41 @@ pub(super) fn reformat_for_pnpm_parity(yaml: &str) -> String { if is_flow_candidate && i + 1 < lines.len() { let inner_indent = indent + 2; let mut entries: Vec = Vec::new(); + let mut all_scalar = true; let mut j = i + 1; while j < lines.len() { let next = lines[j]; let n_stripped = next.trim_start(); let n_indent = next.len() - n_stripped.len(); - if n_stripped.is_empty() || n_indent != inner_indent { + if n_stripped.is_empty() || n_indent < inner_indent { + break; + } + if n_indent > inner_indent { + // Nested structure (e.g. a `variants:` list inside + // a `type: variations` resolution) — flow form + // can't represent it with this rewriter; keep the + // whole block as-is. + all_scalar = false; break; } match n_stripped.split_once(": ") { Some((k, v)) => entries.push(format!("{k}: {v}")), - None => break, + None => { + // A key with no inline value (`variants:`) + // introduces a nested block — leave it alone. + all_scalar = false; + break; + } } j += 1; } - if !entries.is_empty() { + // pnpm renders `binary` / `variations` resolutions in + // block form even when (like a map-less binary) every + // field happens to be scalar — match that. + let block_form_type = entries + .iter() + .any(|e| e == "type: binary" || e == "type: variations"); + if all_scalar && !block_form_type && !entries.is_empty() { compact.push(format!( "{}{}: {{{}}}", " ".repeat(indent), diff --git a/crates/aube-lockfile/src/pnpm/raw.rs b/crates/aube-lockfile/src/pnpm/raw.rs index 9ddf07219..b2c008ead 100644 --- a/crates/aube-lockfile/src/pnpm/raw.rs +++ b/crates/aube-lockfile/src/pnpm/raw.rs @@ -238,6 +238,55 @@ pub(super) struct Resolution { /// encoding it in the snapshot key. #[serde(default, deserialize_with = "deserialize_subpath")] pub(super) path: Option, + /// pnpm 10.14+ `type: variations` resolution (runtime pins like + /// `node@runtime:24.4.1`): one downloadable artifact per platform. + /// `None` for every ordinary package resolution. + #[serde(default)] + pub(super) variants: Option>, +} + +/// One entry of a `variations` resolution's `variants:` list — +/// pnpm's `PlatformAssetResolution` (a binary artifact plus the +/// platform targets it serves). +#[derive(Debug, Deserialize)] +pub(super) struct RawRuntimeVariant { + pub(super) targets: Vec, + pub(super) resolution: RawBinaryResolution, +} + +#[derive(Debug, Deserialize)] +pub(super) struct RawRuntimeTarget { + pub(super) os: String, + pub(super) cpu: String, + #[serde(default)] + pub(super) libc: Option, +} + +/// pnpm's `BinaryResolution` (`type: binary`). +#[derive(Debug, Deserialize)] +pub(super) struct RawBinaryResolution { + #[serde(default, rename = "type")] + #[allow(dead_code)] + pub(super) type_: Option, + #[serde(default)] + pub(super) archive: Option, + pub(super) url: String, + #[serde(default)] + pub(super) integrity: Option, + #[serde(default)] + pub(super) bin: Option, + /// Top-level directory pnpm strips when extracting zip archives. + #[serde(default)] + pub(super) prefix: Option, +} + +/// pnpm's `bin` field on a binary resolution: bare string (the path +/// of a bin named after the package) or a `name → path` map. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub(super) enum RawBinSpec { + Single(String), + Map(BTreeMap), } /// Strip the leading `/` from pnpm's `path:` field so the value lines diff --git a/crates/aube-lockfile/src/pnpm/read.rs b/crates/aube-lockfile/src/pnpm/read.rs index 91610b756..ad8e06ee8 100644 --- a/crates/aube-lockfile/src/pnpm/read.rs +++ b/crates/aube-lockfile/src/pnpm/read.rs @@ -1,10 +1,12 @@ use super::dep_path::{ parse_dep_path, peerless_alias_target, rewrite_snapshot_alias_deps, version_to_dep_path, }; -use super::raw::{RawDepSpec, local_source_from_resolution, parse_raw_lockfile}; +use super::raw::{ + RawBinSpec, RawDepSpec, RawRuntimeVariant, local_source_from_resolution, parse_raw_lockfile, +}; use crate::{ CatalogEntry, DepType, DirectDep, Error, LocalSource, LockedPackage, LockfileGraph, - PeerDepMeta, git_commits_match, + PeerDepMeta, RuntimePin, RuntimeTarget, RuntimeVariant, git_commits_match, }; use aube_util::path::normalize_lexical; use std::collections::BTreeMap; @@ -57,6 +59,34 @@ pub fn parse(path: &Path) -> Result { // Tuple: (alias_dep_path, real_dep_path, alias_name, real_name). let mut alias_remaps: Vec<(String, String, String, String)> = Vec::new(); + // pnpm 10.14+ records `devEngines.runtime` pins as synthetic + // importer deps whose specifier/version carry a `runtime:` prefix + // (`node: {specifier: runtime:^24.4.0, version: runtime:24.4.1}`). + // Those are not packages — route them into `graph.runtimes` + // instead of `DirectDep`s so the resolver/linker never tries to + // fetch `node` from the npm registry. Map: runtime name → + // (specifier, exact version, came from devDependencies). First + // importer to declare a runtime wins (pnpm only writes it on the + // importer whose manifest declares devEngines — the root). + let mut runtime_imports: BTreeMap = BTreeMap::new(); + let mut record_runtime = |name: &str, info: &RawDepSpec, dep_type: DepType| -> bool { + let Some(version) = info.version.strip_prefix("runtime:") else { + return false; + }; + let specifier = info + .specifier + .strip_prefix("runtime:") + .unwrap_or(&info.specifier); + runtime_imports.entry(name.to_string()).or_insert_with(|| { + ( + specifier.to_string(), + version.to_string(), + matches!(dep_type, DepType::Dev), + ) + }); + true + }; + let mut push_direct = |deps: &mut Vec, alias_remaps: &mut Vec<(String, String, String, String)>, importer_path: &str, @@ -216,6 +246,9 @@ pub fn parse(path: &Path) -> Result { if let Some(ref d) = importer.dependencies { for (name, info) in d { + if record_runtime(name, info, DepType::Production) { + continue; + } push_direct( &mut deps, &mut alias_remaps, @@ -228,6 +261,9 @@ pub fn parse(path: &Path) -> Result { } if let Some(ref d) = importer.dev_dependencies { for (name, info) in d { + if record_runtime(name, info, DepType::Dev) { + continue; + } push_direct( &mut deps, &mut alias_remaps, @@ -240,6 +276,9 @@ pub fn parse(path: &Path) -> Result { } if let Some(ref d) = importer.optional_dependencies { for (name, info) in d { + if record_runtime(name, info, DepType::Optional) { + continue; + } push_direct( &mut deps, &mut alias_remaps, @@ -442,6 +481,13 @@ pub fn parse(path: &Path) -> Result { } let (name, version) = parse_dep_path(&dep_path) .ok_or_else(|| Error::parse(path, format!("invalid dep path: {dep_path}")))?; + // Runtime pin entries (`node@runtime:24.4.1`) are not packages + // — they're absorbed into `graph.runtimes` below. Skipping them + // here keeps them out of the package table so the fetch/link + // pipeline never sees them. + if version.starts_with("runtime:") { + continue; + } // URL-based direct deps are absorbed into `local_packages` // under the peer-stripped URL form (see `push_direct`), but the // snapshot key still carries any `(peer@ver)` suffix pnpm @@ -714,6 +760,37 @@ pub fn parse(path: &Path) -> Result { .map(|(k, v)| (k, v.into_path())) .collect(); + // Lift the synthetic runtime importer deps recorded above into + // typed pins, pulling the per-platform artifact list out of the + // matching `@runtime:` packages entry. A missing or + // variant-less packages entry still yields a pin (version intent + // survives); installs on platforms not covered by the variant list + // re-resolve against live SHASUMS. + let mut runtimes = BTreeMap::new(); + for (name, (specifier, version, dev)) in runtime_imports { + let pkg_info = raw.packages.get(&format!("{name}@runtime:{version}")); + let variants = pkg_info + .and_then(|p| p.resolution.as_ref()) + .and_then(|r| r.variants.as_ref()) + .map(|vs| { + vs.iter() + .map(|v| convert_runtime_variant(&name, v)) + .collect() + }) + .unwrap_or_default(); + let has_bin = pkg_info.map(|p| p.has_bin).unwrap_or(true); + runtimes.insert( + name, + RuntimePin { + specifier, + version, + dev, + has_bin, + variants, + }, + ); + } + Ok(LockfileGraph { importers, packages, @@ -730,11 +807,48 @@ pub fn parse(path: &Path) -> Result { bun_config_version: None, patched_dependencies, trusted_dependencies: Vec::new(), + runtimes, extra_fields: BTreeMap::new(), workspace_extra_fields: BTreeMap::new(), }) } +/// Convert a raw `variations` variant into the typed graph shape. +/// pnpm's bare-string `bin:` form names a single executable after the +/// runtime itself (`bin: bin/node` on the `node` entry). +fn convert_runtime_variant(runtime_name: &str, raw: &RawRuntimeVariant) -> RuntimeVariant { + let (bin, bin_is_bare_string) = match &raw.resolution.bin { + Some(RawBinSpec::Single(path)) => { + let mut m = BTreeMap::new(); + m.insert(runtime_name.to_string(), path.clone()); + (m, true) + } + Some(RawBinSpec::Map(m)) => (m.clone(), false), + None => (BTreeMap::new(), false), + }; + RuntimeVariant { + targets: raw + .targets + .iter() + .map(|t| RuntimeTarget { + os: t.os.clone(), + cpu: t.cpu.clone(), + libc: t.libc.clone(), + }) + .collect(), + archive: raw + .resolution + .archive + .clone() + .unwrap_or_else(|| "tarball".to_string()), + url: raw.resolution.url.clone(), + integrity: raw.resolution.integrity.clone().unwrap_or_default(), + bin, + bin_is_bare_string, + prefix: raw.resolution.prefix.clone(), + } +} + fn resolution_requires_integrity( resolution: Option<&super::raw::Resolution>, local_source: &Option, diff --git a/crates/aube-lockfile/src/pnpm/tests.rs b/crates/aube-lockfile/src/pnpm/tests.rs index 6446ba716..6517f8fc7 100644 --- a/crates/aube-lockfile/src/pnpm/tests.rs +++ b/crates/aube-lockfile/src/pnpm/tests.rs @@ -828,6 +828,7 @@ fn test_write_and_reparse_roundtrip() { update_config: None, scripts: BTreeMap::new(), engines: BTreeMap::new(), + dev_engines: None, workspaces: None, bundled_dependencies: None, extra: BTreeMap::new(), @@ -1024,6 +1025,7 @@ fn writer_preserves_workspace_importer_specifiers() { update_config: None, scripts: BTreeMap::new(), engines: BTreeMap::new(), + dev_engines: None, workspaces: None, bundled_dependencies: None, extra: BTreeMap::new(), @@ -1065,6 +1067,7 @@ fn overrides_round_trip_through_pnpm_lock_yaml() { update_config: None, scripts: BTreeMap::new(), engines: BTreeMap::new(), + dev_engines: None, workspaces: None, bundled_dependencies: None, extra: BTreeMap::new(), @@ -1157,6 +1160,7 @@ fn empty_overrides_block_omitted_from_yaml() { update_config: None, scripts: BTreeMap::new(), engines: BTreeMap::new(), + dev_engines: None, workspaces: None, bundled_dependencies: None, extra: BTreeMap::new(), @@ -1237,6 +1241,7 @@ fn test_write_dev_and_optional_deps() { update_config: None, scripts: BTreeMap::new(), engines: BTreeMap::new(), + dev_engines: None, workspaces: None, bundled_dependencies: None, extra: BTreeMap::new(), @@ -1422,6 +1427,7 @@ fn exclude_links_from_lockfile_drops_link_deps_from_importer() { update_config: None, scripts: BTreeMap::new(), engines: BTreeMap::new(), + dev_engines: None, workspaces: None, bundled_dependencies: None, extra: BTreeMap::new(), @@ -3117,3 +3123,250 @@ snapshots: let yaml = std::fs::read_to_string(&path).unwrap(); assert!(yaml.contains("gitHosted: true"), "{yaml}"); } + +// ── runtime pins (pnpm 10.14+ devEngines.runtime recording) ───────── + +/// A pnpm-11-authored lockfile with a `node@runtime:` pin. The +/// importer's synthetic `node` dep must land in `graph.runtimes` (not +/// `importers`/`packages`) so the install pipeline never tries to +/// fetch `node` from the npm registry — this is both the pin feature +/// and a compat fix for reading pnpm 10.14+ lockfiles. +#[test] +fn runtime_pin_parses_from_pnpm_lockfile() { + let dir = tempfile::tempdir().unwrap(); + let lockfile_path = dir.path().join("pnpm-lock.yaml"); + let src = r#"lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + node: + specifier: runtime:^24.4.0 + version: runtime:24.4.1 + +packages: + + node@runtime:24.4.1: + hasBin: true + version: 24.4.1 + resolution: + type: variations + variants: + - resolution: + archive: tarball + bin: bin/node + integrity: sha256-aGVsbG8gd29ybGQgdGhpcyBpcyBub3QgcmVhbA== + type: binary + url: https://nodejs.org/download/release/v24.4.1/node-v24.4.1-darwin-arm64.tar.gz + targets: + - cpu: arm64 + os: darwin + - resolution: + archive: zip + bin: + node: node.exe + integrity: sha256-d2luZG93cyBidWlsZCBjaGVja3N1bSBmYWtl + prefix: node-v24.4.1-win-x64 + type: binary + url: https://nodejs.org/download/release/v24.4.1/node-v24.4.1-win-x64.zip + targets: + - cpu: x64 + os: win32 + - resolution: + archive: tarball + bin: bin/node + integrity: sha256-bXVzbCBidWlsZCBjaGVja3N1bSBmYWtlIQ== + type: binary + url: https://unofficial-builds.nodejs.org/download/release/v24.4.1/node-v24.4.1-linux-x64-musl.tar.gz + targets: + - cpu: x64 + libc: musl + os: linux + +snapshots: + + node@runtime:24.4.1: {} +"#; + std::fs::write(&lockfile_path, src).unwrap(); + let graph = parse(&lockfile_path).unwrap(); + + // The synthetic dep must not leak into the package graph. + assert!(graph.importers.get(".").unwrap().is_empty()); + assert!(graph.packages.is_empty()); + + let pin = graph.runtimes.get("node").expect("node pin parsed"); + assert_eq!(pin.specifier, "^24.4.0"); + assert_eq!(pin.version, "24.4.1"); + assert!(pin.dev); + assert!(pin.has_bin); + assert_eq!(pin.variants.len(), 3); + + let mac = pin.variant_for("darwin", "arm64", None).unwrap(); + assert_eq!(mac.archive, "tarball"); + assert!(mac.bin_is_bare_string); + assert_eq!(mac.bin.get("node").map(String::as_str), Some("bin/node")); + assert_eq!( + mac.url, + "https://nodejs.org/download/release/v24.4.1/node-v24.4.1-darwin-arm64.tar.gz" + ); + + let win = pin.variant_for("win32", "x64", None).unwrap(); + assert_eq!(win.archive, "zip"); + assert!(!win.bin_is_bare_string); + assert_eq!(win.bin.get("node").map(String::as_str), Some("node.exe")); + assert_eq!(win.prefix.as_deref(), Some("node-v24.4.1-win-x64")); + + let musl = pin.variant_for("linux", "x64", Some("musl")).unwrap(); + assert!(musl.url.contains("unofficial-builds")); + assert!(pin.variant_for("linux", "x64", None).is_none()); +} + +/// Write → parse round-trip of a runtime pin must preserve the full +/// pnpm shape: importer synthetic dep, `variations` packages entry, +/// and the empty snapshot. +#[test] +fn runtime_pin_round_trips_through_write() { + use crate::{RuntimePin, RuntimeTarget, RuntimeVariant}; + + let dir = tempfile::tempdir().unwrap(); + let mut graph = LockfileGraph::default(); + graph.importers.insert(".".to_string(), Vec::new()); + graph.runtimes.insert( + "node".to_string(), + RuntimePin { + specifier: "^24.4.0".to_string(), + version: "24.4.1".to_string(), + dev: true, + has_bin: true, + variants: vec![ + RuntimeVariant { + targets: vec![RuntimeTarget { + os: "darwin".to_string(), + cpu: "arm64".to_string(), + libc: None, + }], + archive: "tarball".to_string(), + url: "https://nodejs.org/download/release/v24.4.1/node-v24.4.1-darwin-arm64.tar.gz".to_string(), + integrity: "sha256-aGVsbG8gd29ybGQgdGhpcyBpcyBub3QgcmVhbA==".to_string(), + bin: [("node".to_string(), "bin/node".to_string())].into_iter().collect(), + bin_is_bare_string: true, + prefix: None, + }, + RuntimeVariant { + targets: vec![RuntimeTarget { + os: "win32".to_string(), + cpu: "x64".to_string(), + libc: None, + }], + archive: "zip".to_string(), + url: "https://nodejs.org/download/release/v24.4.1/node-v24.4.1-win-x64.zip".to_string(), + integrity: "sha256-d2luZG93cyBidWlsZCBjaGVja3N1bSBmYWtl".to_string(), + bin: [("node".to_string(), "node.exe".to_string())].into_iter().collect(), + bin_is_bare_string: false, + prefix: Some("node-v24.4.1-win-x64".to_string()), + }, + ], + }, + ); + + let manifest = PackageJson::default(); + let out_path = dir.path().join("aube-lock.yaml"); + write(&out_path, &graph, &manifest).unwrap(); + let written = std::fs::read_to_string(&out_path).unwrap(); + + assert!( + written.contains("specifier: runtime:^24.4.0"), + "importer specifier missing: {written}" + ); + assert!( + written.contains("version: runtime:24.4.1"), + "importer version missing: {written}" + ); + assert!( + written.contains("node@runtime:24.4.1:"), + "packages/snapshots key missing: {written}" + ); + assert!( + written.contains("type: variations"), + "variations resolution missing (or flow-collapsed): {written}" + ); + // The variations resolution must stay in block form — the + // flow-collapse pass must not mangle it. + assert!( + !written.contains("resolution: {type: variations"), + "variations resolution was flow-collapsed: {written}" + ); + assert!( + written.contains("bin: bin/node"), + "bare-string bin form not preserved: {written}" + ); + + let reparsed = parse(&out_path).unwrap(); + assert_eq!( + reparsed.runtimes, graph.runtimes, + "runtime pin did not round-trip" + ); + assert!(reparsed.packages.is_empty()); +} + +/// devEngines drift: a recorded pin whose range no longer matches the +/// manifest (or whose devEngines entry was removed) must read stale; +/// a matching pin stays fresh; and a manifest with devEngines but no +/// recorded pin is NOT drift (foreign formats can't record pins). +#[test] +fn runtime_pin_drift_detection() { + use crate::{DriftStatus, RuntimePin}; + + let mut graph = LockfileGraph::default(); + graph.importers.insert(".".to_string(), Vec::new()); + graph.runtimes.insert( + "node".to_string(), + RuntimePin { + specifier: "^24.4.0".to_string(), + version: "24.4.1".to_string(), + dev: true, + has_bin: true, + variants: Vec::new(), + }, + ); + + let manifest_with = |range: &str| -> PackageJson { + let json = format!( + r#"{{"name": "t", "devEngines": {{"runtime": {{"name": "node", "version": "{range}", "onFail": "download"}}}}}}"# + ); + serde_json::from_str(&json).unwrap() + }; + + let empty: BTreeMap = BTreeMap::new(); + let empty_catalogs: BTreeMap> = BTreeMap::new(); + let fresh = graph.check_drift(&manifest_with("^24.4.0"), &empty, &[], &empty_catalogs); + assert_eq!(fresh, DriftStatus::Fresh, "matching pin must be fresh"); + + let changed = graph.check_drift(&manifest_with("^26.0.0"), &empty, &[], &empty_catalogs); + assert!( + matches!(&changed, DriftStatus::Stale { reason } if reason.contains("node")), + "changed range must be stale: {changed:?}" + ); + + let removed = graph.check_drift(&PackageJson::default(), &empty, &[], &empty_catalogs); + assert!( + matches!(removed, DriftStatus::Stale { .. }), + "removed devEngines must be stale" + ); + + // No pin recorded + devEngines present → not drift (the install + // driver records the pin on formats that support it). + let no_pin = LockfileGraph { + importers: graph.importers.clone(), + ..LockfileGraph::default() + }; + assert_eq!( + no_pin.check_drift(&manifest_with("^24.4.0"), &empty, &[], &empty_catalogs), + DriftStatus::Fresh + ); +} diff --git a/crates/aube-lockfile/src/pnpm/write.rs b/crates/aube-lockfile/src/pnpm/write.rs index a4ed2864d..6d5cb9ee1 100644 --- a/crates/aube-lockfile/src/pnpm/write.rs +++ b/crates/aube-lockfile/src/pnpm/write.rs @@ -106,6 +106,25 @@ pub fn write(path: &Path, graph: &LockfileGraph, manifest: &PackageJson) -> Resu } } + // Runtime pins render as synthetic deps on the root importer + // (pnpm 10.14+ shape): `node: {specifier: runtime:^24.4.0, + // version: runtime:24.4.1}`. Only the root carries them — the + // pin comes from the root manifest's devEngines. + if importer_path == "." { + for (name, pin) in &graph.runtimes { + let spec = WritableDepSpec { + specifier: format!("runtime:{}", pin.specifier), + version: format!("runtime:{}", pin.version), + }; + let slot = if pin.dev { + importer.dev_dependencies.get_or_insert_with(BTreeMap::new) + } else { + importer.dependencies.get_or_insert_with(BTreeMap::new) + }; + slot.insert(name.clone(), spec); + } + } + if let Some(skipped) = graph.skipped_optional_dependencies.get(importer_path) && !skipped.is_empty() { @@ -229,6 +248,7 @@ pub fn write(path: &Path, graph: &LockfileGraph, manifest: &PackageJson) -> Resu repo: None, type_: Some("directory".to_string()), path: None, + variants: None, }), Some(local @ LocalSource::Tarball(_)) => Some(WritableResolution { integrity: None, @@ -239,6 +259,7 @@ pub fn write(path: &Path, graph: &LockfileGraph, manifest: &PackageJson) -> Resu repo: None, type_: None, path: None, + variants: None, }), Some(LocalSource::Link(_)) | Some(LocalSource::Exec(_)) => None, Some(local @ LocalSource::Portal(_)) => Some(WritableResolution { @@ -250,6 +271,7 @@ pub fn write(path: &Path, graph: &LockfileGraph, manifest: &PackageJson) -> Resu repo: None, type_: Some("directory".to_string()), path: None, + variants: None, }), Some(LocalSource::Git(g)) => Some(WritableResolution { integrity: g.integrity.clone().or_else(|| pkg.integrity.clone()), @@ -264,6 +286,7 @@ pub fn write(path: &Path, graph: &LockfileGraph, manifest: &PackageJson) -> Resu // with a `&path:/` selector. Keep the same shape // so byte-identical round-trips survive. path: g.subpath.as_ref().map(|s| format!("/{s}")), + variants: None, }), Some(LocalSource::RemoteTarball(t)) => Some(WritableResolution { integrity: if t.integrity.is_empty() { @@ -278,6 +301,7 @@ pub fn write(path: &Path, graph: &LockfileGraph, manifest: &PackageJson) -> Resu repo: None, type_: None, path: None, + variants: None, }), None if url_keyed => { // URL-keyed transitive entries (github overrides, etc.) @@ -298,6 +322,7 @@ pub fn write(path: &Path, graph: &LockfileGraph, manifest: &PackageJson) -> Resu repo: None, type_: None, path: None, + variants: None, }) } None if pkg.integrity.is_some() || preserve_tarball_url => Some(WritableResolution { @@ -323,6 +348,7 @@ pub fn write(path: &Path, graph: &LockfileGraph, manifest: &PackageJson) -> Resu repo: None, type_: None, path: None, + variants: None, }), None => None, }; @@ -356,6 +382,69 @@ pub fn write(path: &Path, graph: &LockfileGraph, manifest: &PackageJson) -> Resu ); } + // Runtime pin packages entries: `node@runtime:24.4.1` with a + // `variations` resolution carrying one binary artifact per + // platform (pnpm 10.14+ shape). The matching snapshot entry is + // empty — runtimes have no dependencies. + for (name, pin) in &graph.runtimes { + let variants: Vec = pin + .variants + .iter() + .map(|v| WritableRuntimeVariant { + resolution: WritableRuntimeBinaryResolution { + archive: v.archive.clone(), + bin: if v.bin_is_bare_string && v.bin.len() == 1 { + WritableRuntimeBin::Single(v.bin.values().next().unwrap().clone()) + } else { + WritableRuntimeBin::Map(v.bin.clone()) + }, + integrity: if v.integrity.is_empty() { + None + } else { + Some(v.integrity.clone()) + }, + prefix: v.prefix.clone(), + type_: "binary", + url: v.url.clone(), + }, + targets: v + .targets + .iter() + .map(|t| WritableRuntimeTarget { + cpu: t.cpu.clone(), + libc: t.libc.clone(), + os: t.os.clone(), + }) + .collect(), + }) + .collect(); + packages.insert( + format!("{name}@runtime:{}", pin.version), + WritablePackageInfo { + resolution: Some(WritableResolution { + integrity: None, + git_hosted: false, + directory: None, + tarball: None, + commit: None, + repo: None, + type_: Some("variations".to_string()), + path: None, + variants: Some(variants), + }), + version: Some(pin.version.clone()), + engines: None, + os: Vec::new(), + cpu: Vec::new(), + libc: Vec::new(), + has_bin: pin.has_bin, + peer_dependencies: None, + peer_dependencies_meta: None, + alias_of: None, + }, + ); + } + // Translate internal dep_path tails (`git+`, `url+`, // `file+`) to the specifier form pnpm expects in snapshot // dependency maps (`#` for git, raw URL for tarball, @@ -436,6 +525,21 @@ pub fn write(path: &Path, graph: &LockfileGraph, manifest: &PackageJson) -> Resu ); } + // Empty snapshot entries for runtime pins (`node@runtime:24.4.1: {}`), + // matching pnpm's writer. + for (name, pin) in &graph.runtimes { + snapshots.insert( + format!("{name}@runtime:{}", pin.version), + WritableSnapshot { + dependencies: None, + optional_dependencies: None, + transitive_peer_dependencies: None, + optional: None, + bundled_dependencies: None, + }, + ); + } + let time = pruned_time_entries(graph, native_pnpm_aliases); let catalogs = if graph.catalogs.is_empty() { @@ -693,6 +797,51 @@ struct WritableResolution { /// match pnpm's own writer. #[serde(skip_serializing_if = "Option::is_none")] path: Option, + /// `type: variations` artifact list for runtime pins. `None` for + /// every ordinary package resolution. + #[serde(skip_serializing_if = "Option::is_none")] + variants: Option>, +} + +/// One `variants:` entry of a runtime pin's `variations` resolution. +/// Field order is alphabetical (`resolution` before `targets`), +/// matching pnpm's sorted-key lockfile emitter. +#[derive(Debug, Serialize)] +struct WritableRuntimeVariant { + resolution: WritableRuntimeBinaryResolution, + targets: Vec, +} + +/// pnpm `BinaryResolution` — alphabetical field order to match pnpm's +/// sorted-key emitter. +#[derive(Debug, Serialize)] +struct WritableRuntimeBinaryResolution { + archive: String, + bin: WritableRuntimeBin, + #[serde(skip_serializing_if = "Option::is_none")] + integrity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + prefix: Option, + #[serde(rename = "type")] + type_: &'static str, + url: String, +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +enum WritableRuntimeBin { + /// Bare-string form (`bin: bin/node`) — a single executable named + /// after the runtime itself. + Single(String), + Map(BTreeMap), +} + +#[derive(Debug, Serialize)] +struct WritableRuntimeTarget { + cpu: String, + #[serde(skip_serializing_if = "Option::is_none")] + libc: Option, + os: String, } #[derive(Debug, Serialize)] diff --git a/crates/aube-lockfile/src/yarn/tests.rs b/crates/aube-lockfile/src/yarn/tests.rs index 225315d23..04e6bd7a2 100644 --- a/crates/aube-lockfile/src/yarn/tests.rs +++ b/crates/aube-lockfile/src/yarn/tests.rs @@ -22,6 +22,7 @@ fn make_manifest(deps: &[(&str, &str)], dev: &[(&str, &str)]) -> aube_manifest:: update_config: None, scripts: Default::default(), engines: Default::default(), + dev_engines: None, workspaces: None, bundled_dependencies: None, extra: Default::default(), diff --git a/crates/aube-manifest/src/dev_engines.rs b/crates/aube-manifest/src/dev_engines.rs new file mode 100644 index 000000000..8fe131ab8 --- /dev/null +++ b/crates/aube-manifest/src/dev_engines.rs @@ -0,0 +1,299 @@ +//! `devEngines` field (OpenJS package-metadata interoperability spec). +//! +//! Shape per the spec (and pnpm 10.14+/11): +//! +//! ```json +//! { +//! "devEngines": { +//! "runtime": { "name": "node", "version": "^24.4.0", "onFail": "download" } +//! } +//! } +//! ``` +//! +//! Every slot (`runtime`, `packageManager`, `os`, `cpu`, `libc`) accepts a +//! single object or an array of objects. aube only acts on `runtime` +//! entries named `node`; everything else is preserved untouched in +//! `extra` so format round-trips don't drop fields. +//! +//! Parsing is tolerant in the same spirit as `engines_tolerant`: this +//! field appears in arbitrary published packages, and a malformed +//! `devEngines` in some transitive dep must not fail an install. A +//! shape we don't understand deserializes to "absent". + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::collections::BTreeMap; + +/// Parsed `devEngines` map. Only `runtime` is interpreted; other keys +/// ride along in `extra`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct DevEngines { + /// `devEngines.runtime` entries, normalized to a list (the spec's + /// single-object form parses as a one-element list and serializes + /// back as a single object). + #[serde( + default, + skip_serializing_if = "Vec::is_empty", + deserialize_with = "one_or_many_tolerant", + serialize_with = "one_or_many_serialize" + )] + pub runtime: Vec, + /// `devEngines.packageManager` entries — same object-or-array + /// tolerance as `runtime`. aube acts on the entry named `aube` + /// (self-version switching); other package managers are validated + /// territory for the startup guard. + #[serde( + default, + rename = "packageManager", + skip_serializing_if = "Vec::is_empty", + deserialize_with = "one_or_many_tolerant", + serialize_with = "one_or_many_serialize" + )] + pub package_manager: Vec, + /// Unrecognized `devEngines` slots (`os`, `cpu`, `libc`, future + /// additions) — preserved verbatim. + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl DevEngines { + /// The `runtime` entry named `node`, if any. The spec allows one + /// entry per runtime name; if a manifest repeats `node`, the first + /// entry wins (matching pnpm). + pub fn node_runtime(&self) -> Option<&DevEngineDependency> { + self.runtime.iter().find(|r| r.name == "node") + } + + /// The `packageManager` entry named `aube`, if any. + pub fn aube_package_manager(&self) -> Option<&DevEngineDependency> { + self.package_manager.iter().find(|r| r.name == "aube") + } + + /// Names of `runtime` entries aube does not act on (anything but + /// `node`), for a caller that wants to surface a warning. + pub fn unsupported_runtimes(&self) -> Vec<&str> { + self.runtime + .iter() + .filter(|r| r.name != "node") + .map(|r| r.name.as_str()) + .collect() + } +} + +/// A single `devEngines.` dependency entry. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DevEngineDependency { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(default, rename = "onFail", skip_serializing_if = "Option::is_none")] + pub on_fail: Option, + /// Forward-compat passthrough for fields the spec may grow. + #[serde(flatten, skip_serializing_if = "BTreeMap::is_empty")] + pub extra: BTreeMap, +} + +/// `onFail` policy when the active environment doesn't satisfy the +/// declared engine. Spec default (entry without `onFail`) is `error`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum OnFail { + Ignore, + Warn, + Error, + Download, +} + +impl std::str::FromStr for OnFail { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "ignore" => Ok(Self::Ignore), + "warn" => Ok(Self::Warn), + "error" => Ok(Self::Error), + "download" => Ok(Self::Download), + other => Err(format!( + "invalid onFail value {other:?} (expected ignore|warn|error|download)" + )), + } + } +} + +impl std::fmt::Display for OnFail { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Ignore => "ignore", + Self::Warn => "warn", + Self::Error => "error", + Self::Download => "download", + }) + } +} + +/// Deserialize the whole `devEngines` field tolerantly: a non-object +/// (or otherwise unintelligible) value parses as absent rather than +/// failing the manifest, mirroring `engines_tolerant`. +pub fn dev_engines_tolerant<'de, D>(de: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value: Option = Option::deserialize(de)?; + Ok(match value { + Some(v @ serde_json::Value::Object(_)) => { + serde_json::from_value::(v).ok().filter(|d| { + // An object that parsed but carries nothing we (or a + // round-trip) care about is equivalent to absent. + !d.runtime.is_empty() || !d.package_manager.is_empty() || !d.extra.is_empty() + }) + } + _ => None, + }) +} + +/// Accept the spec's single-object or array form for an entry list. +/// Malformed entries inside an array are dropped (one junk entry in a +/// published package must not fail the install); a malformed scalar +/// parses as the empty list. +fn one_or_many_tolerant<'de, D>(de: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value: Option = Option::deserialize(de)?; + Ok(match value { + Some(serde_json::Value::Array(items)) => items + .into_iter() + .filter_map(|v| serde_json::from_value(v).ok()) + .collect(), + Some(v @ serde_json::Value::Object(_)) => { + serde_json::from_value(v).ok().into_iter().collect() + } + _ => Vec::new(), + }) +} + +/// Serialize a one-element list back as the single-object form the +/// manifest most likely used, and longer lists as arrays. +fn one_or_many_serialize(entries: &[DevEngineDependency], ser: S) -> Result +where + S: Serializer, +{ + match entries { + [single] => single.serialize(ser), + many => many.serialize(ser), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(json: &str) -> Option { + #[derive(Deserialize)] + struct Wrap { + #[serde(default, deserialize_with = "dev_engines_tolerant")] + dev_engines: Option, + } + serde_json::from_str::(&format!(r#"{{"dev_engines": {json}}}"#)) + .unwrap() + .dev_engines + } + + #[test] + fn object_form_parses() { + let d = + parse(r#"{"runtime": {"name": "node", "version": "^24.4.0", "onFail": "download"}}"#) + .unwrap(); + let node = d.node_runtime().unwrap(); + assert_eq!(node.version.as_deref(), Some("^24.4.0")); + assert_eq!(node.on_fail, Some(OnFail::Download)); + } + + #[test] + fn array_form_parses_and_first_node_wins() { + let d = parse( + r#"{"runtime": [ + {"name": "bun", "version": "^1.2.0"}, + {"name": "node", "version": ">=22"}, + {"name": "node", "version": ">=99"} + ]}"#, + ) + .unwrap(); + assert_eq!(d.node_runtime().unwrap().version.as_deref(), Some(">=22")); + assert_eq!(d.unsupported_runtimes(), vec!["bun"]); + } + + #[test] + fn missing_on_fail_is_none() { + let d = parse(r#"{"runtime": {"name": "node", "version": "^22"}}"#).unwrap(); + assert_eq!(d.node_runtime().unwrap().on_fail, None); + } + + #[test] + fn junk_shapes_parse_as_absent() { + assert!(parse(r#""totally a string""#).is_none()); + assert!(parse("42").is_none()); + assert!(parse("[]").is_none()); + assert!(parse("null").is_none()); + assert!(parse("{}").is_none()); + } + + #[test] + fn junk_entry_in_array_is_dropped() { + let d = parse(r#"{"runtime": ["nonsense", {"name": "node", "version": "^20"}]}"#).unwrap(); + assert_eq!(d.runtime.len(), 1); + assert_eq!(d.node_runtime().unwrap().version.as_deref(), Some("^20")); + } + + #[test] + fn invalid_on_fail_drops_the_entry() { + // Unknown onFail values fail that entry's parse; tolerance + // drops it rather than failing the manifest. A typo'd policy + // should not be silently coerced into some default here — + // the entry vanishing means the field has no effect, the + // same outcome npm gives unknown garbage. + let d = parse(r#"{"runtime": [{"name": "node", "version": "^20", "onFail": "explode"}]}"#); + assert!(d.is_none()); + } + + #[test] + fn package_manager_slot_is_typed() { + let d = parse( + r#"{"runtime": {"name": "node"}, "packageManager": {"name": "pnpm", "version": "^10"}}"#, + ) + .unwrap(); + assert!(d.aube_package_manager().is_none()); + assert_eq!(d.package_manager[0].name, "pnpm"); + let d = parse(r#"{"packageManager": {"name": "aube", "version": "1.18.2"}}"#).unwrap(); + assert_eq!( + d.aube_package_manager().unwrap().version.as_deref(), + Some("1.18.2") + ); + } + + #[test] + fn unknown_slots_are_preserved() { + let d = parse(r#"{"runtime": {"name": "node"}, "os": {"name": "linux"}}"#).unwrap(); + assert!(d.extra.contains_key("os")); + } + + #[test] + fn single_entry_round_trips_as_object() { + let d = parse(r#"{"runtime": {"name": "node", "version": "^24"}}"#).unwrap(); + let back = serde_json::to_value(&d).unwrap(); + assert!(back["runtime"].is_object(), "got {back}"); + } + + #[test] + fn multi_entry_round_trips_as_array() { + let d = parse(r#"{"runtime": [{"name": "node"}, {"name": "deno"}]}"#).unwrap(); + let back = serde_json::to_value(&d).unwrap(); + assert!(back["runtime"].is_array(), "got {back}"); + } + + #[test] + fn extra_entry_fields_are_preserved() { + let d = parse(r#"{"runtime": {"name": "node", "version": "^24", "futureField": true}}"#) + .unwrap(); + assert!(d.node_runtime().unwrap().extra.contains_key("futureField")); + } +} diff --git a/crates/aube-manifest/src/lib.rs b/crates/aube-manifest/src/lib.rs index 1273c2591..922168d9c 100644 --- a/crates/aube-manifest/src/lib.rs +++ b/crates/aube-manifest/src/lib.rs @@ -1,5 +1,7 @@ +pub mod dev_engines; pub mod workspace; +pub use dev_engines::{DevEngineDependency, DevEngines, OnFail, dev_engines_tolerant}; pub use workspace::{JailBuildPermission, WorkspaceConfig}; use serde::{Deserialize, Deserializer, Serialize}; @@ -178,6 +180,16 @@ pub struct PackageJson { skip_serializing_if = "BTreeMap::is_empty" )] pub engines: BTreeMap, + /// `devEngines` field — development-environment requirements per + /// the OpenJS spec. aube acts on `devEngines.runtime` entries named + /// `node` (version switching); see [`dev_engines`] for the shape + /// and tolerance rules. + #[serde( + default, + deserialize_with = "dev_engines_tolerant", + skip_serializing_if = "Option::is_none" + )] + pub dev_engines: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub workspaces: Option, #[serde(flatten)] @@ -222,6 +234,8 @@ struct PackageJsonRaw { scripts: BTreeMap, #[serde(default, deserialize_with = "engines_tolerant")] engines: BTreeMap, + #[serde(default, deserialize_with = "dev_engines_tolerant")] + dev_engines: Option, #[serde(default)] workspaces: Option, #[serde(flatten)] @@ -241,6 +255,7 @@ impl From for PackageJson { bundled_dependencies: raw.bundled_dependencies.or(raw.bundle_dependencies_alias), scripts: raw.scripts, engines: raw.engines, + dev_engines: raw.dev_engines, workspaces: raw.workspaces, extra: raw.extra, } diff --git a/crates/aube-manifest/src/workspace/config.rs b/crates/aube-manifest/src/workspace/config.rs index 430c819c8..a21d318d2 100644 --- a/crates/aube-manifest/src/workspace/config.rs +++ b/crates/aube-manifest/src/workspace/config.rs @@ -241,6 +241,30 @@ pub struct WorkspaceConfig { #[serde(default)] pub update_config: Option, + /// Node.js download mirror map (pnpm parity), keyed by channel: + /// `release` is used for runtime downloads; `rc`/`nightly` are + /// parsed but currently unused. + #[serde(default)] + pub node_download_mirrors: BTreeMap, + + /// Who installs a missing Node.js runtime: `auto` (mise when + /// present, else aube), `mise`, or `aube`. Declared as a typed + /// field so the settings-meta parity test sees the workspaceYaml + /// key. + #[serde(default)] + pub runtime_installer: Option, + + /// Override for `devEngines.runtime`'s `onFail` policy: + /// `download`, `error`, `warn`, or `ignore`. + #[serde(default)] + pub runtime_on_fail: Option, + + /// Self-version switching toggle (pnpm parity). Declared as a + /// typed field so the settings-meta parity test sees the + /// workspaceYaml key. + #[serde(default)] + pub manage_package_manager_versions: Option, + /// Trust-policy mode. Parsed for pnpm parity; resolver support is /// limited to accepting the configured policy surface until registry /// trust metadata is available. diff --git a/crates/aube-resolver/src/peer_context.rs b/crates/aube-resolver/src/peer_context.rs index ae95a331c..9eff841f1 100644 --- a/crates/aube-resolver/src/peer_context.rs +++ b/crates/aube-resolver/src/peer_context.rs @@ -541,6 +541,7 @@ pub(crate) fn dedupe_peer_variants(graph: LockfileGraph) -> LockfileGraph { bun_config_version, patched_dependencies, trusted_dependencies, + runtimes, extra_fields, workspace_extra_fields, } = graph; @@ -587,6 +588,7 @@ pub(crate) fn dedupe_peer_variants(graph: LockfileGraph) -> LockfileGraph { bun_config_version, patched_dependencies, trusted_dependencies, + runtimes, extra_fields, workspace_extra_fields, } @@ -711,6 +713,7 @@ fn apply_peer_contexts_once( settings: canonical.settings, overrides: canonical.overrides, ignored_optional_dependencies: canonical.ignored_optional_dependencies, + runtimes: canonical.runtimes, times: canonical.times, skipped_optional_dependencies: canonical.skipped_optional_dependencies, catalogs: canonical.catalogs, @@ -1181,6 +1184,7 @@ fn propagate_peer_suffixes_to_ancestors( bun_config_version, patched_dependencies, trusted_dependencies, + runtimes, extra_fields, workspace_extra_fields, } = graph; @@ -1237,6 +1241,7 @@ fn propagate_peer_suffixes_to_ancestors( bun_config_version, patched_dependencies, trusted_dependencies, + runtimes, extra_fields, workspace_extra_fields, } @@ -1397,6 +1402,7 @@ pub(crate) fn dedupe_peer_suffixes(graph: LockfileGraph) -> LockfileGraph { settings: graph.settings, overrides: graph.overrides, ignored_optional_dependencies: graph.ignored_optional_dependencies, + runtimes: graph.runtimes, times: graph.times, skipped_optional_dependencies: graph.skipped_optional_dependencies, catalogs: graph.catalogs, diff --git a/crates/aube-resolver/src/resolve.rs b/crates/aube-resolver/src/resolve.rs index b734d1db4..375aed2e8 100644 --- a/crates/aube-resolver/src/resolve.rs +++ b/crates/aube-resolver/src/resolve.rs @@ -124,6 +124,7 @@ impl Resolver { // them back from the prior lockfile when round-tripping. patched_dependencies: BTreeMap::new(), trusted_dependencies: Vec::new(), + runtimes: BTreeMap::new(), extra_fields: BTreeMap::new(), workspace_extra_fields: BTreeMap::new(), }; diff --git a/crates/aube-resolver/src/tests.rs b/crates/aube-resolver/src/tests.rs index 1ed25cabf..fab2a2667 100644 --- a/crates/aube-resolver/src/tests.rs +++ b/crates/aube-resolver/src/tests.rs @@ -2842,6 +2842,7 @@ async fn resolve_handles_lockfile_reused_name_with_incompatible_transitive_range bun_config_version: None, patched_dependencies: BTreeMap::new(), trusted_dependencies: Vec::new(), + runtimes: BTreeMap::new(), extra_fields: BTreeMap::new(), workspace_extra_fields: BTreeMap::new(), }; @@ -2915,6 +2916,7 @@ async fn lockfile_reuse_preserves_transitive_optional_edges() { bun_config_version: None, patched_dependencies: BTreeMap::new(), trusted_dependencies: Vec::new(), + runtimes: BTreeMap::new(), extra_fields: BTreeMap::new(), workspace_extra_fields: BTreeMap::new(), }; diff --git a/crates/aube-runtime/Cargo.toml b/crates/aube-runtime/Cargo.toml new file mode 100644 index 000000000..88344de86 --- /dev/null +++ b/crates/aube-runtime/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "aube-runtime" +description = "Node.js runtime version resolution, discovery, and installation for Aube" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +readme.workspace = true +include = ["src/**/*.rs"] + +[dependencies] +aube-codes = { workspace = true } +aube-manifest = { workspace = true } +aube-util = { workspace = true } +base64 = { workspace = true } +flate2 = { workspace = true } +hex = { workspace = true } +node-semver = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +tar = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +xx = { workspace = true } +zip = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/aube-runtime/src/discover.rs b/crates/aube-runtime/src/discover.rs new file mode 100644 index 000000000..5a7e3ca33 --- /dev/null +++ b/crates/aube-runtime/src/discover.rs @@ -0,0 +1,247 @@ +//! Discovery of already-installed Node versions: aube's own runtime +//! dir, mise's installs dir (read-only), and the `node` on PATH. + +use crate::paths; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// Where an installed Node came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallOrigin { + Aube, + Mise, +} + +impl InstallOrigin { + pub fn label(self) -> &'static str { + match self { + InstallOrigin::Aube => "aube", + InstallOrigin::Mise => "mise", + } + } +} + +/// A validated on-disk Node install. +#[derive(Debug, Clone)] +pub struct InstalledNode { + pub version: node_semver::Version, + pub install_dir: PathBuf, + /// The directory to prepend to PATH: `/bin` on unix, the + /// install dir itself on Windows (node.exe sits at the root). + pub bin_dir: PathBuf, + pub node_bin: PathBuf, + pub origin: InstallOrigin, +} + +/// List every valid installed Node version across aube's runtime dir +/// and mise's installs dir. When both have the same version, aube's +/// copy wins (deterministic, and it's the copy aube can manage). +pub fn list_installed() -> Vec { + let mut by_version: BTreeMap = BTreeMap::new(); + // Insert mise first so aube entries overwrite on version collision. + if let Some(dir) = mise_node_installs_dir() { + for node in scan_install_dir(&dir, InstallOrigin::Mise) { + by_version.insert(node.version.clone(), node); + } + } + if let Some(dir) = paths::runtime_dir() { + for node in scan_install_dir(&dir, InstallOrigin::Aube) { + by_version.insert(node.version.clone(), node); + } + } + by_version.into_values().collect() +} + +/// mise's node installs directory. +pub fn mise_node_installs_dir() -> Option { + mise_tool_installs_dir("node") +} + +/// mise's installs directory for one tool: +/// `$MISE_INSTALLS_DIR || $MISE_DATA_DIR/installs || ~/.local/share/mise/installs`, +/// plus the tool segment. mise uses `~/.local/share` on every OS. +pub fn mise_tool_installs_dir(tool: &str) -> Option { + let installs = if let Some(dir) = std::env::var_os("MISE_INSTALLS_DIR") { + PathBuf::from(dir) + } else if let Some(dir) = std::env::var_os("MISE_DATA_DIR") { + PathBuf::from(dir).join("installs") + } else { + let data_home = aube_util::env::xdg_data_home() + .or_else(|| aube_util::env::home_dir().map(|h| h.join(".local/share")))?; + data_home.join("mise/installs") + }; + Some(installs.join(tool)) +} + +/// Scan one installs root (dir-per-version) and validate each entry: +/// the dir name must parse as a version, symlinks are skipped (mise's +/// `latest` / `lts` / `20` aliases are symlinks — including them would +/// double-count), an in-progress install (mise's `incomplete` marker +/// file) is skipped, and the node binary must exist. +fn scan_install_dir(root: &Path, origin: InstallOrigin) -> Vec { + let Ok(entries) = std::fs::read_dir(root) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() { + // Skips both files and symlinked alias dirs: + // `DirEntry::file_type` does not follow symlinks. + continue; + } + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let Ok(version) = node_semver::Version::parse(name.trim_start_matches('v')) else { + continue; + }; + if let Some(node) = validate_install(&path, version, origin) { + out.push(node); + } + } + out +} + +/// Validate a single version dir and compute its bin paths. Public to +/// the crate so the installer can re-check a freshly-published dir +/// (or one mise just created) through the exact same rules. +pub(crate) fn validate_install( + dir: &Path, + version: node_semver::Version, + origin: InstallOrigin, +) -> Option { + if dir.join("incomplete").exists() { + return None; + } + let (bin_dir, node_bin) = node_paths_in(dir); + if !node_bin.is_file() { + return None; + } + Some(InstalledNode { + version, + install_dir: dir.to_path_buf(), + bin_dir, + node_bin, + origin, + }) +} + +/// Per-OS layout of a native Node install: unix puts `node` under +/// `bin/`, Windows puts `node.exe` at the root (mise mirrors both). +pub(crate) fn node_paths_in(dir: &Path) -> (PathBuf, PathBuf) { + if cfg!(windows) { + // Windows zips have node.exe at the archive root, but mise + // (and some mirrors' layouts) use bin\node.exe — accept both. + let root_exe = dir.join("node.exe"); + if root_exe.is_file() { + return (dir.to_path_buf(), root_exe); + } + let bin = dir.join("bin"); + let exe = bin.join("node.exe"); + (bin, exe) + } else { + let bin = dir.join("bin"); + let exe = bin.join("node"); + (bin, exe) + } +} + +/// Find `node` on PATH and probe its version (`node --version`). +/// Memoized for the process: one spawn no matter how many resolution +/// calls happen. +pub fn probe_path_node() -> Option<(node_semver::Version, PathBuf)> { + static PROBED: std::sync::OnceLock> = + std::sync::OnceLock::new(); + PROBED.get_or_init(probe_path_node_uncached).clone() +} + +fn probe_path_node_uncached() -> Option<(node_semver::Version, PathBuf)> { + let exe = find_on_path(if cfg!(windows) { "node.exe" } else { "node" })?; + let output = std::process::Command::new(&exe) + .arg("--version") + .output() + .ok()?; + if !output.status.success() { + return None; + } + let raw = String::from_utf8(output.stdout).ok()?; + let version = node_semver::Version::parse(raw.trim().trim_start_matches('v')).ok()?; + Some((version, exe)) +} + +/// Minimal PATH walk (std-only). Returns the first existing, +/// file-typed match. +pub(crate) fn find_on_path(bin_name: &str) -> Option { + let path = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path) { + if dir.as_os_str().is_empty() { + continue; + } + let candidate = dir.join(bin_name); + if candidate.is_file() { + return Some(candidate); + } + #[cfg(windows)] + { + // PATHEXT resolution for the bare name (mise is usually + // mise.exe; node is node.exe — callers pass the .exe name + // already, this is a fallback). + let with_exe = dir.join(format!("{bin_name}.exe")); + if with_exe.is_file() { + return Some(with_exe); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fab_install(root: &Path, version: &str) { + let dir = root.join(version); + let bin = if cfg!(windows) { + dir.clone() + } else { + dir.join("bin") + }; + std::fs::create_dir_all(&bin).unwrap(); + let exe = bin.join(if cfg!(windows) { "node.exe" } else { "node" }); + std::fs::write(&exe, "#!/bin/sh\necho v0.0.0\n").unwrap(); + } + + #[test] + fn scans_and_validates() { + let tmp = tempfile::tempdir().unwrap(); + fab_install(tmp.path(), "22.1.0"); + fab_install(tmp.path(), "24.4.1"); + // Incomplete install: skipped. + fab_install(tmp.path(), "26.0.0"); + std::fs::write(tmp.path().join("26.0.0/incomplete"), "").unwrap(); + // Missing binary: skipped. + std::fs::create_dir_all(tmp.path().join("20.0.0")).unwrap(); + // Non-version dir: skipped. + std::fs::create_dir_all(tmp.path().join(".downloads")).unwrap(); + + let found = scan_install_dir(tmp.path(), InstallOrigin::Aube); + let mut versions: Vec = found.iter().map(|n| n.version.to_string()).collect(); + versions.sort(); + assert_eq!(versions, vec!["22.1.0", "24.4.1"]); + } + + #[cfg(unix)] + #[test] + fn alias_symlinks_are_skipped() { + let tmp = tempfile::tempdir().unwrap(); + fab_install(tmp.path(), "22.1.0"); + std::os::unix::fs::symlink(tmp.path().join("22.1.0"), tmp.path().join("22")).unwrap(); + std::os::unix::fs::symlink(tmp.path().join("22.1.0"), tmp.path().join("latest")).unwrap(); + let found = scan_install_dir(tmp.path(), InstallOrigin::Aube); + assert_eq!(found.len(), 1, "{found:?}"); + } +} diff --git a/crates/aube-runtime/src/error.rs b/crates/aube-runtime/src/error.rs new file mode 100644 index 000000000..49a5103c1 --- /dev/null +++ b/crates/aube-runtime/src/error.rs @@ -0,0 +1,82 @@ +//! Typed errors for runtime resolution and installation. Each fatal +//! variant maps onto a stable `ERR_AUBE_*` code (see [`Error::code`]) +//! so the CLI layer can wrap them into miette diagnostics with the +//! right exit codes. + +use aube_codes::errors; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error( + "the project requires Node.js {requested} but no satisfying version is available{hint}" + )] + VersionUnsatisfied { requested: String, hint: String }, + + #[error("no Node.js release satisfies {requested}{platform_note}")] + NoMatchingVersion { + requested: String, + platform_note: String, + }, + + #[error("failed to download {url}: {reason}")] + DownloadFailed { url: String, reason: String }, + + #[error( + "checksum mismatch for {url}: expected sha256 {expected}, got {actual} — the archive was discarded" + )] + ChecksumMismatch { + url: String, + expected: String, + actual: String, + }, + + #[error("failed to extract Node.js archive: {reason}")] + ExtractFailed { reason: String }, + + /// `version` carries the full tool spec (`node@22.1.0`, + /// `aube@1.18.2`). + #[error("mise failed to install {version}: {reason}")] + MiseInstallFailed { version: String, reason: String }, + + #[error( + "no Node.js build is published for {platform}; set nodeDownloadMirrors to a mirror that carries one, or install Node via mise or your system package manager" + )] + UnsupportedPlatform { platform: String }, + + #[error("offline mode is active and {what} is not cached")] + Offline { what: String }, + + #[error("{context}: {source}")] + Io { + context: String, + #[source] + source: std::io::Error, + }, +} + +impl Error { + /// The stable `ERR_AUBE_*` identifier for this error. + pub fn code(&self) -> &'static str { + match self { + Error::VersionUnsatisfied { .. } => errors::ERR_AUBE_RUNTIME_VERSION_UNSATISFIED, + Error::NoMatchingVersion { .. } => errors::ERR_AUBE_RUNTIME_NO_MATCHING_VERSION, + Error::DownloadFailed { .. } => errors::ERR_AUBE_RUNTIME_DOWNLOAD_FAILED, + Error::ChecksumMismatch { .. } => errors::ERR_AUBE_RUNTIME_CHECKSUM_MISMATCH, + Error::ExtractFailed { .. } => errors::ERR_AUBE_RUNTIME_EXTRACT_FAILED, + Error::MiseInstallFailed { .. } => errors::ERR_AUBE_RUNTIME_MISE_INSTALL_FAILED, + Error::UnsupportedPlatform { .. } => errors::ERR_AUBE_RUNTIME_UNSUPPORTED_PLATFORM, + Error::Offline { .. } => errors::ERR_AUBE_OFFLINE, + // No bespoke code: I/O failures inside the runtime store + // surface through the generic exit path; the message names + // the failing path. + Error::Io { .. } => errors::ERR_AUBE_RUNTIME_DOWNLOAD_FAILED, + } + } + + pub(crate) fn io(context: impl Into, source: std::io::Error) -> Self { + Error::Io { + context: context.into(), + source, + } + } +} diff --git a/crates/aube-runtime/src/extract.rs b/crates/aube-runtime/src/extract.rs new file mode 100644 index 000000000..64402a2aa --- /dev/null +++ b/crates/aube-runtime/src/extract.rs @@ -0,0 +1,302 @@ +//! Plain extract-to-directory for Node release archives. Not the CAS +//! import path — runtime installs keep Node's native layout so +//! aube-managed and mise-managed installs look identical to +//! discovery. + +use crate::error::Error; +use std::path::{Component, Path, PathBuf}; + +/// Extract `archive_path` into `dest`. `strip_first` drops the +/// top-level directory (`node-v{V}-{slug}/`; aube release archives +/// have no top dir and pass `false`). `zip` selects the Windows zip +/// format; everything else is gzipped tar. +/// +/// Runs blocking I/O — call inside `spawn_blocking`. +pub(crate) fn extract_archive( + archive_path: &Path, + dest: &Path, + zip: bool, + strip_first: bool, +) -> Result<(), Error> { + if zip { + extract_zip(archive_path, dest, strip_first) + } else { + extract_tar_gz(archive_path, dest, strip_first) + } +} + +fn extract_tar_gz(archive_path: &Path, dest: &Path, strip_first: bool) -> Result<(), Error> { + let file = std::fs::File::open(archive_path) + .map_err(|e| Error::io(format!("open {}", archive_path.display()), e))?; + let decoder = flate2::read::GzDecoder::new(std::io::BufReader::new(file)); + let mut archive = tar::Archive::new(decoder); + for entry in archive.entries().map_err(|e| Error::ExtractFailed { + reason: e.to_string(), + })? { + let mut entry = entry.map_err(|e| Error::ExtractFailed { + reason: e.to_string(), + })?; + let path = entry.path().map_err(|e| Error::ExtractFailed { + reason: e.to_string(), + })?; + // `entry_dest_path` doubles as the path-escape guard: it + // returns None for the top-level dir entry (when stripping) + // and for any path containing `..` / absolute components. + let Some(stripped) = entry_dest_path(&path, strip_first) else { + continue; + }; + // Node tarballs legitimately contain intra-tree symlinks + // (`bin/npm → ../lib/node_modules/npm/bin/npm-cli.js`), so + // symlinks are allowed — but only when the resolved target + // stays inside `dest`. The checksum gate upstream already + // authenticates the archive; this is defense in depth. + if matches!( + entry.header().entry_type(), + tar::EntryType::Symlink | tar::EntryType::Link + ) { + let target = entry + .link_name() + .ok() + .flatten() + .ok_or_else(|| Error::ExtractFailed { + reason: format!("link entry {} has no target", stripped.display()), + })?; + if !link_target_stays_inside(dest, &stripped, &target) { + return Err(Error::ExtractFailed { + reason: format!( + "link {} escapes the install dir (target {})", + stripped.display(), + target.display() + ), + }); + } + } + let dest_path = dest.join(&stripped); + if let Some(parent) = dest_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| Error::io(format!("create {}", parent.display()), e))?; + } + entry.unpack(&dest_path).map_err(|e| Error::ExtractFailed { + reason: format!("{}: {e}", stripped.display()), + })?; + } + Ok(()) +} + +/// Lexically resolve a link target relative to its entry location and +/// check the result stays under `dest`. Absolute targets are rejected +/// outright. +fn link_target_stays_inside(dest: &Path, entry_rel: &Path, target: &Path) -> bool { + if target.is_absolute() { + return false; + } + let from_dir = match entry_rel.parent() { + Some(p) => dest.join(p), + None => dest.to_path_buf(), + }; + let resolved = aube_util::path::normalize_lexical(&from_dir.join(target)); + resolved.starts_with(dest) +} + +fn extract_zip(archive_path: &Path, dest: &Path, strip_first: bool) -> Result<(), Error> { + let file = std::fs::File::open(archive_path) + .map_err(|e| Error::io(format!("open {}", archive_path.display()), e))?; + let mut zip = zip::ZipArchive::new(file).map_err(|e| Error::ExtractFailed { + reason: e.to_string(), + })?; + for i in 0..zip.len() { + let mut entry = zip.by_index(i).map_err(|e| Error::ExtractFailed { + reason: e.to_string(), + })?; + let Some(raw_path) = entry.enclosed_name() else { + return Err(Error::ExtractFailed { + reason: format!("unsafe entry path {:?}", entry.name()), + }); + }; + let Some(stripped) = entry_dest_path(&raw_path, strip_first) else { + continue; + }; + let dest_path = dest.join(&stripped); + if entry.is_dir() { + std::fs::create_dir_all(&dest_path) + .map_err(|e| Error::io(format!("create {}", dest_path.display()), e))?; + continue; + } + if let Some(parent) = dest_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| Error::io(format!("create {}", parent.display()), e))?; + } + let mut out = std::fs::File::create(&dest_path) + .map_err(|e| Error::io(format!("create {}", dest_path.display()), e))?; + std::io::copy(&mut entry, &mut out).map_err(|e| Error::ExtractFailed { + reason: format!("{}: {e}", stripped.display()), + })?; + } + Ok(()) +} + +/// Compute an entry's destination-relative path, optionally dropping +/// the leading component, and validate it stays relative (no `..`, no +/// absolute components). Returns `None` for the bare top-level dir +/// entry when stripping, and for any unsafe path. +fn entry_dest_path(path: &Path, strip_first: bool) -> Option { + let rest: PathBuf = if strip_first { + let mut components = path.components(); + components.next()?; + components.as_path().to_path_buf() + } else { + path.to_path_buf() + }; + if rest.as_os_str().is_empty() { + return None; + } + for c in rest.components() { + match c { + Component::Normal(_) => {} + _ => return None, + } + } + Some(rest) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn make_tar_gz(entries: &[(&str, &str)]) -> Vec { + let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::fast(), + )); + for (path, content) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + builder + .append_data(&mut header, path, content.as_bytes()) + .unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() + } + + #[test] + fn tar_strips_top_dir() { + let tmp = tempfile::tempdir().unwrap(); + let bytes = make_tar_gz(&[ + ("node-v22.1.0-linux-x64/bin/node", "fake-binary"), + ("node-v22.1.0-linux-x64/LICENSE", "mit"), + ]); + let archive = tmp.path().join("a.tar.gz"); + std::fs::write(&archive, bytes).unwrap(); + let dest = tmp.path().join("out"); + std::fs::create_dir_all(&dest).unwrap(); + extract_archive(&archive, &dest, false, true).unwrap(); + assert!(dest.join("bin/node").is_file()); + assert!(dest.join("LICENSE").is_file()); + } + + #[cfg(unix)] + #[test] + fn tar_intra_tree_symlink_is_allowed() { + let tmp = tempfile::tempdir().unwrap(); + let mut builder = tar::Builder::new(flate2::write::GzEncoder::new( + Vec::new(), + flate2::Compression::fast(), + )); + let mut header = tar::Header::new_gnu(); + header.set_size(4); + header.set_mode(0o755); + header.set_cksum(); + builder + .append_data(&mut header, "top/lib/real.js", "hi()".as_bytes()) + .unwrap(); + let mut link = tar::Header::new_gnu(); + link.set_entry_type(tar::EntryType::Symlink); + link.set_size(0); + link.set_cksum(); + builder + .append_link(&mut link, "top/bin/npm", "../lib/real.js") + .unwrap(); + let bytes = builder.into_inner().unwrap().finish().unwrap(); + let archive = tmp.path().join("a.tar.gz"); + std::fs::write(&archive, bytes).unwrap(); + let dest = tmp.path().join("out"); + std::fs::create_dir_all(&dest).unwrap(); + extract_archive(&archive, &dest, false, true).unwrap(); + assert!(dest.join("bin/npm").exists()); + assert_eq!( + std::fs::read_to_string(dest.join("bin/npm")).unwrap(), + "hi()" + ); + } + + #[test] + fn escape_paths_are_rejected_by_strip_guard() { + // The tar crate refuses to even *author* `..` entries, so the + // guard is exercised directly: any remainder containing `..`, + // `.` or absolute components must be dropped. + assert_eq!(entry_dest_path(Path::new("top/../escape.txt"), true), None); + assert_eq!(entry_dest_path(Path::new("top/a/../../b"), true), None); + assert_eq!( + entry_dest_path(Path::new("top//etc/passwd"), true), + Some(PathBuf::from("etc/passwd")) + ); + assert_eq!(entry_dest_path(Path::new("top"), true), None); + assert_eq!( + entry_dest_path(Path::new("top/bin/node"), true), + Some(PathBuf::from("bin/node")) + ); + // No-strip mode: paths kept verbatim, same escape rules. + assert_eq!( + entry_dest_path(Path::new("aube"), false), + Some(PathBuf::from("aube")) + ); + assert_eq!(entry_dest_path(Path::new("../aube"), false), None); + } + + #[test] + fn symlink_escape_guard() { + let dest = Path::new("/x/dest"); + assert!(link_target_stays_inside( + dest, + Path::new("bin/npm"), + Path::new("../lib/npm-cli.js") + )); + assert!(!link_target_stays_inside( + dest, + Path::new("bin/npm"), + Path::new("../../../etc/passwd") + )); + assert!(!link_target_stays_inside( + dest, + Path::new("bin/npm"), + Path::new("/etc/passwd") + )); + } + + #[test] + fn zip_strips_top_dir() { + let tmp = tempfile::tempdir().unwrap(); + let archive = tmp.path().join("a.zip"); + let file = std::fs::File::create(&archive).unwrap(); + let mut writer = zip::ZipWriter::new(file); + let opts: zip::write::SimpleFileOptions = Default::default(); + writer + .start_file("node-v22.1.0-win-x64/node.exe", opts) + .unwrap(); + writer.write_all(b"fake-exe").unwrap(); + writer + .start_file("node-v22.1.0-win-x64/npm.cmd", opts) + .unwrap(); + writer.write_all(b"@echo off").unwrap(); + writer.finish().unwrap(); + + let dest = tmp.path().join("out"); + std::fs::create_dir_all(&dest).unwrap(); + extract_archive(&archive, &dest, true, true).unwrap(); + assert!(dest.join("node.exe").is_file()); + assert!(dest.join("npm.cmd").is_file()); + } +} diff --git a/crates/aube-runtime/src/http.rs b/crates/aube-runtime/src/http.rs new file mode 100644 index 000000000..7c18d613d --- /dev/null +++ b/crates/aube-runtime/src/http.rs @@ -0,0 +1,163 @@ +//! Thin HTTP layer for nodejs.org (and mirrors): a shared client and +//! a retrying GET. Deliberately not `aube-registry`'s `RegistryClient` +//! — that type drags in npm auth/packument machinery irrelevant to +//! dist downloads, and keeping this crate off it keeps the compile +//! graph parallel. Retry semantics mirror +//! `aube-registry/src/client/tarball.rs`: request-level retries with +//! exponential backoff on transport errors, 5xx, and 429; `Retry-After` +//! honored when present. + +use crate::error::Error; +use std::time::Duration; + +pub(crate) struct Http { + client: reqwest::Client, + retries: u32, +} + +pub(crate) struct HttpResponse { + pub(crate) etag: Option, + pub(crate) last_modified: Option, + pub(crate) body: Option, +} + +impl Http { + pub(crate) fn new(retries: u32) -> Self { + static UA: std::sync::OnceLock = std::sync::OnceLock::new(); + let user_agent = UA.get_or_init(|| { + format!( + "aube/{} ({} {})", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH + ) + }); + let client = aube_util::http::with_webpki_root_fallback(reqwest::Client::builder()) + .user_agent(user_agent) + // Archives are already compressed; metadata (index.json) + // benefits from gzip. reqwest handles per-request override + // via Accept-Encoding when we stream archives. + .gzip(true) + .timeout(Duration::from_secs(120)) + .tcp_nodelay(true) + .build() + .expect("reqwest client construction cannot fail with static config"); + Http { client, retries } + } + + /// GET `url`, optionally conditional (`If-None-Match` / + /// `If-Modified-Since`). Returns the open response for the caller + /// to consume; 304 yields `body: None`. + pub(crate) async fn get( + &self, + url: &str, + etag: Option<&str>, + last_modified: Option<&str>, + identity_encoding: bool, + ) -> Result { + validate_url(url)?; + let mut attempt = 0u32; + loop { + let mut req = self.client.get(url); + if let Some(tag) = etag { + req = req.header(reqwest::header::IF_NONE_MATCH, tag); + } + if let Some(lm) = last_modified { + req = req.header(reqwest::header::IF_MODIFIED_SINCE, lm); + } + if identity_encoding { + req = req.header(reqwest::header::ACCEPT_ENCODING, "identity"); + } + match req.send().await { + Ok(resp) => { + let status = resp.status(); + if status == reqwest::StatusCode::NOT_MODIFIED { + return Ok(HttpResponse { + etag: header_string(&resp, reqwest::header::ETAG), + last_modified: header_string(&resp, reqwest::header::LAST_MODIFIED), + body: None, + }); + } + if status.is_success() { + return Ok(HttpResponse { + etag: header_string(&resp, reqwest::header::ETAG), + last_modified: header_string(&resp, reqwest::header::LAST_MODIFIED), + body: Some(resp), + }); + } + let retriable = status.is_server_error() + || status == reqwest::StatusCode::TOO_MANY_REQUESTS; + if !retriable || attempt >= self.retries { + return Err(Error::DownloadFailed { + url: url.to_string(), + reason: format!("HTTP {status}"), + }); + } + let wait = retry_after(&resp).unwrap_or_else(|| backoff(attempt)); + tracing::warn!( + code = aube_codes::warnings::WARN_AUBE_HTTP_RETRY_TRANSIENT, + url, + status = %status, + backoff_ms = wait.as_millis() as u64, + "retrying node dist fetch" + ); + tokio::time::sleep(wait).await; + } + Err(e) => { + if attempt >= self.retries { + return Err(Error::DownloadFailed { + url: url.to_string(), + reason: e.to_string(), + }); + } + let wait = backoff(attempt); + tracing::warn!( + code = aube_codes::warnings::WARN_AUBE_HTTP_RETRY_TRANSPORT, + url, + error = %e, + backoff_ms = wait.as_millis() as u64, + "retrying node dist fetch after transport error" + ); + tokio::time::sleep(wait).await; + } + } + attempt += 1; + } + } +} + +fn header_string(resp: &reqwest::Response, name: reqwest::header::HeaderName) -> Option { + resp.headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) +} + +fn retry_after(resp: &reqwest::Response) -> Option { + let secs: u64 = resp + .headers() + .get(reqwest::header::RETRY_AFTER)? + .to_str() + .ok()? + .trim() + .parse() + .ok()?; + Some(Duration::from_secs(secs.min(30))) +} + +fn backoff(attempt: u32) -> Duration { + Duration::from_millis(500u64.saturating_mul(1 << attempt.min(4))) +} + +/// Same belt-and-suspenders scheme check as +/// `aube-registry`'s `validate_tarball_url`. +fn validate_url(url: &str) -> Result<(), Error> { + if url.starts_with("https://") || url.starts_with("http://") { + Ok(()) + } else { + Err(Error::DownloadFailed { + url: url.to_string(), + reason: "only http(s) URLs are allowed".to_string(), + }) + } +} diff --git a/crates/aube-runtime/src/index.rs b/crates/aube-runtime/src/index.rs new file mode 100644 index 000000000..fd5f43bb7 --- /dev/null +++ b/crates/aube-runtime/src/index.rs @@ -0,0 +1,317 @@ +//! The nodejs.org dist index (`{mirror}/index.json`): fetch, disk +//! cache with ETag/Last-Modified revalidation (mirroring +//! `aube-registry/src/client/cache.rs`'s packument cache), and +//! version selection against a [`NodeSpec`]. + +use crate::error::Error; +use crate::http::Http; +use crate::paths; +use crate::platform::Platform; +use crate::spec::NodeSpec; +use crate::{NetworkMode, RuntimeConfig}; +use serde::{Deserialize, Serialize}; + +/// One release line from index.json (newest first). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IndexEntry { + /// `"v22.1.0"` in the raw feed; exposed parsed. + #[serde(with = "v_prefixed_version")] + pub version: node_semver::Version, + /// `false`, or the LTS codename (`"Jod"`). + #[serde(default)] + pub lts: LtsField, + /// Artifact availability tokens (`"osx-arm64-tar"`, `"win-x64-zip"`, …). + #[serde(default)] + pub files: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(untagged)] +pub enum LtsField { + #[default] + NotLts, + Flag(bool), + Codename(String), +} + +impl LtsField { + pub fn codename(&self) -> Option<&str> { + match self { + LtsField::Codename(name) => Some(name), + _ => None, + } + } + + pub fn is_lts(&self) -> bool { + matches!(self, LtsField::Codename(_)) + } +} + +mod v_prefixed_version { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(v: &node_semver::Version, ser: S) -> Result { + ser.serialize_str(&format!("v{v}")) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result { + let raw = String::deserialize(de)?; + node_semver::Version::parse(raw.trim_start_matches('v')) + .map_err(|e| serde::de::Error::custom(format!("bad version {raw:?}: {e}"))) + } +} + +/// Wrapper persisted to disk — same shape as the packument cache: +/// validators + fetch time + payload. +#[derive(Serialize, Deserialize)] +struct CachedIndex { + etag: Option, + last_modified: Option, + fetched_at: u64, + entries: Vec, +} + +const INDEX_TTL_SECS: u64 = 30 * 60; + +fn now_epoch() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Load the dist index for `cfg`'s mirror, serving the disk cache +/// when fresh, revalidating with ETag/If-Modified-Since when stale, +/// and serving any cache at all in offline mode. +pub(crate) async fn load_index(http: &Http, cfg: &RuntimeConfig) -> Result, Error> { + let base = cfg.mirror_base(); + let cache_path = paths::index_cache_path(&base); + let cached: Option = cache_path + .as_deref() + .and_then(|p| std::fs::read(p).ok()) + .and_then(|bytes| serde_json::from_slice(&bytes).ok()); + + if let Some(ref c) = cached { + let age = now_epoch().saturating_sub(c.fetched_at); + if age < INDEX_TTL_SECS || cfg.network == NetworkMode::Offline { + return Ok(c.entries.clone()); + } + } else if cfg.network == NetworkMode::Offline { + return Err(Error::Offline { + what: "the Node.js release index".to_string(), + }); + } + + let url = format!("{base}/index.json"); + let resp = match http + .get( + &url, + cached.as_ref().and_then(|c| c.etag.as_deref()), + cached.as_ref().and_then(|c| c.last_modified.as_deref()), + false, + ) + .await + { + Ok(resp) => resp, + Err(e) => { + // Network trouble with a (stale) cache on disk: serve the + // cache. A 30-minute-old index is far better than failing + // `aubr test` outright on a flaky connection. + if let Some(c) = cached { + tracing::debug!(error = %e, "node index refresh failed; serving stale cache"); + return Ok(c.entries); + } + return Err(e); + } + }; + + let (entries, etag, last_modified) = match resp.body { + None => { + // 304 — refresh the cache timestamp so the TTL restarts. + let c = cached.expect("304 implies a conditional request from cache"); + ( + c.entries, + resp.etag.or(c.etag), + resp.last_modified.or(c.last_modified), + ) + } + Some(body) => { + let bytes = body.bytes().await.map_err(|e| Error::DownloadFailed { + url: url.clone(), + reason: e.to_string(), + })?; + let entries: Vec = + serde_json::from_slice(&bytes).map_err(|e| Error::DownloadFailed { + url: url.clone(), + reason: format!("invalid index.json: {e}"), + })?; + (entries, resp.etag, resp.last_modified) + } + }; + + if let Some(path) = cache_path { + let wrapper = CachedIndex { + etag, + last_modified, + fetched_at: now_epoch(), + entries: entries.clone(), + }; + if let Ok(bytes) = serde_json::to_vec(&wrapper) + && let Some(parent) = path.parent() + { + let _ = std::fs::create_dir_all(parent); + if let Err(e) = aube_util::fs_atomic::atomic_write(&path, &bytes) { + tracing::warn!( + code = aube_codes::warnings::WARN_AUBE_CACHE_WRITE_FAILED, + path = %path.display(), + error = %e, + "failed to write node index cache" + ); + } + } + } + Ok(entries) +} + +/// Pick the best entry satisfying `spec`, requiring the platform's +/// artifact token in `files[]` (the feed marks which builds exist per +/// release). Entries are newest-first; "best" is the highest +/// satisfying version. +/// +/// musl note: official index entries never list musl tokens — musl +/// artifacts live on unofficial-builds with availability tracked by +/// its own index. For musl platforms the `files[]` gate uses the bare +/// linux token (see [`Platform::index_files_token`]); the checksum +/// fetch is the authoritative existence check. +pub(crate) fn select<'a>( + entries: &'a [IndexEntry], + spec: &NodeSpec, + platform: &Platform, +) -> Option<&'a IndexEntry> { + let token = platform.index_files_token(); + let available = |e: &IndexEntry| e.files.is_empty() || e.files.iter().any(|f| f == &token); + let mut candidates: Vec<&IndexEntry> = entries + .iter() + .filter(|e| available(e)) + .filter(|e| match spec { + NodeSpec::Exact(v) => &e.version == v, + NodeSpec::Range(r) => e.version.satisfies(r), + NodeSpec::Lts => e.lts.is_lts(), + NodeSpec::Latest => true, + NodeSpec::LtsCodename(name) => e + .lts + .codename() + .is_some_and(|c| c.eq_ignore_ascii_case(name)), + }) + .collect(); + candidates.sort_by(|a, b| a.version.cmp(&b.version)); + candidates.pop() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(version: &str, lts: Option<&str>, files: &[&str]) -> IndexEntry { + IndexEntry { + version: version.parse().unwrap(), + lts: match lts { + Some(name) => LtsField::Codename(name.to_string()), + None => LtsField::Flag(false), + }, + files: files.iter().map(|s| s.to_string()).collect(), + } + } + + fn plat() -> Platform { + Platform { + os: "darwin".into(), + cpu: "arm64".into(), + libc: None, + } + } + + fn feed() -> Vec { + vec![ + entry("26.1.0", None, &["osx-arm64-tar", "win-x64-zip"]), + entry("24.4.1", Some("Krypton"), &["osx-arm64-tar", "win-x64-zip"]), + entry("24.4.0", Some("Krypton"), &["osx-arm64-tar"]), + entry("22.11.0", Some("Jod"), &["osx-arm64-tar"]), + entry("0.12.18", None, &[]), + ] + } + + #[test] + fn selects_highest_in_range() { + let f = feed(); + let spec = NodeSpec::parse("^24").unwrap(); + assert_eq!( + select(&f, &spec, &plat()).unwrap().version.to_string(), + "24.4.1" + ); + } + + #[test] + fn selects_latest_and_lts() { + let f = feed(); + assert_eq!( + select(&f, &NodeSpec::Latest, &plat()) + .unwrap() + .version + .to_string(), + "26.1.0" + ); + assert_eq!( + select(&f, &NodeSpec::Lts, &plat()) + .unwrap() + .version + .to_string(), + "24.4.1" + ); + } + + #[test] + fn selects_codename_case_insensitively() { + let f = feed(); + let spec = NodeSpec::LtsCodename("jod".into()); + assert_eq!( + select(&f, &spec, &plat()).unwrap().version.to_string(), + "22.11.0" + ); + } + + #[test] + fn respects_files_availability() { + let f = feed(); + let win = Platform { + os: "win32".into(), + cpu: "x64".into(), + libc: None, + }; + // 24.4.0 has no win build; the LTS pick for windows is 24.4.1. + let spec = NodeSpec::parse("24.4").unwrap(); + assert_eq!( + select(&f, &spec, &win).unwrap().version.to_string(), + "24.4.1" + ); + } + + #[test] + fn no_match_returns_none() { + let f = feed(); + assert!(select(&f, &NodeSpec::parse("^99").unwrap(), &plat()).is_none()); + assert!(select(&f, &NodeSpec::LtsCodename("argon".into()), &plat()).is_none()); + } + + #[test] + fn raw_feed_shape_parses() { + let raw = r#"[ + {"version": "v26.1.0", "date": "2026-06-01", "files": ["osx-arm64-tar"], "npm": "11.16.0", "lts": false, "security": false}, + {"version": "v24.4.1", "date": "2026-05-20", "files": ["osx-arm64-tar"], "lts": "Krypton", "security": true} + ]"#; + let entries: Vec = serde_json::from_str(raw).unwrap(); + assert_eq!(entries.len(), 2); + assert!(!entries[0].lts.is_lts()); + assert_eq!(entries[1].lts.codename(), Some("Krypton")); + } +} diff --git a/crates/aube-runtime/src/installer.rs b/crates/aube-runtime/src/installer.rs new file mode 100644 index 000000000..a23976515 --- /dev/null +++ b/crates/aube-runtime/src/installer.rs @@ -0,0 +1,240 @@ +//! Download → verify → extract → atomically publish a Node version +//! into aube's runtime dir. +//! +//! Concurrency: an `xx::fslock` per version serializes competing aube +//! processes; after acquiring the lock the destination is re-checked +//! (the other process may have won). Publishing is staging-dir → +//! `fs::rename`, so the final path only ever appears fully formed — +//! no `incomplete` marker is needed for aube's own installs. + +use crate::discover::{InstallOrigin, InstalledNode, validate_install}; +use crate::error::Error; +use crate::http::Http; +use crate::paths; +use crate::progress::{DownloadProgress, InstallPhase}; +use sha2::Digest; +use std::path::{Path, PathBuf}; +use tokio::io::AsyncWriteExt; + +pub(crate) struct DownloadSpec { + pub(crate) url: String, + pub(crate) expected_sha256: [u8; 32], + /// Windows zip vs gz tarball. + pub(crate) zip: bool, +} + +/// Install `version` from `spec` into the runtime dir, returning the +/// validated install. No-op (returns the existing install) when the +/// version is already present and valid. +pub(crate) async fn install( + http: &Http, + version: &node_semver::Version, + spec: &DownloadSpec, + progress: &dyn DownloadProgress, +) -> Result { + let runtime_dir = paths::runtime_dir().ok_or_else(|| { + Error::io( + "locate the aube runtime dir", + std::io::Error::new(std::io::ErrorKind::NotFound, "no home directory"), + ) + })?; + let dest = runtime_dir.join(version.to_string()); + if let Some(existing) = validate_install(&dest, version.clone(), InstallOrigin::Aube) { + return Ok(existing); + } + + let locks = paths::locks_dir().expect("runtime_dir implies locks_dir"); + std::fs::create_dir_all(&locks) + .map_err(|e| Error::io(format!("create {}", locks.display()), e))?; + let lock_path = locks.join(format!("{version}.lock")); + // fslock is blocking; hold it on a blocking thread for the whole + // install. The closure below runs the async download via a handle + // back into the runtime. + let lock = tokio::task::spawn_blocking(move || xx::fslock::FSLock::new(&lock_path).lock()) + .await + .map_err(|e| { + Error::io( + "acquire runtime install lock", + std::io::Error::other(e.to_string()), + ) + })? + .map_err(|e| { + Error::io( + "acquire runtime install lock", + std::io::Error::other(e.to_string()), + ) + })?; + + // Re-check under the lock: another process may have finished the + // install while we waited. + if let Some(existing) = validate_install(&dest, version.clone(), InstallOrigin::Aube) { + drop(lock); + return Ok(existing); + } + + gc_stale_temp_dirs(); + + let result = download_verify_extract(http, version, spec, &dest, progress).await; + drop(lock); + result +} + +async fn download_verify_extract( + http: &Http, + version: &node_semver::Version, + spec: &DownloadSpec, + dest: &Path, + progress: &dyn DownloadProgress, +) -> Result { + let downloads = paths::downloads_dir().expect("runtime_dir implies downloads_dir"); + let staging_root = paths::staging_dir().expect("runtime_dir implies staging_dir"); + std::fs::create_dir_all(&downloads) + .map_err(|e| Error::io(format!("create {}", downloads.display()), e))?; + std::fs::create_dir_all(&staging_root) + .map_err(|e| Error::io(format!("create {}", staging_root.display()), e))?; + + // Download, hashing incrementally. + progress.on_phase(version, InstallPhase::Downloading); + let archive_name = spec + .url + .rsplit('/') + .next() + .filter(|s| !s.is_empty()) + .unwrap_or("node-archive"); + let archive_path = downloads.join(format!("{archive_name}.{}", std::process::id())); + let actual = stream_to_file(http, &spec.url, &archive_path, progress).await?; + + progress.on_phase(version, InstallPhase::Verifying); + if actual != spec.expected_sha256 { + let _ = std::fs::remove_file(&archive_path); + return Err(Error::ChecksumMismatch { + url: spec.url.clone(), + expected: hex::encode(spec.expected_sha256), + actual: hex::encode(actual), + }); + } + + // Extract into staging, then atomically publish. + progress.on_phase(version, InstallPhase::Extracting); + let staging = staging_root.join(format!("{version}.{}", std::process::id())); + std::fs::create_dir_all(&staging) + .map_err(|e| Error::io(format!("create {}", staging.display()), e))?; + let extract_archive = archive_path.clone(); + let extract_staging = staging.clone(); + let zip = spec.zip; + let extract_result = tokio::task::spawn_blocking(move || { + crate::extract::extract_archive(&extract_archive, &extract_staging, zip, true) + }) + .await + .map_err(|e| Error::ExtractFailed { + reason: e.to_string(), + })?; + let _ = std::fs::remove_file(&archive_path); + if let Err(e) = extract_result { + let _ = std::fs::remove_dir_all(&staging); + return Err(e); + } + + match std::fs::rename(&staging, dest) { + Ok(()) => {} + Err(rename_err) => { + // A concurrent winner (or an AlreadyExists race on + // platforms where rename-over-dir fails) is success if the + // destination now validates. + let _ = std::fs::remove_dir_all(&staging); + if validate_install(dest, version.clone(), InstallOrigin::Aube).is_none() { + return Err(Error::io( + format!("publish {} into {}", version, dest.display()), + rename_err, + )); + } + } + } + progress.on_done(); + + validate_install(dest, version.clone(), InstallOrigin::Aube).ok_or_else(|| { + Error::ExtractFailed { + reason: format!( + "extracted archive did not produce a usable node at {}", + dest.display() + ), + } + }) +} + +/// Stream `url` to `path`, returning the SHA-256 of the bytes +/// written. +pub(crate) async fn stream_to_file( + http: &Http, + url: &str, + path: &PathBuf, + progress: &dyn DownloadProgress, +) -> Result<[u8; 32], Error> { + let resp = http.get(url, None, None, true).await?; + let mut body = resp.body.ok_or_else(|| Error::DownloadFailed { + url: url.to_string(), + reason: "unexpected empty response".to_string(), + })?; + progress.on_download_start(body.content_length()); + let mut hasher = sha2::Sha256::new(); + let mut file = tokio::fs::File::create(path) + .await + .map_err(|e| Error::io(format!("create {}", path.display()), e))?; + loop { + match body.chunk().await { + Ok(Some(chunk)) => { + hasher.update(&chunk); + progress.on_download_chunk(chunk.len() as u64); + file.write_all(&chunk) + .await + .map_err(|e| Error::io(format!("write {}", path.display()), e))?; + } + Ok(None) => break, + Err(e) => { + let _ = tokio::fs::remove_file(path).await; + return Err(Error::DownloadFailed { + url: url.to_string(), + reason: e.to_string(), + }); + } + } + } + file.flush() + .await + .map_err(|e| Error::io(format!("flush {}", path.display()), e))?; + Ok(hasher.finalize().into()) +} + +/// Best-effort cleanup of crash debris in `.downloads/` and `.tmp/` +/// older than 24h. Runs under the per-version lock, so concurrent +/// installs of *other* versions could in principle race a GC of their +/// live temp files — the age threshold makes that practically +/// impossible (a 24h-old in-flight download is dead). +fn gc_stale_temp_dirs() { + let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(24 * 3600); + for dir in [paths::downloads_dir(), paths::staging_dir()] + .into_iter() + .flatten() + { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let Ok(meta) = entry.metadata() else { + continue; + }; + let Ok(modified) = meta.modified() else { + continue; + }; + if modified < cutoff { + let path = entry.path(); + tracing::debug!(path = %path.display(), "removing stale runtime temp entry"); + if meta.is_dir() { + let _ = std::fs::remove_dir_all(&path); + } else { + let _ = std::fs::remove_file(&path); + } + } + } + } +} diff --git a/crates/aube-runtime/src/lib.rs b/crates/aube-runtime/src/lib.rs new file mode 100644 index 000000000..94dc6349d --- /dev/null +++ b/crates/aube-runtime/src/lib.rs @@ -0,0 +1,181 @@ +//! Node.js runtime management for aube: resolve a project's requested +//! Node version (devEngines.runtime / `.node-version` / `.nvmrc`), +//! discover satisfying installs (PATH, mise, aube's own runtime dir), +//! and download missing versions — delegating to mise by default when +//! it's present so mise users keep one Node store. +//! +//! This crate is policy-light plumbing: settings resolution, manifest +//! parsing, PATH injection, and lockfile recording live in the CLI +//! crate. Nothing here prints; progress flows through +//! [`DownloadProgress`] and diagnostics through `tracing`. + +mod discover; +mod error; +mod extract; +mod http; +mod index; +mod installer; +mod mise; +mod paths; +mod platform; +mod progress; +mod resolver; +mod self_install; +mod shasums; +mod sources; +mod spec; + +pub use discover::{ + InstallOrigin, InstalledNode, list_installed, mise_node_installs_dir, probe_path_node, +}; +pub use error::Error; +pub use mise::mise_on_path; +pub use paths::{install_dir, runtime_dir}; +pub use platform::Platform; +pub use progress::{DownloadProgress, InstallPhase, NoopProgress}; +pub use resolver::{NodeRuntime, Resolution, ResolvedFrom}; +pub use self_install::{ + InstalledAube, find_installed_aube, install_aube, latest_aube_version, list_installed_aube, + release_target_triple, self_dir, +}; +pub use shasums::{sha256_from_sri, sri_sha256}; +pub use sources::{effective_request, find_version_file}; +pub use spec::{NodeRequest, NodeSpec, RequestSource}; + +use std::collections::BTreeMap; + +/// Default download base, matching pnpm's runtime resolver (the +/// `/download/release` tree mirrors `/dist` and is what pnpm records +/// in lockfiles). +pub const DEFAULT_MIRROR_BASE: &str = "https://nodejs.org/download/release"; + +/// musl builds aren't published on the official mirror; pnpm and mise +/// both source them from unofficial-builds. Used only when no custom +/// mirror is configured. +pub const UNOFFICIAL_BASE: &str = "https://unofficial-builds.nodejs.org/download/release"; + +/// Who installs a missing runtime (the `runtimeInstaller` setting). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum InstallerMode { + /// Delegate to `mise install` when mise is on PATH, else download. + #[default] + Auto, + /// Always delegate; fail if mise is missing. + Mise, + /// Never delegate. + Aube, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum NetworkMode { + #[default] + Online, + /// Serve caches regardless of staleness; never touch the network. + Offline, +} + +/// Configuration for a [`NodeRuntime`]. +#[derive(Debug, Clone, Default)] +pub struct RuntimeConfig { + pub installer: InstallerMode, + /// `nodeDownloadMirrors.release` — replaces the official base for + /// index, checksums, and artifacts when set. + pub mirror: Option, + pub network: NetworkMode, + /// Extra request retries after the first attempt (default 2). + pub retries: u32, +} + +impl RuntimeConfig { + pub fn new() -> Self { + RuntimeConfig { + retries: 2, + ..Default::default() + } + } + + /// The base URL for the index and (non-musl) artifacts, without a + /// trailing slash. + pub(crate) fn mirror_base(&self) -> String { + match &self.mirror { + Some(m) => m.trim_end_matches('/').to_string(), + None => DEFAULT_MIRROR_BASE.to_string(), + } + } + + /// The base URL artifacts and checksums are fetched from for + /// `platform`: the configured mirror always wins (a corporate + /// mirror may host musl artifacts); otherwise musl platforms + /// route to unofficial-builds. + pub(crate) fn artifact_base(&self, platform: &Platform) -> String { + if self.mirror.is_none() && platform.libc.as_deref() == Some("musl") { + UNOFFICIAL_BASE.to_string() + } else { + self.mirror_base() + } + } +} + +/// An exact resolved version plus its per-platform artifacts — +/// the interchange shape for lockfile pins (the CLI maps this onto +/// `aube_lockfile::RuntimePin`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PinnedNode { + pub version: node_semver::Version, + pub variants: Vec, +} + +impl PinnedNode { + pub fn variant_for(&self, os: &str, cpu: &str, libc: Option<&str>) -> Option<&PinnedVariant> { + self.variants + .iter() + .find(|v| v.os == os && v.cpu == cpu && v.libc.as_deref() == libc) + } +} + +/// One platform's artifact in a [`PinnedNode`]. Vocabulary matches +/// pnpm's lockfile (`os: win32`, `archive: tarball|zip`, +/// `integrity: sha256-`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PinnedVariant { + pub os: String, + pub cpu: String, + pub libc: Option, + pub archive: String, + pub url: String, + pub integrity_sri: String, + pub bin: BTreeMap, + pub prefix: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mirror_bases() { + let mut cfg = RuntimeConfig::new(); + assert_eq!(cfg.mirror_base(), DEFAULT_MIRROR_BASE); + cfg.mirror = Some("https://npmmirror.com/mirrors/node/".to_string()); + assert_eq!(cfg.mirror_base(), "https://npmmirror.com/mirrors/node"); + + let musl = Platform { + os: "linux".into(), + cpu: "x64".into(), + libc: Some("musl".into()), + }; + // Custom mirror wins even for musl. + assert_eq!( + cfg.artifact_base(&musl), + "https://npmmirror.com/mirrors/node" + ); + cfg.mirror = None; + assert_eq!(cfg.artifact_base(&musl), UNOFFICIAL_BASE); + let glibc = Platform { + os: "linux".into(), + cpu: "x64".into(), + libc: None, + }; + assert_eq!(cfg.artifact_base(&glibc), DEFAULT_MIRROR_BASE); + } +} diff --git a/crates/aube-runtime/src/mise.rs b/crates/aube-runtime/src/mise.rs new file mode 100644 index 000000000..4d8258854 --- /dev/null +++ b/crates/aube-runtime/src/mise.rs @@ -0,0 +1,97 @@ +//! mise integration: read-only discovery lives in [`crate::discover`]; +//! this module handles *delegation* — asking mise to install a version +//! so mise users keep a single Node store on disk. + +use crate::discover::{self, InstallOrigin, InstalledNode}; +use crate::error::Error; +use std::path::PathBuf; + +/// The `mise` executable on PATH, if any. Memoized — PATH walks are +/// cheap but this gets asked on every resolution that reaches the +/// install stage. +pub fn mise_on_path() -> Option { + static FOUND: std::sync::OnceLock> = std::sync::OnceLock::new(); + FOUND + .get_or_init(|| discover::find_on_path(if cfg!(windows) { "mise.exe" } else { "mise" })) + .clone() +} + +/// Run `mise install node@` and return the resulting install +/// from mise's installs dir. +/// +/// Output is captured, never inherited — a clx progress bar may be +/// live on stderr, and mise's own progress output would corrupt it. +/// Captured stderr is surfaced through `tracing::debug!` and, on +/// failure, the tail rides along in the error message. Failure is +/// exit-status only; mise's stderr is human prose and not a stable +/// interface. +pub(crate) async fn install_via_mise( + mise_bin: &std::path::Path, + version: &node_semver::Version, +) -> Result { + install_tool_via_mise(mise_bin, "node", version).await?; + + // Rescan for exactly that version. Exit 0 but no discoverable + // install usually means aube's view of the installs dir differs + // from mise's config (custom MISE_DATA_DIR in mise's own env, + // shared install dirs, etc.). + find_mise_install(version).ok_or_else(|| Error::MiseInstallFailed { + version: format!("node@{version}"), + reason: format!( + "mise reported success but node@{} was not found under {} — \ + if mise uses a custom data dir, export MISE_DATA_DIR so aube sees the same path", + version, + discover::mise_node_installs_dir() + .unwrap_or_default() + .display() + ), + }) +} + +/// Run `mise install @` for any tool. Callers rescan +/// the installs dir themselves — layouts differ per tool. +pub(crate) async fn install_tool_via_mise( + mise_bin: &std::path::Path, + tool: &str, + version: &node_semver::Version, +) -> Result<(), Error> { + let spec = format!("{tool}@{version}"); + tracing::debug!(mise = %mise_bin.display(), %spec, "delegating install to mise"); + let output = tokio::process::Command::new(mise_bin) + .args(["install", &spec]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .await + .map_err(|e| Error::MiseInstallFailed { + version: spec.clone(), + reason: format!("failed to spawn mise: {e}"), + })?; + + let stderr = String::from_utf8_lossy(&output.stderr); + for line in stderr.lines() { + tracing::debug!(target: "mise", "{line}"); + } + + if !output.status.success() { + let tail: Vec<&str> = stderr.lines().rev().take(20).collect(); + let tail: String = tail.into_iter().rev().collect::>().join("\n"); + return Err(Error::MiseInstallFailed { + version: spec, + reason: format!( + "exit status {}{}{}", + output.status.code().unwrap_or(-1), + if tail.is_empty() { "" } else { "\n" }, + tail + ), + }); + } + Ok(()) +} + +/// Look up one exact version in mise's installs dir. +pub(crate) fn find_mise_install(version: &node_semver::Version) -> Option { + let dir = discover::mise_node_installs_dir()?.join(version.to_string()); + discover::validate_install(&dir, version.clone(), InstallOrigin::Mise) +} diff --git a/crates/aube-runtime/src/paths.rs b/crates/aube-runtime/src/paths.rs new file mode 100644 index 000000000..217782f6a --- /dev/null +++ b/crates/aube-runtime/src/paths.rs @@ -0,0 +1,92 @@ +//! On-disk layout for aube-managed Node installs and caches. Follows +//! the same XDG / `%LOCALAPPDATA%` / home-fallback convention as +//! `aube-store/src/dirs.rs` (kept separate so this crate doesn't pull +//! in the CAS machinery for three path joins). +//! +//! ```text +//! $XDG_DATA_HOME/aube/nodejs/ +//! ├── 24.1.0/ # native layout: unix bin/node, win node.exe +//! ├── .downloads/ # in-flight archive downloads +//! ├── .tmp/ # extraction staging (rename source) +//! └── .locks/ # per-version fslock files +//! ``` + +use std::path::PathBuf; + +/// Root of aube-managed Node installs. `AUBE_RUNTIME_DIR` overrides +/// for tests and unusual setups. +pub fn runtime_dir() -> Option { + if let Some(dir) = std::env::var_os("AUBE_RUNTIME_DIR") { + return Some(PathBuf::from(dir)); + } + #[cfg(windows)] + if let Ok(local) = std::env::var("LOCALAPPDATA") { + return Some(PathBuf::from(local).join("aube/nodejs")); + } + let data_home = match aube_util::env::xdg_data_home() { + Some(xdg) => xdg, + None => aube_util::env::home_dir()?.join(".local/share"), + }; + Some(data_home.join("aube/nodejs")) +} + +/// The install dir for an exact version: `/24.1.0/`. +pub fn install_dir(version: &node_semver::Version) -> Option { + runtime_dir().map(|d| d.join(version.to_string())) +} + +pub(crate) fn downloads_dir() -> Option { + runtime_dir().map(|d| d.join(".downloads")) +} + +pub(crate) fn staging_dir() -> Option { + runtime_dir().map(|d| d.join(".tmp")) +} + +pub(crate) fn locks_dir() -> Option { + runtime_dir().map(|d| d.join(".locks")) +} + +/// Cache directory shared with the rest of aube +/// (`$XDG_CACHE_HOME/aube`), mirroring `aube-store`'s `cache_dir`. +pub(crate) fn cache_dir() -> Option { + if let Some(xdg) = aube_util::env::xdg_cache_home() { + return Some(xdg.join("aube")); + } + #[cfg(windows)] + if let Ok(local) = std::env::var("LOCALAPPDATA") { + return Some(PathBuf::from(local).join("aube")); + } + aube_util::env::home_dir().map(|h| h.join(".cache/aube")) +} + +/// Disk cache for the dist index, segmented by mirror origin so two +/// mirrors never serve each other's entries: +/// `$XDG_CACHE_HOME/aube/node-index/origin-/index.json`. +pub(crate) fn index_cache_path(mirror_base: &str) -> Option { + cache_dir().map(|d| { + d.join("node-index") + .join(origin_segment(mirror_base)) + .join("index.json") + }) +} + +/// Disk cache for a release's SHASUMS256.txt (immutable once +/// published — cached forever): +/// `$XDG_CACHE_HOME/aube/node-shasums/origin-/v24.1.0.txt`. +pub(crate) fn shasums_cache_path( + mirror_base: &str, + version: &node_semver::Version, +) -> Option { + cache_dir().map(|d| { + d.join("node-shasums") + .join(origin_segment(mirror_base)) + .join(format!("v{version}.txt")) + }) +} + +fn origin_segment(mirror_base: &str) -> String { + use sha2::Digest; + let digest = sha2::Sha256::digest(mirror_base.as_bytes()); + format!("origin-{}", hex::encode(&digest[..8])) +} diff --git a/crates/aube-runtime/src/platform.rs b/crates/aube-runtime/src/platform.rs new file mode 100644 index 000000000..f78851301 --- /dev/null +++ b/crates/aube-runtime/src/platform.rs @@ -0,0 +1,191 @@ +//! Host platform detection and the two naming vocabularies it maps +//! into: +//! +//! - **lockfile vocabulary** (pnpm / Node `process.platform`): +//! `darwin` / `linux` / `win32`, `x64` / `arm64`, `libc: musl`; +//! - **dist-file vocabulary** (nodejs.org artifact names): +//! `node-v{V}-darwin-arm64.tar.gz`, `node-v{V}-linux-x64-musl.tar.gz`, +//! `node-v{V}-win-x64.zip`. + +use crate::error::Error; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Platform { + /// `process.platform` vocabulary: `darwin` / `linux` / `win32`. + pub os: String, + /// `process.arch` vocabulary: `x64` / `arm64` (others passed + /// through as-is from Rust's target arch mapping). + pub cpu: String, + /// `Some("musl")` on musl-libc Linux hosts. + pub libc: Option, +} + +impl Platform { + /// Detect the host platform. + /// + /// musl detection is a *runtime* check: aube ships static-musl + /// Linux binaries, so `cfg!(target_env = "musl")` is true even on + /// glibc hosts and cannot be trusted. The presence of musl's + /// dynamic loader (`/lib/ld-musl-.so.1`) is the signal mise + /// uses for the same decision. + pub fn current() -> Result { + let os = match std::env::consts::OS { + "macos" => "darwin", + "linux" => "linux", + "windows" => "win32", + other => { + return Err(Error::UnsupportedPlatform { + platform: format!("{other}-{}", std::env::consts::ARCH), + }); + } + }; + let cpu = match std::env::consts::ARCH { + "x86_64" => "x64", + "aarch64" => "arm64", + "x86" => "x86", + "powerpc64" => "ppc64", + "s390x" => "s390x", + other => other, + }; + let libc = (os == "linux" && detect_musl()).then(|| "musl".to_string()); + Ok(Platform { + os: os.to_string(), + cpu: cpu.to_string(), + libc, + }) + } + + /// The platform segment of a dist artifact name: + /// `darwin-arm64`, `linux-x64-musl`, `win-x64`. + pub fn dist_slug(&self) -> String { + let os = if self.os == "win32" { "win" } else { &self.os }; + let musl = if self.libc.as_deref() == Some("musl") { + "-musl" + } else { + "" + }; + format!("{os}-{}{musl}", self.cpu) + } + + /// The token nodejs.org's `index.json` `files[]` array uses for + /// this platform. macOS entries use the legacy `osx-*` prefix + /// with a `-tar` suffix; Windows uses `win--zip`. + /// + /// musl builds never appear in the official index (they live on + /// unofficial-builds.nodejs.org, whose index has the same shape + /// but plain `linux-x64` tokens), so musl maps to the bare linux + /// token for `files[]` gating purposes. + pub fn index_files_token(&self) -> String { + match self.os.as_str() { + "darwin" => format!("osx-{}-tar", self.cpu), + "win32" => format!("win-{}-zip", self.cpu), + _ => format!("linux-{}", self.cpu), + } + } + + /// Archive extension for this platform's dist artifact. + pub fn archive_ext(&self) -> &'static str { + if self.os == "win32" { "zip" } else { "tar.gz" } + } + + /// pnpm's `archive:` vocabulary for this platform. + pub fn archive_kind(&self) -> &'static str { + if self.os == "win32" { "zip" } else { "tarball" } + } + + /// Human-readable label for error messages. + pub fn label(&self) -> String { + match &self.libc { + Some(libc) => format!("{}-{} ({libc})", self.os, self.cpu), + None => format!("{}-{}", self.os, self.cpu), + } + } +} + +#[cfg(target_os = "linux")] +fn detect_musl() -> bool { + let arch = match std::env::consts::ARCH { + "x86_64" => "x86_64", + "aarch64" => "aarch64", + other => other, + }; + std::path::Path::new(&format!("/lib/ld-musl-{arch}.so.1")).exists() +} + +#[cfg(not(target_os = "linux"))] +fn detect_musl() -> bool { + false +} + +/// The artifact filename for `version` on `platform`: +/// `node-v22.1.0-darwin-arm64.tar.gz`. +pub fn artifact_filename(version: &node_semver::Version, platform: &Platform) -> String { + format!( + "node-v{version}-{}.{}", + platform.dist_slug(), + platform.archive_ext() + ) +} + +/// The top-level directory inside an artifact: +/// `node-v22.1.0-darwin-arm64`. +pub fn artifact_top_dir(version: &node_semver::Version, platform: &Platform) -> String { + format!("node-v{version}-{}", platform.dist_slug()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn plat(os: &str, cpu: &str, libc: Option<&str>) -> Platform { + Platform { + os: os.into(), + cpu: cpu.into(), + libc: libc.map(String::from), + } + } + + #[test] + fn dist_slugs() { + assert_eq!(plat("darwin", "arm64", None).dist_slug(), "darwin-arm64"); + assert_eq!(plat("linux", "x64", None).dist_slug(), "linux-x64"); + assert_eq!( + plat("linux", "x64", Some("musl")).dist_slug(), + "linux-x64-musl" + ); + assert_eq!(plat("win32", "x64", None).dist_slug(), "win-x64"); + } + + #[test] + fn index_tokens() { + assert_eq!( + plat("darwin", "arm64", None).index_files_token(), + "osx-arm64-tar" + ); + assert_eq!( + plat("win32", "x64", None).index_files_token(), + "win-x64-zip" + ); + assert_eq!( + plat("linux", "arm64", None).index_files_token(), + "linux-arm64" + ); + assert_eq!( + plat("linux", "x64", Some("musl")).index_files_token(), + "linux-x64" + ); + } + + #[test] + fn artifact_names() { + let v: node_semver::Version = "22.1.0".parse().unwrap(); + assert_eq!( + artifact_filename(&v, &plat("win32", "x64", None)), + "node-v22.1.0-win-x64.zip" + ); + assert_eq!( + artifact_top_dir(&v, &plat("darwin", "arm64", None)), + "node-v22.1.0-darwin-arm64" + ); + } +} diff --git a/crates/aube-runtime/src/progress.rs b/crates/aube-runtime/src/progress.rs new file mode 100644 index 000000000..3b2432fa8 --- /dev/null +++ b/crates/aube-runtime/src/progress.rs @@ -0,0 +1,22 @@ +//! Progress reporting hook. This crate is a library — it never prints. +//! The CLI wires an implementation backed by `clx::progress`; tests +//! and non-interactive callers use [`NoopProgress`]. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallPhase { + Resolving, + Downloading, + Verifying, + Extracting, +} + +pub trait DownloadProgress: Send + Sync { + fn on_phase(&self, _version: &node_semver::Version, _phase: InstallPhase) {} + fn on_download_start(&self, _total_bytes: Option) {} + fn on_download_chunk(&self, _bytes: u64) {} + fn on_done(&self) {} +} + +pub struct NoopProgress; + +impl DownloadProgress for NoopProgress {} diff --git a/crates/aube-runtime/src/resolver.rs b/crates/aube-runtime/src/resolver.rs new file mode 100644 index 000000000..61d26249c --- /dev/null +++ b/crates/aube-runtime/src/resolver.rs @@ -0,0 +1,473 @@ +//! The resolution state machine. Hot-path guarantee: when a +//! satisfying Node is already on PATH or installed (aube or mise), +//! resolution touches the network never and spawns at most one +//! memoized `node --version`. + +use crate::discover::{self, InstallOrigin, InstalledNode}; +use crate::error::Error; +use crate::http::Http; +use crate::index; +use crate::installer::{self, DownloadSpec}; +use crate::mise; +use crate::platform::{Platform, artifact_filename, artifact_top_dir}; +use crate::progress::DownloadProgress; +use crate::shasums::{self, sha256_from_sri, sri_sha256}; +use crate::spec::{NodeRequest, NodeSpec}; +use crate::{InstallerMode, PinnedNode, PinnedVariant, RuntimeConfig}; +use aube_manifest::OnFail; +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// How a resolution was satisfied. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResolvedFrom { + /// The `node` already on PATH satisfies the request — no PATH + /// manipulation needed. + PathEnv, + /// An existing install (aube's runtime dir or mise's installs). + Installed(InstallOrigin), + /// Installed during this resolution. + FreshInstall(InstallOrigin), +} + +/// A successfully resolved runtime. +#[derive(Debug, Clone)] +pub struct Resolution { + pub version: node_semver::Version, + /// Directory to prepend to PATH. `None` for [`ResolvedFrom::PathEnv`]. + pub bin_dir: Option, + pub node_bin: PathBuf, + pub from: ResolvedFrom, + /// Populated when this resolution hit the network index — lets the + /// caller record/refresh the lockfile pin without a second + /// SHASUMS round-trip. + pub fresh_pin: Option, +} + +pub struct NodeRuntime { + pub(crate) cfg: RuntimeConfig, + pub(crate) http: Http, + memo: tokio::sync::Mutex>>, +} + +impl NodeRuntime { + pub fn new(cfg: RuntimeConfig) -> Self { + let http = Http::new(cfg.retries); + NodeRuntime { + cfg, + http, + memo: tokio::sync::Mutex::new(BTreeMap::new()), + } + } + + /// Resolve `req`, preferring the lockfile `pinned` version when + /// present. + /// + /// `Ok(None)` means "leave the environment alone": the request + /// couldn't be satisfied locally and the `onFail` policy + /// (`ignore`/`warn`) says to keep running on whatever node PATH + /// provides. `Ok(Some(_))` is a concrete runtime to put on PATH + /// (or, for [`ResolvedFrom::PathEnv`], to leave as-is). + pub async fn resolve( + &self, + req: &NodeRequest, + pinned: Option<&PinnedNode>, + progress: &dyn DownloadProgress, + ) -> Result, Error> { + let memo_key = match pinned { + Some(p) => format!("pin:{}", p.version), + None => format!("spec:{}", req.raw), + }; + if let Some(hit) = self.memo.lock().await.get(&memo_key) { + return Ok(hit.clone()); + } + let result = self.resolve_uncached(req, pinned, progress).await?; + self.memo.lock().await.insert(memo_key, result.clone()); + Ok(result) + } + + async fn resolve_uncached( + &self, + req: &NodeRequest, + pinned: Option<&PinnedNode>, + progress: &dyn DownloadProgress, + ) -> Result, Error> { + // The lockfile pin wins over the range: reproducibility. + let target = match pinned { + Some(p) => NodeSpec::Exact(p.version.clone()), + None => req.spec.clone(), + }; + + // Zero-network fast paths. Lts/Latest/codename targets skip + // them — satisfaction is unknowable without the index. + if let Some(resolution) = local_resolution(&target) { + return Ok(Some(resolution)); + } + + // Locally unsatisfiable: apply policy before touching the + // network. + match req.on_fail { + OnFail::Ignore => return Ok(None), + OnFail::Warn => { + tracing::warn!( + code = aube_codes::warnings::WARN_AUBE_RUNTIME_VERSION_MISMATCH, + requested = %req.raw, + source = req.source.label(), + "the active Node.js does not satisfy the project's runtime requirement" + ); + return Ok(None); + } + OnFail::Error => { + // Alias specs were never checked locally; give them + // their network check before failing (an installed + // node may well BE the latest LTS, but we can't know + // without the index — and `error` says don't fetch + // runtimes, not don't fetch metadata). + if matches!(target, NodeSpec::Exact(_) | NodeSpec::Range(_)) { + return Err(self.unsatisfied(req)); + } + } + OnFail::Download => {} + } + + // Network: pin the spec to an exact version. + progress.on_phase( + &node_semver::Version::parse("0.0.0").unwrap(), + crate::progress::InstallPhase::Resolving, + ); + let platform = Platform::current()?; + let (version, fresh_pin) = match pinned { + Some(p) => (p.version.clone(), None), + None => { + let entries = index::load_index(&self.http, &self.cfg).await?; + let entry = index::select(&entries, &target, &platform).ok_or_else(|| { + Error::NoMatchingVersion { + requested: req.raw.clone(), + platform_note: format!(" with a build for {}", platform.label()), + } + })?; + (entry.version.clone(), None) + } + }; + + // The exact version may already be present even though the + // range check above couldn't run (alias specs) or the pin + // differs from what PATH carries. + let exact = NodeSpec::Exact(version.clone()); + if let Some(resolution) = local_resolution(&exact) { + return Ok(Some(resolution)); + } + if req.on_fail == OnFail::Error { + return Err(self.unsatisfied(req)); + } + + // Build the download spec: lockfile variant when available, + // live SHASUMS otherwise. + let artifact_base = self.cfg.artifact_base(&platform); + let pinned_variant = pinned + .and_then(|p| p.variant_for(&platform.os, &platform.cpu, platform.libc.as_deref())); + let (download, fresh_pin) = match pinned_variant { + Some(v) => { + let expected = + sha256_from_sri(&v.integrity_sri).ok_or_else(|| Error::ChecksumMismatch { + url: v.url.clone(), + expected: v.integrity_sri.clone(), + actual: "".to_string(), + })?; + ( + DownloadSpec { + url: v.url.clone(), + expected_sha256: expected, + zip: v.archive == "zip", + }, + fresh_pin, + ) + } + None => { + if pinned.is_some() { + // Lockfile written before this platform was + // supported — verify against live SHASUMS instead + // and let the caller refresh the pin. + tracing::warn!( + version = %version, + platform = %platform.label(), + "lockfile runtime pin has no variant for this platform; using live checksums" + ); + } + let sums = + shasums::load_shasums(&self.http, &self.cfg, &artifact_base, &version).await?; + let filename = artifact_filename(&version, &platform); + let digest = sums.for_file(&filename).copied().ok_or_else(|| { + Error::UnsupportedPlatform { + platform: platform.label(), + } + })?; + let pin = self.build_full_pin(&version).await.unwrap_or_else(|e| { + tracing::debug!(error = %e, "could not build full runtime pin"); + PinnedNode { + version: version.clone(), + variants: Vec::new(), + } + }); + ( + DownloadSpec { + url: format!("{artifact_base}/v{version}/{filename}"), + expected_sha256: digest, + zip: platform.os == "win32", + }, + Some(pin), + ) + } + }; + + // Install, honoring the delegation mode. + let installed = self.install(&version, &download, progress).await?; + Ok(Some(Resolution { + version: installed.version.clone(), + bin_dir: Some(installed.bin_dir.clone()), + node_bin: installed.node_bin.clone(), + from: ResolvedFrom::FreshInstall(installed.origin), + fresh_pin, + })) + } + + async fn install( + &self, + version: &node_semver::Version, + download: &DownloadSpec, + progress: &dyn DownloadProgress, + ) -> Result { + match self.cfg.installer { + InstallerMode::Aube => { + installer::install(&self.http, version, download, progress).await + } + InstallerMode::Mise => { + let Some(mise_bin) = mise::mise_on_path() else { + return Err(Error::MiseInstallFailed { + version: format!("node@{version}"), + reason: "runtimeInstaller=mise but mise is not on PATH".to_string(), + }); + }; + mise::install_via_mise(&mise_bin, version).await + } + InstallerMode::Auto => match mise::mise_on_path() { + Some(mise_bin) => match mise::install_via_mise(&mise_bin, version).await { + Ok(node) => Ok(node), + Err(e) => { + tracing::warn!( + code = aube_codes::warnings::WARN_AUBE_RUNTIME_MISE_FALLBACK, + error = %e, + "mise failed to install the runtime; falling back to aube's own download" + ); + installer::install(&self.http, version, download, progress).await + } + }, + None => installer::install(&self.http, version, download, progress).await, + }, + } + } + + fn unsatisfied(&self, req: &NodeRequest) -> Error { + let current = discover::probe_path_node() + .map(|(v, _)| format!(" (PATH provides {v})")) + .unwrap_or_else(|| " (no node on PATH)".to_string()); + Error::VersionUnsatisfied { + requested: req.raw.clone(), + hint: format!( + "{current}; required by {} at {}", + req.source.label(), + req.origin.display() + ), + } + } + + /// Resolve `spec` to an exact version plus the full per-platform + /// artifact set — the lockfile-pin path. Always network-backed + /// (through the disk caches). + pub async fn resolve_for_lockfile(&self, spec: &NodeSpec) -> Result { + let platform = Platform::current()?; + let entries = index::load_index(&self.http, &self.cfg).await?; + let entry = + index::select(&entries, spec, &platform).ok_or_else(|| Error::NoMatchingVersion { + requested: spec.display(), + platform_note: String::new(), + })?; + let version = entry.version.clone(); + self.build_full_pin(&version).await + } + + /// Build a full pin (all platforms) from SHASUMS data: the + /// configured mirror's checksums, plus — when running against the + /// default official mirror — unofficial-builds' musl checksums, + /// best-effort (older releases have no musl builds). + async fn build_full_pin(&self, version: &node_semver::Version) -> Result { + let base = self.cfg.mirror_base(); + let sums = shasums::load_shasums(&self.http, &self.cfg, &base, version).await?; + let mut variants = variants_from_shasums(&base, version, sums.iter()); + if self.cfg.mirror.is_none() { + let musl_base = crate::UNOFFICIAL_BASE; + match shasums::load_shasums(&self.http, &self.cfg, musl_base, version).await { + Ok(musl_sums) => { + variants.extend( + variants_from_shasums(musl_base, version, musl_sums.iter()) + .into_iter() + .filter(|v| v.libc.as_deref() == Some("musl")), + ); + } + Err(e) => { + tracing::debug!(error = %e, "no musl builds recorded for v{version}"); + } + } + } + Ok(PinnedNode { + version: version.clone(), + variants, + }) + } +} + +/// Zero-network resolution: PATH probe, then installed scan. Only +/// meaningful for `Exact` / `Range` targets. +fn local_resolution(target: &NodeSpec) -> Option { + if let Some((version, node_bin)) = discover::probe_path_node() + && target.satisfied_by(&version) == Some(true) + { + return Some(Resolution { + version, + bin_dir: None, + node_bin, + from: ResolvedFrom::PathEnv, + fresh_pin: None, + }); + } + let best = discover::list_installed() + .into_iter() + .filter(|n| target.satisfied_by(&n.version) == Some(true)) + .max_by(|a, b| a.version.cmp(&b.version))?; + Some(Resolution { + version: best.version.clone(), + bin_dir: Some(best.bin_dir.clone()), + node_bin: best.node_bin.clone(), + from: ResolvedFrom::Installed(best.origin), + fresh_pin: None, + }) +} + +/// Map SHASUMS entries (` node-v{V}-{os}-{arch}[-musl].{ext}`) +/// onto lockfile variants, mirroring pnpm's `readNodeAssetsFromMirror`: +/// `win` → `win32`, bin paths per OS, `prefix` set for zips. +fn variants_from_shasums<'a>( + base: &str, + version: &node_semver::Version, + entries: impl Iterator, +) -> Vec { + let prefix = format!("node-v{version}-"); + let mut out = Vec::new(); + for (filename, digest) in entries { + let Some(rest) = filename.strip_prefix(&prefix) else { + continue; + }; + let (slug, ext) = if let Some(s) = rest.strip_suffix(".tar.gz") { + (s, "tar.gz") + } else if let Some(s) = rest.strip_suffix(".zip") { + (s, "zip") + } else { + continue; + }; + let (slug, musl) = match slug.strip_suffix("-musl") { + Some(s) => (s, true), + None => (slug, false), + }; + let Some((os_raw, cpu)) = slug.split_once('-') else { + continue; + }; + // Only the canonical platform pairs; skip exotic artifacts + // (headers, pkg, 7z multi-dash names fall out naturally via + // the extension filter, `win-x64-7z` via the split shape). + if cpu.contains('-') { + continue; + } + let os = match os_raw { + "win" => "win32", + "osx" | "darwin" => "darwin", + "linux" => "linux", + "aix" => "aix", + _ => continue, + }; + let bin: BTreeMap = if os == "win32" { + [("node".to_string(), "node.exe".to_string())].into() + } else { + [("node".to_string(), "bin/node".to_string())].into() + }; + out.push(PinnedVariant { + os: os.to_string(), + cpu: cpu.to_string(), + libc: musl.then(|| "musl".to_string()), + archive: if ext == "zip" { "zip" } else { "tarball" }.to_string(), + url: format!("{base}/v{version}/{filename}"), + integrity_sri: sri_sha256(digest), + bin, + prefix: (ext == "zip").then(|| { + let plat = Platform { + os: os.to_string(), + cpu: cpu.to_string(), + libc: musl.then(|| "musl".to_string()), + }; + artifact_top_dir(version, &plat) + }), + }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shasums_variant_mapping() { + let version: node_semver::Version = "24.4.1".parse().unwrap(); + let entries: Vec<(String, [u8; 32])> = vec![ + ("node-v24.4.1-darwin-arm64.tar.gz".into(), [1; 32]), + ("node-v24.4.1-linux-x64.tar.gz".into(), [2; 32]), + ("node-v24.4.1-linux-x64-musl.tar.gz".into(), [3; 32]), + ("node-v24.4.1-win-x64.zip".into(), [4; 32]), + ("node-v24.4.1-headers.tar.gz".into(), [5; 32]), + ("node-v24.4.1.pkg".into(), [6; 32]), + ("node-v24.4.1-win-x64.7z".into(), [7; 32]), + ("node-v24.4.1-darwin-arm64.tar.xz".into(), [8; 32]), + ]; + let variants = variants_from_shasums( + "https://nodejs.org/download/release", + &version, + entries.iter().map(|(k, v)| (k, v)), + ); + let labels: Vec = variants + .iter() + .map(|v| { + format!( + "{}-{}{}", + v.os, + v.cpu, + v.libc + .as_deref() + .map(|l| format!("-{l}")) + .unwrap_or_default() + ) + }) + .collect(); + assert_eq!( + labels, + vec!["darwin-arm64", "linux-x64", "linux-x64-musl", "win32-x64"] + ); + let win = variants.iter().find(|v| v.os == "win32").unwrap(); + assert_eq!(win.archive, "zip"); + assert_eq!(win.prefix.as_deref(), Some("node-v24.4.1-win-x64")); + assert_eq!(win.bin.get("node").map(String::as_str), Some("node.exe")); + assert!(win.url.ends_with("/v24.4.1/node-v24.4.1-win-x64.zip")); + let mac = variants.iter().find(|v| v.os == "darwin").unwrap(); + assert_eq!(mac.archive, "tarball"); + assert_eq!(mac.prefix, None); + assert!(mac.integrity_sri.starts_with("sha256-")); + } +} diff --git a/crates/aube-runtime/src/self_install.rs b/crates/aube-runtime/src/self_install.rs new file mode 100644 index 000000000..868f31a01 --- /dev/null +++ b/crates/aube-runtime/src/self_install.rs @@ -0,0 +1,476 @@ +//! aube self-version management: discovering, downloading, and +//! installing *aube* binaries so a project's `packageManager` / +//! `devEngines.packageManager` pin can re-exec the right version +//! (corepack semantics; pnpm's `managePackageManagerVersions`). +//! +//! Sources, same shape as Node runtimes: mise installs +//! (`installs/aube//`, binaries at the version root) are reused +//! read-only, and self-downloads come from GitHub release archives +//! (`aube-v{V}-{target-triple}.tar.gz` / `.zip`, binaries at the +//! archive root) into `$XDG_DATA_HOME/aube/self//`. + +use crate::discover::{self, InstallOrigin}; +use crate::error::Error; +use crate::http::Http; +use crate::installer::stream_to_file; +use crate::mise; +use crate::progress::{DownloadProgress, InstallPhase, NoopProgress}; +use crate::{InstallerMode, RuntimeConfig}; +use std::path::{Path, PathBuf}; + +/// Default base for release archives. `AUBE_SELF_DOWNLOAD_BASE` +/// overrides for tests and mirrors; archives live at +/// `{base}/v{V}/aube-v{V}-{triple}.{ext}` with a sibling +/// `{archive}.sha256`. +const RELEASE_BASE: &str = "https://github.com/jdx/aube/releases/download"; + +/// Endpoint announcing the newest release (one line, bare version). +/// Shared with the update notifier. `AUBE_SELF_VERSION_URL` overrides. +const VERSION_URL: &str = "https://aube.jdx.dev/VERSION"; + +/// A validated on-disk aube install. +#[derive(Debug, Clone)] +pub struct InstalledAube { + pub version: node_semver::Version, + pub install_dir: PathBuf, + /// The `aube` executable. `aubr` / `aubx` siblings live next to it. + pub exe: PathBuf, + pub origin: InstallOrigin, +} + +/// aube's own versions dir (`$XDG_DATA_HOME/aube/self`). +/// `AUBE_SELF_DIR` overrides for tests. +pub fn self_dir() -> Option { + if let Some(dir) = std::env::var_os("AUBE_SELF_DIR") { + return Some(PathBuf::from(dir)); + } + #[cfg(windows)] + if let Ok(local) = std::env::var("LOCALAPPDATA") { + return Some(PathBuf::from(local).join("aube/self")); + } + let data_home = aube_util::env::xdg_data_home() + .or_else(|| aube_util::env::home_dir().map(|h| h.join(".local/share")))?; + Some(data_home.join("aube/self")) +} + +/// Every valid installed aube across mise's installs dir and aube's +/// self dir. Same collision rule as Node: aube's own copy of a +/// version wins over mise's. +pub fn list_installed_aube() -> Vec { + let mut by_version: std::collections::BTreeMap = + Default::default(); + if let Some(dir) = discover::mise_tool_installs_dir("aube") { + for install in scan_aube_dir(&dir, InstallOrigin::Mise) { + by_version.insert(install.version.clone(), install); + } + } + if let Some(dir) = self_dir() { + for install in scan_aube_dir(&dir, InstallOrigin::Aube) { + by_version.insert(install.version.clone(), install); + } + } + by_version.into_values().collect() +} + +/// Look up one exact installed version (mise first, then self dir — +/// the self-dir copy wins, mirroring `list_installed_aube`). +pub fn find_installed_aube(version: &node_semver::Version) -> Option { + let from_self = self_dir() + .map(|d| d.join(version.to_string())) + .and_then(|d| validate_aube_install(&d, version.clone(), InstallOrigin::Aube)); + from_self.or_else(|| { + discover::mise_tool_installs_dir("aube") + .map(|d| d.join(version.to_string())) + .and_then(|d| validate_aube_install(&d, version.clone(), InstallOrigin::Mise)) + }) +} + +fn scan_aube_dir(root: &Path, origin: InstallOrigin) -> Vec { + let Ok(entries) = std::fs::read_dir(root) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() { + // Skips mise's alias symlinks (`1`, `1.18`, `latest`). + continue; + } + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let Ok(version) = node_semver::Version::parse(name.trim_start_matches('v')) else { + continue; + }; + if let Some(install) = validate_aube_install(&path, version, origin) { + out.push(install); + } + } + out +} + +/// Validate a version dir: no `incomplete` marker (mise's in-progress +/// signal), and the `aube` executable present at the root or under +/// `bin/` (mise and the release archives use the root; `bin/` covers +/// alternative packagings). +fn validate_aube_install( + dir: &Path, + version: node_semver::Version, + origin: InstallOrigin, +) -> Option { + if dir.join("incomplete").exists() { + return None; + } + let exe_name = if cfg!(windows) { "aube.exe" } else { "aube" }; + let exe = [dir.join(exe_name), dir.join("bin").join(exe_name)] + .into_iter() + .find(|p| p.is_file())?; + Some(InstalledAube { + version, + install_dir: dir.to_path_buf(), + exe, + origin, + }) +} + +/// The release-archive target triple for the host. aube publishes: +/// `aarch64-apple-darwin`, `{x86_64,aarch64}-unknown-linux-{gnu,musl}`, +/// `{x86_64,aarch64}-pc-windows-msvc`. Hosts without a published +/// build (e.g. Intel macOS) get an [`Error::UnsupportedPlatform`] +/// pointing at mise. +pub fn release_target_triple() -> Result { + let arch = match std::env::consts::ARCH { + "x86_64" => "x86_64", + "aarch64" => "aarch64", + other => { + return Err(Error::UnsupportedPlatform { + platform: format!("{}-{other}", std::env::consts::OS), + }); + } + }; + let triple = match std::env::consts::OS { + "macos" => { + if arch != "aarch64" { + return Err(Error::UnsupportedPlatform { + platform: "macos-x86_64 (no published aube build; install via mise)" + .to_string(), + }); + } + format!("{arch}-apple-darwin") + } + "linux" => { + let libc = if crate::Platform::current()?.libc.as_deref() == Some("musl") { + "musl" + } else { + "gnu" + }; + format!("{arch}-unknown-linux-{libc}") + } + "windows" => format!("{arch}-pc-windows-msvc"), + other => { + return Err(Error::UnsupportedPlatform { + platform: format!("{other}-{arch}"), + }); + } + }; + Ok(triple) +} + +fn release_base() -> String { + std::env::var("AUBE_SELF_DOWNLOAD_BASE") + .ok() + .filter(|s| !s.trim().is_empty()) + .map(|s| s.trim_end_matches('/').to_string()) + .unwrap_or_else(|| RELEASE_BASE.to_string()) +} + +/// Resolve the newest published aube version (for range pins). +pub async fn latest_aube_version(retries: u32) -> Result { + let url = std::env::var("AUBE_SELF_VERSION_URL") + .ok() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| VERSION_URL.to_string()); + let http = Http::new(retries); + let resp = http.get(&url, None, None, false).await?; + let body = resp.body.ok_or_else(|| Error::DownloadFailed { + url: url.clone(), + reason: "unexpected empty response".to_string(), + })?; + let text = body.text().await.map_err(|e| Error::DownloadFailed { + url: url.clone(), + reason: e.to_string(), + })?; + node_semver::Version::parse(text.trim().trim_start_matches('v')).map_err(|e| { + Error::DownloadFailed { + url, + reason: format!("unparseable version announcement: {e}"), + } + }) +} + +/// Install aube `version`, honoring the installer mode: mise +/// delegation first under `auto`/`mise` (one tool store for mise +/// users), self-download from GitHub releases otherwise. +pub async fn install_aube( + cfg: &RuntimeConfig, + version: &node_semver::Version, +) -> Result { + if let Some(existing) = find_installed_aube(version) { + return Ok(existing); + } + match cfg.installer { + InstallerMode::Aube => self_download(cfg, version).await, + InstallerMode::Mise => { + let Some(mise_bin) = mise::mise_on_path() else { + return Err(Error::MiseInstallFailed { + version: format!("aube@{version}"), + reason: "runtimeInstaller=mise but mise is not on PATH".to_string(), + }); + }; + delegate_to_mise(&mise_bin, version).await + } + InstallerMode::Auto => match mise::mise_on_path() { + Some(mise_bin) => match delegate_to_mise(&mise_bin, version).await { + Ok(install) => Ok(install), + Err(e) => { + tracing::warn!( + code = aube_codes::warnings::WARN_AUBE_RUNTIME_MISE_FALLBACK, + error = %e, + "mise failed to install aube; falling back to a release download" + ); + self_download(cfg, version).await + } + }, + None => self_download(cfg, version).await, + }, + } +} + +async fn delegate_to_mise( + mise_bin: &Path, + version: &node_semver::Version, +) -> Result { + mise::install_tool_via_mise(mise_bin, "aube", version).await?; + discover::mise_tool_installs_dir("aube") + .map(|d| d.join(version.to_string())) + .and_then(|d| validate_aube_install(&d, version.clone(), InstallOrigin::Mise)) + .ok_or_else(|| Error::MiseInstallFailed { + version: format!("aube@{version}"), + reason: "mise reported success but the install was not found — \ + if mise uses a custom data dir, export MISE_DATA_DIR so aube sees the same path" + .to_string(), + }) +} + +/// Download a release archive, verify its published `.sha256` when +/// available (older releases predate checksum publishing; those fall +/// back to TLS-only with a debug note), extract — binaries sit at the +/// archive root — and atomically publish. +async fn self_download( + cfg: &RuntimeConfig, + version: &node_semver::Version, +) -> Result { + let root = self_dir().ok_or_else(|| { + Error::io( + "locate the aube self dir", + std::io::Error::new(std::io::ErrorKind::NotFound, "no home directory"), + ) + })?; + let dest = root.join(version.to_string()); + let locks = root.join(".locks"); + std::fs::create_dir_all(&locks) + .map_err(|e| Error::io(format!("create {}", locks.display()), e))?; + let lock_path = locks.join(format!("{version}.lock")); + let lock = tokio::task::spawn_blocking(move || xx::fslock::FSLock::new(&lock_path).lock()) + .await + .map_err(|e| { + Error::io( + "acquire self-install lock", + std::io::Error::other(e.to_string()), + ) + })? + .map_err(|e| { + Error::io( + "acquire self-install lock", + std::io::Error::other(e.to_string()), + ) + })?; + if let Some(existing) = validate_aube_install(&dest, version.clone(), InstallOrigin::Aube) { + drop(lock); + return Ok(existing); + } + + let triple = release_target_triple()?; + let ext = if cfg!(windows) { "zip" } else { "tar.gz" }; + let archive_name = format!("aube-v{version}-{triple}.{ext}"); + let url = format!("{}/v{version}/{archive_name}", release_base()); + let http = Http::new(cfg.retries); + let progress = NoopProgress; + progress.on_phase(version, InstallPhase::Downloading); + + let downloads = root.join(".downloads"); + let staging_root = root.join(".tmp"); + std::fs::create_dir_all(&downloads) + .map_err(|e| Error::io(format!("create {}", downloads.display()), e))?; + std::fs::create_dir_all(&staging_root) + .map_err(|e| Error::io(format!("create {}", staging_root.display()), e))?; + let archive_path = downloads.join(format!("{archive_name}.{}", std::process::id())); + let actual = stream_to_file(&http, &url, &archive_path, &progress).await?; + + // Published checksum, when the release ships one. + match fetch_published_sha256(&http, &url).await { + Some(expected) if expected != actual => { + let _ = std::fs::remove_file(&archive_path); + drop(lock); + return Err(Error::ChecksumMismatch { + url, + expected: hex::encode(expected), + actual: hex::encode(actual), + }); + } + Some(_) => {} + None => { + tracing::debug!( + %url, + "release publishes no .sha256 (pre-checksum release); trusting TLS" + ); + } + } + + let staging = staging_root.join(format!("{version}.{}", std::process::id())); + std::fs::create_dir_all(&staging) + .map_err(|e| Error::io(format!("create {}", staging.display()), e))?; + let extract_from = archive_path.clone(); + let extract_to = staging.clone(); + let zip = ext == "zip"; + let extract_result = tokio::task::spawn_blocking(move || { + crate::extract::extract_archive(&extract_from, &extract_to, zip, false) + }) + .await + .map_err(|e| Error::ExtractFailed { + reason: e.to_string(), + })?; + let _ = std::fs::remove_file(&archive_path); + if let Err(e) = extract_result { + let _ = std::fs::remove_dir_all(&staging); + drop(lock); + return Err(e); + } + + if let Err(rename_err) = std::fs::rename(&staging, &dest) { + let _ = std::fs::remove_dir_all(&staging); + if validate_aube_install(&dest, version.clone(), InstallOrigin::Aube).is_none() { + drop(lock); + return Err(Error::io( + format!("publish aube {} into {}", version, dest.display()), + rename_err, + )); + } + } + drop(lock); + + validate_aube_install(&dest, version.clone(), InstallOrigin::Aube).ok_or_else(|| { + Error::ExtractFailed { + reason: format!( + "release archive did not produce a usable aube at {}", + dest.display() + ), + } + }) +} + +/// Fetch `{archive_url}.sha256` and parse the leading hex digest +/// (taiki-e's checksum files are ` *`). `None` when the +/// asset doesn't exist or doesn't parse — caller decides the policy. +async fn fetch_published_sha256(http: &Http, archive_url: &str) -> Option<[u8; 32]> { + let url = format!("{archive_url}.sha256"); + let resp = http.get(&url, None, None, false).await.ok()?; + let text = resp.body?.text().await.ok()?; + let hex_token = text.split_whitespace().next()?; + let bytes = hex::decode(hex_token).ok()?; + <[u8; 32]>::try_from(bytes.as_slice()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fab_aube(root: &Path, version: &str) { + let dir = root.join(version); + std::fs::create_dir_all(&dir).unwrap(); + for bin in ["aube", "aubr", "aubx"] { + let path = dir.join(if cfg!(windows) { + format!("{bin}.exe") + } else { + bin.to_string() + }); + std::fs::write(&path, "#!/bin/sh\necho fake\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + } + + #[test] + fn scans_and_validates_aube_installs() { + let tmp = tempfile::tempdir().unwrap(); + fab_aube(tmp.path(), "1.17.0"); + fab_aube(tmp.path(), "1.18.2"); + fab_aube(tmp.path(), "1.19.0"); + std::fs::write(tmp.path().join("1.19.0/incomplete"), "").unwrap(); + std::fs::create_dir_all(tmp.path().join("not-a-version")).unwrap(); + + let mut versions: Vec = scan_aube_dir(tmp.path(), InstallOrigin::Mise) + .into_iter() + .map(|i| i.version.to_string()) + .collect(); + versions.sort(); + assert_eq!(versions, vec!["1.17.0", "1.18.2"]); + } + + #[test] + fn validate_accepts_bin_subdir_layout() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("2.0.0/bin"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join(if cfg!(windows) { "aube.exe" } else { "aube" }), + "x", + ) + .unwrap(); + let install = validate_aube_install( + &tmp.path().join("2.0.0"), + "2.0.0".parse().unwrap(), + InstallOrigin::Aube, + ) + .unwrap(); + assert!(install.exe.parent().unwrap().ends_with("bin")); + } + + #[test] + fn target_triple_is_publishable() { + // On every platform CI runs, the host triple must map to a + // name aube actually publishes (Intel macOS is the documented + // exception). + match release_target_triple() { + Ok(t) => { + assert!( + t.contains("apple-darwin") + || t.contains("unknown-linux") + || t.contains("pc-windows"), + "{t}" + ); + } + Err(Error::UnsupportedPlatform { .. }) => { + assert_eq!(std::env::consts::OS, "macos"); + assert_eq!(std::env::consts::ARCH, "x86_64"); + } + Err(other) => panic!("unexpected error: {other}"), + } + } +} diff --git a/crates/aube-runtime/src/shasums.rs b/crates/aube-runtime/src/shasums.rs new file mode 100644 index 000000000..e5ba85df7 --- /dev/null +++ b/crates/aube-runtime/src/shasums.rs @@ -0,0 +1,142 @@ +//! `SHASUMS256.txt` fetch + parse. A release's checksum file is +//! immutable once published, so it caches forever (no TTL, no +//! revalidation). + +use crate::error::Error; +use crate::http::Http; +use crate::paths; +use crate::{NetworkMode, RuntimeConfig}; +use std::collections::BTreeMap; + +/// Parsed checksum file: artifact filename → SHA-256 (raw bytes). +pub(crate) struct Shasums { + entries: BTreeMap, +} + +impl Shasums { + pub(crate) fn for_file(&self, filename: &str) -> Option<&[u8; 32]> { + self.entries.get(filename) + } + + pub(crate) fn iter(&self) -> impl Iterator { + self.entries.iter() + } + + fn parse(text: &str) -> Shasums { + let mut entries = BTreeMap::new(); + for line in text.lines() { + let mut parts = line.split_whitespace(); + let (Some(hash), Some(name)) = (parts.next(), parts.next()) else { + continue; + }; + let Ok(bytes) = hex::decode(hash) else { + continue; + }; + let Ok(arr) = <[u8; 32]>::try_from(bytes.as_slice()) else { + continue; + }; + entries.insert(name.to_string(), arr); + } + Shasums { entries } + } +} + +/// Convert a raw SHA-256 into the SRI form lockfiles use +/// (`sha256-`). +pub fn sri_sha256(digest: &[u8; 32]) -> String { + use base64::Engine; + format!( + "sha256-{}", + base64::engine::general_purpose::STANDARD.encode(digest) + ) +} + +/// Parse an SRI `sha256-` string back into raw bytes. +pub fn sha256_from_sri(sri: &str) -> Option<[u8; 32]> { + use base64::Engine; + let b64 = sri.strip_prefix("sha256-")?; + let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?; + <[u8; 32]>::try_from(bytes.as_slice()).ok() +} + +/// Load `SHASUMS256.txt` for `version` from `base` (a mirror base +/// URL), via the forever-cache. +pub(crate) async fn load_shasums( + http: &Http, + cfg: &RuntimeConfig, + base: &str, + version: &node_semver::Version, +) -> Result { + let cache_path = paths::shasums_cache_path(base, version); + if let Some(text) = cache_path + .as_deref() + .and_then(|p| std::fs::read_to_string(p).ok()) + { + return Ok(Shasums::parse(&text)); + } + if cfg.network == NetworkMode::Offline { + return Err(Error::Offline { + what: format!("checksums for Node.js v{version}"), + }); + } + let url = format!("{base}/v{version}/SHASUMS256.txt"); + let resp = http.get(&url, None, None, false).await?; + let body = resp.body.ok_or_else(|| Error::DownloadFailed { + url: url.clone(), + reason: "unexpected empty response".to_string(), + })?; + let text = body.text().await.map_err(|e| Error::DownloadFailed { + url: url.clone(), + reason: e.to_string(), + })?; + let parsed = Shasums::parse(&text); + if parsed.entries.is_empty() { + return Err(Error::DownloadFailed { + url, + reason: "SHASUMS256.txt contained no parseable entries".to_string(), + }); + } + if let Some(path) = cache_path + && let Some(parent) = path.parent() + { + let _ = std::fs::create_dir_all(parent); + if let Err(e) = aube_util::fs_atomic::atomic_write(&path, text.as_bytes()) { + tracing::warn!( + code = aube_codes::warnings::WARN_AUBE_CACHE_WRITE_FAILED, + path = %path.display(), + error = %e, + "failed to write node shasums cache" + ); + } + } + Ok(parsed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_shasums_format() { + let text = "\ +0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef node-v22.1.0-darwin-arm64.tar.gz +fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210 node-v22.1.0-win-x64.zip +garbage line +deadbeef too-short-hash.tar.gz +"; + let s = Shasums::parse(text); + assert_eq!(s.entries.len(), 2); + assert!(s.for_file("node-v22.1.0-darwin-arm64.tar.gz").is_some()); + assert!(s.for_file("too-short-hash.tar.gz").is_none()); + } + + #[test] + fn sri_round_trip() { + let digest = [7u8; 32]; + let sri = sri_sha256(&digest); + assert!(sri.starts_with("sha256-")); + assert_eq!(sha256_from_sri(&sri), Some(digest)); + assert_eq!(sha256_from_sri("sha512-AAAA"), None); + assert_eq!(sha256_from_sri("sha256-notbase64!!!"), None); + } +} diff --git a/crates/aube-runtime/src/sources.rs b/crates/aube-runtime/src/sources.rs new file mode 100644 index 000000000..e842d15e7 --- /dev/null +++ b/crates/aube-runtime/src/sources.rs @@ -0,0 +1,209 @@ +//! Version-file discovery: `.node-version` and `.nvmrc`, searched +//! upward from the project directory. `devEngines.runtime` is *not* +//! read here — the caller already has parsed manifests and merges it +//! in at higher precedence via [`effective_request`]. + +use crate::error::Error; +use crate::spec::{NodeRequest, NodeSpec, RequestSource}; +use std::path::Path; + +/// Walk upward from `start_dir` looking for `.node-version` then +/// `.nvmrc` (same-directory precedence: `.node-version` wins; nearer +/// directory beats farther). The walk stops after checking the home +/// directory (inclusive) or the filesystem root, whichever comes +/// first — predictable, and matches what nvm/fnm users expect in +/// monorepos where the version file sits above the invocation dir. +/// +/// A file that exists but doesn't parse as a version request is +/// logged and treated as absent rather than failing the command — a +/// stray `.nvmrc` containing prose shouldn't break `aubr test`. +pub fn find_version_file(start_dir: &Path) -> Option { + let home = aube_util::env::home_dir(); + let mut dir = Some(start_dir); + while let Some(d) = dir { + for (file, source) in [ + (".node-version", RequestSource::NodeVersionFile), + (".nvmrc", RequestSource::Nvmrc), + ] { + let path = d.join(file); + let Ok(raw) = std::fs::read_to_string(&path) else { + continue; + }; + let trimmed = first_meaningful_line(&raw); + if trimmed.is_empty() { + continue; + } + match NodeSpec::parse(trimmed) { + Ok(spec) => { + return Some(NodeRequest { + spec, + raw: trimmed.to_string(), + // Version files have no onFail vocabulary; the + // whole point of writing one is "use this + // version", so missing versions download. The + // runtimeOnFail setting overrides at the + // integration layer. + on_fail: aube_manifest::OnFail::Download, + source, + origin: path, + }); + } + Err(_) => { + tracing::warn!( + path = %path.display(), + content = trimmed, + "ignoring unparseable node version file" + ); + } + } + } + if home.as_deref() == Some(d) { + break; + } + dir = d.parent(); + } + None +} + +/// First non-empty, non-comment line. nvm tolerates comments +/// (`# comment`) and surrounding whitespace in `.nvmrc`. +fn first_meaningful_line(raw: &str) -> &str { + raw.lines() + .map(str::trim) + .find(|l| !l.is_empty() && !l.starts_with('#')) + .unwrap_or("") +} + +/// Build the effective request for a project: `devEngines.runtime` +/// (passed in pre-parsed by the caller) beats version files. +/// +/// `dev_engines` carries the manifest's node runtime entry, if any: +/// `(version range, declared onFail, manifest path)`. An entry without +/// a `version` is treated as "no requirement". +pub fn effective_request( + dev_engines: Option<(&str, Option, &Path)>, + start_dir: &Path, +) -> Result, Error> { + if let Some((range, on_fail, manifest_path)) = dev_engines { + let spec = NodeSpec::parse(range)?; + return Ok(Some(NodeRequest { + spec, + raw: range.to_string(), + // The OpenJS spec defaults a missing onFail to `error`. + on_fail: on_fail.unwrap_or(aube_manifest::OnFail::Error), + source: RequestSource::DevEngines, + origin: manifest_path.to_path_buf(), + })); + } + Ok(find_version_file(start_dir)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_nvmrc_in_parent() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join(".nvmrc"), "v22.1.0\n").unwrap(); + let nested = tmp.path().join("a/b"); + std::fs::create_dir_all(&nested).unwrap(); + let req = find_version_file(&nested).unwrap(); + assert_eq!(req.source, RequestSource::Nvmrc); + assert_eq!(req.spec, NodeSpec::Exact("22.1.0".parse().unwrap())); + assert_eq!(req.on_fail, aube_manifest::OnFail::Download); + } + + #[test] + fn node_version_beats_nvmrc_in_same_dir() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join(".nvmrc"), "20").unwrap(); + std::fs::write(tmp.path().join(".node-version"), "22").unwrap(); + let req = find_version_file(tmp.path()).unwrap(); + assert_eq!(req.source, RequestSource::NodeVersionFile); + } + + #[test] + fn nearer_nvmrc_beats_farther_node_version() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join(".node-version"), "20").unwrap(); + let nested = tmp.path().join("proj"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(nested.join(".nvmrc"), "22").unwrap(); + let req = find_version_file(&nested).unwrap(); + assert_eq!(req.source, RequestSource::Nvmrc); + } + + #[test] + fn unparseable_file_is_skipped_and_walk_continues() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join(".nvmrc"), "22").unwrap(); + let nested = tmp.path().join("proj"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write(nested.join(".nvmrc"), "definitely not a version !!!").unwrap(); + let req = find_version_file(&nested).unwrap(); + assert_eq!(req.origin, tmp.path().join(".nvmrc")); + } + + #[test] + fn comments_and_blank_lines_are_tolerated() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write( + tmp.path().join(".nvmrc"), + "# pinned for CI\n\n lts/jod \n", + ) + .unwrap(); + let req = find_version_file(tmp.path()).unwrap(); + assert_eq!(req.spec, NodeSpec::LtsCodename("jod".into())); + } + + #[test] + fn no_file_returns_none() { + let tmp = tempfile::tempdir().unwrap(); + // The walk can escape the tempdir upward; only assert when the + // ancestor chain is clean of version files (always true on CI, + // usually true locally — gate on it instead of flaking). + let mut ancestor_has_file = false; + let mut d = tmp.path().parent(); + while let Some(p) = d { + if p.join(".nvmrc").exists() || p.join(".node-version").exists() { + ancestor_has_file = true; + break; + } + d = p.parent(); + } + if !ancestor_has_file { + assert!(find_version_file(tmp.path()).is_none()); + } + } + + #[test] + fn dev_engines_beats_version_files() { + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join(".nvmrc"), "20").unwrap(); + let manifest = tmp.path().join("package.json"); + let req = effective_request(Some(("^22", None, manifest.as_path())), tmp.path()) + .unwrap() + .unwrap(); + assert_eq!(req.source, RequestSource::DevEngines); + // Spec default: missing onFail means error. + assert_eq!(req.on_fail, aube_manifest::OnFail::Error); + } + + #[test] + fn dev_engines_on_fail_is_honored() { + let tmp = tempfile::tempdir().unwrap(); + let manifest = tmp.path().join("package.json"); + let req = effective_request( + Some(( + "^22", + Some(aube_manifest::OnFail::Download), + manifest.as_path(), + )), + tmp.path(), + ) + .unwrap() + .unwrap(); + assert_eq!(req.on_fail, aube_manifest::OnFail::Download); + } +} diff --git a/crates/aube-runtime/src/spec.rs b/crates/aube-runtime/src/spec.rs new file mode 100644 index 000000000..c22dc5e9f --- /dev/null +++ b/crates/aube-runtime/src/spec.rs @@ -0,0 +1,198 @@ +//! Node version request parsing: exact versions, semver ranges, and +//! the alias vocabulary `pnpm runtime` / nvm users expect (`lts`, +//! `latest`, LTS codenames like `jod` / `lts/jod`). + +use crate::error::Error; + +/// A parsed Node version request. +#[derive(Debug, Clone, PartialEq)] +pub enum NodeSpec { + Exact(node_semver::Version), + Range(node_semver::Range), + /// Newest LTS release (`lts`, `lts/*`). + Lts, + /// Newest release of any kind (`latest`, `current`, `node`). + Latest, + /// A named LTS line (`jod`, `lts/jod`, `lts/iron`). Stored + /// lowercased; validated against the dist index at resolve time. + LtsCodename(String), +} + +impl NodeSpec { + /// Parse a user-written request. Accepts a leading `v` on + /// versions and either `lts/` or a bare codename. + pub fn parse(raw: &str) -> Result { + let s = raw.trim(); + let lowered = s.to_ascii_lowercase(); + match lowered.as_str() { + "lts" | "lts/*" => return Ok(NodeSpec::Lts), + "latest" | "current" | "node" | "*" => return Ok(NodeSpec::Latest), + _ => {} + } + if let Some(name) = lowered.strip_prefix("lts/") { + if !name.is_empty() && name.chars().all(|c| c.is_ascii_alphabetic()) { + return Ok(NodeSpec::LtsCodename(name.to_string())); + } + return Err(Error::NoMatchingVersion { + requested: raw.to_string(), + platform_note: String::new(), + }); + } + let unprefixed = s.strip_prefix('v').unwrap_or(s); + if let Ok(v) = node_semver::Version::parse(unprefixed) { + return Ok(NodeSpec::Exact(v)); + } + if let Ok(r) = node_semver::Range::parse(unprefixed) { + return Ok(NodeSpec::Range(r)); + } + // Bare alphabetic word → treat as an LTS codename (`jod`, + // `iron`); resolution fails cleanly if the index has no such + // LTS line. + if !lowered.is_empty() && lowered.chars().all(|c| c.is_ascii_alphabetic()) { + return Ok(NodeSpec::LtsCodename(lowered)); + } + Err(Error::NoMatchingVersion { + requested: raw.to_string(), + platform_note: String::new(), + }) + } + + /// Whether `version` satisfies this request, for the parts of the + /// vocabulary that are decidable without the dist index. `Lts` / + /// `Latest` / codenames return `None` — satisfaction depends on + /// index data the caller may not have. + pub fn satisfied_by(&self, version: &node_semver::Version) -> Option { + match self { + NodeSpec::Exact(v) => Some(v == version), + NodeSpec::Range(r) => Some(version.satisfies(r)), + NodeSpec::Lts | NodeSpec::Latest | NodeSpec::LtsCodename(_) => None, + } + } + + /// The request as the user would write it (used for messages and + /// for the lockfile `specifier`). + pub fn display(&self) -> String { + match self { + NodeSpec::Exact(v) => v.to_string(), + NodeSpec::Range(r) => r.to_string(), + NodeSpec::Lts => "lts".to_string(), + NodeSpec::Latest => "latest".to_string(), + NodeSpec::LtsCodename(name) => format!("lts/{name}"), + } + } +} + +impl std::str::FromStr for NodeSpec { + type Err = Error; + + fn from_str(s: &str) -> Result { + NodeSpec::parse(s) + } +} + +impl std::fmt::Display for NodeSpec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.display()) + } +} + +/// Where a node version request came from, in precedence order. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RequestSource { + /// `package.json` `devEngines.runtime` (name == node). + DevEngines, + /// A `.node-version` file. + NodeVersionFile, + /// A `.nvmrc` file. + Nvmrc, +} + +impl RequestSource { + pub fn label(self) -> &'static str { + match self { + RequestSource::DevEngines => "devEngines.runtime", + RequestSource::NodeVersionFile => ".node-version", + RequestSource::Nvmrc => ".nvmrc", + } + } +} + +/// A fully-formed request: what version, what to do when it can't be +/// satisfied locally, and where the requirement came from. +#[derive(Debug, Clone)] +pub struct NodeRequest { + pub spec: NodeSpec, + /// The request exactly as the user wrote it (`"22"`, `"^24.4.0"`, + /// `"lts/jod"`). Lockfile specifiers and display both use this — + /// `NodeSpec::display()` normalizes ranges, and a normalized form + /// would never string-match the verbatim range pnpm records. + pub raw: String, + pub on_fail: aube_manifest::OnFail, + pub source: RequestSource, + /// Path of the file the request was read from (version file or + /// package.json), for diagnostics. + pub origin: std::path::PathBuf, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(s: &str) -> NodeSpec { + NodeSpec::parse(s).unwrap() + } + + #[test] + fn parses_exact_versions() { + assert!(matches!(parse("22.1.0"), NodeSpec::Exact(_))); + assert!(matches!(parse("v22.1.0"), NodeSpec::Exact(_))); + assert!(matches!(parse("18.0.0-rc.1"), NodeSpec::Exact(_))); + } + + #[test] + fn parses_ranges() { + assert!(matches!(parse("^22"), NodeSpec::Range(_))); + assert!(matches!(parse(">=18 <21"), NodeSpec::Range(_))); + assert!(matches!(parse("22"), NodeSpec::Range(_))); + assert!(matches!(parse("22.x"), NodeSpec::Range(_))); + assert!(matches!(parse("~18.12"), NodeSpec::Range(_))); + } + + #[test] + fn parses_aliases() { + assert_eq!(parse("lts"), NodeSpec::Lts); + assert_eq!(parse("LTS"), NodeSpec::Lts); + assert_eq!(parse("lts/*"), NodeSpec::Lts); + assert_eq!(parse("latest"), NodeSpec::Latest); + assert_eq!(parse("current"), NodeSpec::Latest); + assert_eq!(parse("node"), NodeSpec::Latest); + assert_eq!(parse("*"), NodeSpec::Latest); + assert_eq!(parse("lts/jod"), NodeSpec::LtsCodename("jod".into())); + assert_eq!(parse("lts/Jod"), NodeSpec::LtsCodename("jod".into())); + assert_eq!(parse("jod"), NodeSpec::LtsCodename("jod".into())); + } + + #[test] + fn rejects_garbage() { + assert!(NodeSpec::parse("lts/").is_err()); + assert!(NodeSpec::parse("not a spec !!").is_err()); + assert!(NodeSpec::parse("").is_err()); + } + + #[test] + fn local_satisfaction() { + let v: node_semver::Version = "22.3.0".parse().unwrap(); + assert_eq!(parse("^22").satisfied_by(&v), Some(true)); + assert_eq!(parse("^20").satisfied_by(&v), Some(false)); + assert_eq!(parse("22.3.0").satisfied_by(&v), Some(true)); + assert_eq!(parse("lts").satisfied_by(&v), None); + assert_eq!(parse("jod").satisfied_by(&v), None); + } + + #[test] + fn bare_number_is_a_range() { + // "22" must match every 22.x.y, not just 22.0.0. + let v: node_semver::Version = "22.9.1".parse().unwrap(); + assert_eq!(parse("22").satisfied_by(&v), Some(true)); + } +} diff --git a/crates/aube-scripts/src/lib.rs b/crates/aube-scripts/src/lib.rs index 31018600d..ae12a26e9 100644 --- a/crates/aube-scripts/src/lib.rs +++ b/crates/aube-scripts/src/lib.rs @@ -36,6 +36,14 @@ pub struct ScriptSettings { pub script_shell: Option, pub unsafe_perm: Option, pub shell_emulator: bool, + /// Directory of the project's resolved Node runtime, prepended to + /// PATH after the project `.bin` so the switched node beats the + /// system one while project-local binaries still win. `None` when + /// no runtime switching is active. + pub node_bin_dir: Option, + /// The resolved node executable, exported as `npm_node_execpath` / + /// `NODE` (npm parity) for every script. + pub node_exe: Option, } /// Native build jail applied to dependency lifecycle scripts. @@ -129,8 +137,13 @@ fn script_settings() -> ScriptSettings { /// Prepend `bin_dir` to the current `PATH` using the platform's path /// separator (`:` on Unix, `;` on Windows). pub fn prepend_path(bin_dir: &Path) -> std::ffi::OsString { + prepend_paths(std::slice::from_ref(&bin_dir.to_path_buf())) +} + +/// [`prepend_path`] for multiple directories, prepended in order. +pub fn prepend_paths(bin_dirs: &[PathBuf]) -> std::ffi::OsString { let path = std::env::var_os("PATH").unwrap_or_default(); - let mut entries = vec![bin_dir.to_path_buf()]; + let mut entries: Vec = bin_dirs.to_vec(); entries.extend(std::env::split_paths(&path)); std::env::join_paths(entries).unwrap_or(path) } @@ -842,16 +855,22 @@ pub async fn run_script( // `"node_modules"` at the call site, but a workspace may have // configured something else. let project_bin = project_root.join(modules_dir_name).join(".bin"); + let settings = script_settings(); let path = std::env::var_os("PATH").unwrap_or_default(); - let mut entries: Vec = Vec::with_capacity(extra_bin_dirs.len() + 1); + let mut entries: Vec = Vec::with_capacity(extra_bin_dirs.len() + 2); for dir in extra_bin_dirs { entries.push(dir.to_path_buf()); } entries.push(project_bin); + // The switched Node runtime sits between project bins and the + // inherited PATH: scripts spawning `node` (directly or via + // `#!/usr/bin/env node`) get the project's pinned version, while + // anything installed into `.bin` still wins. + if let Some(dir) = &settings.node_bin_dir { + entries.push(dir.clone()); + } entries.extend(std::env::split_paths(&path)); let new_path = std::env::join_paths(entries).unwrap_or(path); - - let settings = script_settings(); let jail_home = jail.map(|j| jail_home(&j.package_dir)); if let Some(home) = &jail_home { std::fs::create_dir_all(home) @@ -865,6 +884,11 @@ pub async fn run_script( .stderr(child_stderr()) .env("PATH", &new_path) .env("npm_lifecycle_event", script_name); + // Set after the jail's env_clear (builder env calls compose in + // order), so jailed builds see the pinned node too. + if let Some(node_exe) = &settings.node_exe { + cmd.env("npm_node_execpath", node_exe).env("NODE", node_exe); + } // Pass INIT_CWD the way npm/pnpm do — the directory the user // invoked the package manager from, *not* the script's own cwd. diff --git a/crates/aube-settings/settings.toml b/crates/aube-settings/settings.toml index 6d7bf2527..f7ddaba93 100644 --- a/crates/aube-settings/settings.toml +++ b/crates/aube-settings/settings.toml @@ -1994,17 +1994,26 @@ sources.npmrc = ["package-manager-strict-version", "packageManagerStrictVersion" examples = [] [managePackageManagerVersions] -description = "Auto-download the specified pnpm version when mismatched." +description = "Switch to the aube version pinned by `packageManager` / `devEngines.packageManager`, downloading it when missing." type = "bool" default = "true" -docs = "Accepted for pnpm parity. aube does not download or re-exec other package-manager versions; when exact version enforcement is enabled, mismatches are reported instead." +docs = """ +pnpm parity (corepack semantics). When the project pins aube — +`packageManager: "aube@1.17.2"` (exact) or +`devEngines.packageManager: {name: "aube", version: "^1.17"}` (ranges +allowed) — and the running aube doesn't satisfy the pin, aube locates +or installs the pinned version (mise installs are reused; +`runtimeInstaller` controls who downloads) and re-execs it with the +same arguments. Set to `false` for validation-only behavior: +`packageManagerStrict` / `packageManagerStrictVersion` then warn or +error on mismatch instead of switching. Pins naming *other* package +managers (`pnpm@…`) are never switched to — only validated. +""" sources.cli = [] sources.env = ["npm_config_manage_package_manager_versions", "NPM_CONFIG_MANAGE_PACKAGE_MANAGER_VERSIONS", "AUBE_MANAGE_PACKAGE_MANAGER_VERSIONS"] -sources.npmrc = ["managePackageManagerVersions"] +sources.npmrc = ["manage-package-manager-versions", "managePackageManagerVersions"] +sources.workspaceYaml = ["managePackageManagerVersions"] examples = [] -# Parity no-op: aube does not download other package-manager versions. -# Remove this flag if/when aube ever grows that capability. -typedAccessorUnused = true # ========================================= Build ========================================= @@ -2268,7 +2277,17 @@ typedAccessorUnused = true description = "Node.js version aube reports when evaluating `engines` checks." type = "string" default = "output of `node -v` with the leading `v` stripped" -docs = "Paired with `engineStrict`. Set this in .npmrc to pin the Node version engines checks validate against, rather than probing `node --version` at install time." +docs = """ +Paired with `engineStrict`. Set this in .npmrc to pin the Node version +engines checks validate against, rather than probing `node --version` +at install time. + +This setting is validation-only — it never switches the Node version +scripts actually run on (pnpm semantics). Runtime switching is driven +by `devEngines.runtime` in package.json, `.node-version`, or `.nvmrc`; +when one of those resolves, engines checks validate against the +resolved runtime unless `nodeVersion` overrides the reported version. +""" sources.cli = [] sources.env = ["npm_config_node_version", "NPM_CONFIG_NODE_VERSION", "AUBE_NODE_VERSION"] sources.npmrc = ["node-version", "nodeVersion"] @@ -2279,13 +2298,63 @@ description = "Custom Node.js download mirror URLs." type = "object" default = "undefined" docs = """ -Accepted for pnpm config parity. aube does not download Node.js itself, -so the parsed mirror map is preserved for config introspection but has -no install-time effect. +Mirror map for Node.js runtime downloads, keyed like pnpm's setting +(`release` is the only key aube currently uses; `rc`/`nightly` are +parsed for parity). When the project requests a Node version aube has +to download (see `devEngines.runtime` and `runtimeInstaller`), the +`release` mirror replaces `https://nodejs.org/dist`. + +```yaml +nodeDownloadMirrors: + release: https://npmmirror.com/mirrors/node/ +``` """ sources.cli = [] sources.env = [] sources.npmrc = ["nodeDownloadMirrors"] +sources.workspaceYaml = ["nodeDownloadMirrors"] +examples = [] + +[runtimeInstaller] +description = "Who installs a missing Node.js version requested by `devEngines.runtime`, `.node-version`, or `.nvmrc`." +type = '"auto" | "mise" | "aube"' +default = "\"auto\"" +docs = """ +When the project pins a Node version that isn't installed anywhere +aube can see (PATH, mise installs, aube's own runtime dir), this +decides who fetches it: + +- `auto` (default): delegate to `mise install node@` when + `mise` is on PATH — one shared Node store for mise users — and fall + back to aube's own nodejs.org download otherwise (or when mise + fails). +- `mise`: always delegate; error (`ERR_AUBE_RUNTIME_MISE_INSTALL_FAILED`) + if mise is missing or fails. +- `aube`: never delegate; download from nodejs.org (or + `nodeDownloadMirrors.release`) into `$XDG_DATA_HOME/aube/nodejs/`. +""" +sources.cli = [] +sources.env = ["npm_config_runtime_installer", "NPM_CONFIG_RUNTIME_INSTALLER", "AUBE_RUNTIME_INSTALLER"] +sources.npmrc = ["runtime-installer", "runtimeInstaller"] +sources.workspaceYaml = ["runtimeInstaller"] +examples = ["echo 'runtime-installer=aube' >> .npmrc"] + +[runtimeOnFail] +description = "Override the `onFail` policy applied when the active Node.js doesn't satisfy the project's runtime requirement." +type = '"download" | "error" | "warn" | "ignore"' +default = "undefined" +docs = """ +pnpm 11 parity. When unset, `devEngines.runtime`'s own `onFail` field +applies (spec default `error`), and `.node-version`/`.nvmrc` pins — +which have no `onFail` vocabulary — behave as `download`. Setting this +forces one policy everywhere: `error` is the air-gapped-CI "never +download a runtime" switch; `download` makes a bare `devEngines.runtime` +auto-fetch. +""" +sources.cli = [] +sources.env = ["npm_config_runtime_on_fail", "NPM_CONFIG_RUNTIME_ON_FAIL", "AUBE_RUNTIME_ON_FAIL"] +sources.npmrc = ["runtime-on-fail", "runtimeOnFail"] +sources.workspaceYaml = ["runtimeOnFail"] examples = [] # ========================================= Other ========================================= diff --git a/crates/aube/Cargo.toml b/crates/aube/Cargo.toml index 9a6f775eb..7bdab5b67 100644 --- a/crates/aube/Cargo.toml +++ b/crates/aube/Cargo.toml @@ -33,6 +33,7 @@ test = false [dependencies] aube-codes = { workspace = true } aube-lockfile = { workspace = true } +aube-runtime = { workspace = true } aube-manifest = { workspace = true } aube-registry = { workspace = true } aube-util = { workspace = true } diff --git a/crates/aube/src/commands/dlx.rs b/crates/aube/src/commands/dlx.rs index bf9fe03fe..ae3e86520 100644 --- a/crates/aube/src/commands/dlx.rs +++ b/crates/aube/src/commands/dlx.rs @@ -159,6 +159,13 @@ pub async fn run(args: DlxArgs) -> miette::Result<()> { } } + // Resolve the runtime from the *user's* project before switching + // into the scratch dir — a dlx scratch project has no version + // config, and the OnceCell context is process-global, so resolving + // here (original cwd) is what makes `aubx` honor the project's + // .nvmrc / devEngines. + crate::runtime::ensure_for_cwd(&crate::dirs::cwd()?).await?; + let tmp = tempfile::Builder::new() .prefix("aube-dlx-") .tempdir() @@ -216,8 +223,11 @@ pub async fn run(args: DlxArgs) -> miette::Result<()> { // globally. Read the same setting here so the scratch bin dir // matches where the install actually wrote the bins. let bin_dir = super::project_modules_dir(&project_dir).join(".bin"); - let new_path = aube_scripts::prepend_path(&bin_dir); + let mut path_dirs = vec![bin_dir]; + path_dirs.extend(crate::runtime::path_entries()); + let new_path = aube_scripts::prepend_paths(&path_dirs); let mut cmd = aube_scripts::spawn_shell(&line); + crate::runtime::apply_child_env(&mut cmd); cmd.env("PATH", &new_path) .current_dir(&prev_cwd) .stderr(aube_scripts::child_stderr()) @@ -253,11 +263,18 @@ pub async fn run(args: DlxArgs) -> miette::Result<()> { // (os error 193). Prefer the `.cmd` shim on Windows; on Unix // the bare shim is the executable. let exec_path = super::exec::resolve_exec_shim(&bin_path); - tokio::process::Command::new(&exec_path) - .args(&bin_args) + let mut cmd = tokio::process::Command::new(&exec_path); + cmd.args(&bin_args) .current_dir(&prev_cwd) - .stderr(aube_scripts::child_stderr()) - .status() + .stderr(aube_scripts::child_stderr()); + // Shebang shims resolve `node` through PATH — give them the + // switched runtime. + let runtime_dirs = crate::runtime::path_entries(); + if !runtime_dirs.is_empty() { + cmd.env("PATH", aube_scripts::prepend_paths(&runtime_dirs)); + } + crate::runtime::apply_child_env(&mut cmd); + cmd.status() .await .into_diagnostic() .wrap_err("failed to execute dlx binary")? diff --git a/crates/aube/src/commands/doctor.rs b/crates/aube/src/commands/doctor.rs index fcdae1410..e3978b391 100644 --- a/crates/aube/src/commands/doctor.rs +++ b/crates/aube/src/commands/doctor.rs @@ -46,7 +46,17 @@ pub async fn run(args: DoctorArgs) -> miette::Result<()> { let project_root = crate::dirs::find_project_root(&cwd); let anchor = project_root.clone().unwrap_or_else(|| cwd.clone()); - let report = build_report(&anchor, project_root.is_some())?; + // Resolve the project's runtime so the report shows what scripts + // would actually run on. A resolution failure is a finding, not a + // crash — doctor exists to surface exactly these problems. + let runtime_error = crate::runtime::ensure_for_cwd(&cwd).await.err(); + + let mut report = build_report(&anchor, project_root.is_some())?; + if let Some(e) = runtime_error { + report + .errors + .push(format!("node runtime resolution failed: {e}")); + } if args.json { print_json(&report); @@ -133,7 +143,7 @@ fn version_section() -> Section { fn runtime_section(warnings: &mut Vec) -> Section { let mut s = Section::new("runtime"); - match crate::engines::resolve_node_version(None) { + match crate::engines::effective_node_version(None) { Some(v) => s.push("node", format!("v{v}")), None => { s.push("node", "(not found)"); @@ -143,6 +153,22 @@ fn runtime_section(warnings: &mut Vec) -> Section { ); } } + if let Some(ctx) = crate::runtime::current() { + s.push("node-source", ctx.source.label()); + if let Some(requested) = &ctx.requested { + s.push("node-requested", requested.clone()); + s.push("node-provenance", ctx.provenance.label()); + if ctx.version.is_none() { + warnings.push(format!( + "the project requests node {requested} (via {}) but the requirement is unsatisfied", + ctx.source.label() + )); + } + } + if let Some(bin) = &ctx.node_bin { + s.push("node-bin", display_path_owned(bin.clone())); + } + } if let Some(shell) = std::env::var_os("SHELL") && let Some(text) = shell.to_str() { @@ -184,8 +210,26 @@ fn project_section(anchor: &Path, report: &mut Report) -> Section { _ => "(unnamed)".into(), }; s.push("package", label); + // Self-version pin (packageManager / devEngines.packageManager). + let self_pin = manifest + .dev_engines + .as_ref() + .and_then(|d| d.aube_package_manager()) + .and_then(|e| e.version.clone()) + .map(|v| (v, "devEngines.packageManager")) + .or_else(|| { + manifest + .extra + .get("packageManager") + .and_then(|v| v.as_str()) + .and_then(|raw| raw.strip_prefix("aube@")) + .map(|v| (v.to_string(), "packageManager")) + }); + if let Some((pin, source)) = self_pin { + s.push("aube-pin", format!("{pin} (via {source})")); + } if let Some(range) = manifest.engines.get("node") - && let Some(node) = crate::engines::resolve_node_version(None) + && let Some(node) = crate::engines::effective_node_version(None) { match ( node_semver::Version::parse(&node), diff --git a/crates/aube/src/commands/exec.rs b/crates/aube/src/commands/exec.rs index e6c45e87e..9ebe6b554 100644 --- a/crates/aube/src/commands/exec.rs +++ b/crates/aube/src/commands/exec.rs @@ -79,6 +79,9 @@ pub async fn run( exec_args.network.install_overrides(); exec_args.lockfile.install_overrides(); exec_args.virtual_store.install_overrides(); + // Resolve the project's Node runtime before anything spawns (see + // run.rs for the warm-path rationale). + crate::runtime::ensure_for_cwd(&crate::dirs::cwd()?).await?; let ExecArgs { bin, args, @@ -298,7 +301,9 @@ pub(crate) async fn exec_bin_with_node_args( .collect::>() .join(" "); let bin_dir = super::project_modules_dir(cwd).join(".bin"); - let new_path = aube_scripts::prepend_path(&bin_dir); + let mut path_dirs = vec![bin_dir]; + path_dirs.extend(crate::runtime::path_entries()); + let new_path = aube_scripts::prepend_paths(&path_dirs); let mut cmd = aube_scripts::spawn_shell(&line); cmd.env("PATH", &new_path); cmd @@ -358,7 +363,9 @@ pub(crate) async fn exec_bin_status_with_node_args( .collect::>() .join(" "); let bin_dir = super::project_modules_dir(cwd).join(".bin"); - let new_path = aube_scripts::prepend_path(&bin_dir); + let mut path_dirs = vec![bin_dir]; + path_dirs.extend(crate::runtime::path_entries()); + let new_path = aube_scripts::prepend_paths(&path_dirs); let mut cmd = aube_scripts::spawn_shell(&line); cmd.env("PATH", &new_path); cmd @@ -366,8 +373,15 @@ pub(crate) async fn exec_bin_status_with_node_args( let exec_path = resolve_exec_shim(bin_path); let mut cmd = tokio::process::Command::new(exec_path); cmd.args(args); + // `#!/usr/bin/env node` shebangs resolve through the child's + // PATH — put the switched runtime ahead of the inherited one. + let runtime_dirs = crate::runtime::path_entries(); + if !runtime_dirs.is_empty() { + cmd.env("PATH", aube_scripts::prepend_paths(&runtime_dirs)); + } cmd }; + crate::runtime::apply_child_env(&mut command); command.current_dir(cwd); super::run_output::run_command(command, output_mode).await } @@ -385,7 +399,8 @@ fn node_bin_command( if !is_node_backed_bin(&target.path) { return None; } - let mut cmd = tokio::process::Command::new(target.node.unwrap_or_else(|| "node".into())); + let mut cmd = + tokio::process::Command::new(target.node.unwrap_or_else(crate::runtime::node_program)); if let Some(node_path) = target.node_path { cmd.env("NODE_PATH", node_path); } diff --git a/crates/aube/src/commands/install/fetch.rs b/crates/aube/src/commands/install/fetch.rs index 1cbea95c6..806526169 100644 --- a/crates/aube/src/commands/install/fetch.rs +++ b/crates/aube/src/commands/install/fetch.rs @@ -88,7 +88,7 @@ pub(super) async fn import_local_source( "buildDir": build_dir, "locator": format!("{pkg_name}@{}", local.specifier()), }); - let status = tokio::process::Command::new("node") + let status = tokio::process::Command::new(crate::runtime::node_program()) .arg("-e") .arg(aube_resolver::YARN_EXEC_WRAPPER) .arg(&script) diff --git a/crates/aube/src/commands/install/mod.rs b/crates/aube/src/commands/install/mod.rs index 11fb0b522..84c501ce6 100644 --- a/crates/aube/src/commands/install/mod.rs +++ b/crates/aube/src/commands/install/mod.rs @@ -394,6 +394,26 @@ pub async fn run(opts: InstallOptions) -> miette::Result<()> { let existing_for_resolver: Option<&aube_lockfile::LockfileGraph> = lockfile_pre_parse.as_ref().map(|(g, _)| g); + // Resolve the project's Node runtime before graph hashing (the + // virtual-store keys fold the node major in) and before any + // lifecycle script can spawn. The lockfile's recorded pin wins + // over the manifest range when it still satisfies it. A no-op if + // `aube run`/`exec` already resolved on this process. + crate::runtime::ensure( + &cwd, + Some(&manifest), + crate::runtime::RuntimeSettings::from_ctx(&settings_ctx), + lockfile_pre_parse + .as_ref() + .and_then(|(g, _)| g.runtimes.get("node")), + ) + .await?; + // Re-push script settings now that the runtime context exists — + // the first call (top of this function) ran before the lockfile + // was parsed, so lifecycle scripts would otherwise miss the + // switched node. + super::configure_script_settings(&settings_ctx); + // `--lockfile-only` short-circuit. Resolves (or reuses a fresh // lockfile), writes the new lockfile, and exits before any tarball // fetch / link / lifecycle work. Runs *before* the FrozenMode @@ -618,7 +638,7 @@ pub async fn run(opts: InstallOptions) -> miette::Result<()> { let network_mode = opts.network_mode; let cwd_for_client = cwd.clone(); - let lock_node_version = crate::engines::resolve_node_version( + let lock_node_version = crate::engines::effective_node_version( aube_settings::resolved::node_version(&settings_ctx).as_deref(), ); let lock_build_policy = std::sync::Arc::new(build_policy.clone()); @@ -724,7 +744,7 @@ pub async fn run(opts: InstallOptions) -> miette::Result<()> { // await) can compute the same graph hashes the link phase // will. Keeping a single source of truth avoids any // subdir-name drift between prewarm and link step 1. - let node_version_for_prewarm = crate::engines::resolve_node_version( + let node_version_for_prewarm = crate::engines::effective_node_version( aube_settings::resolved::node_version(&settings_ctx).as_deref(), ); let build_policy_for_prewarm = std::sync::Arc::new(build_policy.clone()); @@ -1472,6 +1492,15 @@ pub async fn run(opts: InstallOptions) -> miette::Result<()> { } } let write_kind = source_kind_before.unwrap_or(aube_lockfile::LockfileKind::Aube); + // Record/refresh the devEngines runtime pin before the + // graph hits disk (pnpm 10.14+ parity). + crate::runtime::refresh_lockfile_pin( + &mut graph, + &manifest, + crate::runtime::RuntimeSettings::from_ctx(&settings_ctx), + write_kind, + ) + .await?; if shared_workspace_lockfile || !has_workspace { let written_path = write_lockfile_dir_remapped( &lockfile_dir, @@ -1707,7 +1736,7 @@ pub async fn run(opts: InstallOptions) -> miette::Result<()> { let (jail_policy, jail_policy_warnings) = JailBuildPolicy::from_settings(&settings_ctx, &ws_config_shared); let node_version_override = aube_settings::resolved::node_version(&settings_ctx); - let node_version = crate::engines::resolve_node_version(node_version_override.as_deref()); + let node_version = crate::engines::effective_node_version(node_version_override.as_deref()); crate::engines::run_checks( &aube_dir, &manifest, diff --git a/crates/aube/src/commands/install/resolve.rs b/crates/aube/src/commands/install/resolve.rs index 01b9c86fe..52119736d 100644 --- a/crates/aube/src/commands/install/resolve.rs +++ b/crates/aube/src/commands/install/resolve.rs @@ -259,6 +259,14 @@ pub(super) async fn run_lockfile_only(input: LockfileOnlyInput<'_>) -> miette::R } } let lo_write_kind = source_kind_before.unwrap_or(LockfileKind::Aube); + // Same runtime-pin recording as the main install path. + crate::runtime::refresh_lockfile_pin( + &mut graph, + manifest, + crate::runtime::RuntimeSettings::from_ctx(settings_ctx), + lo_write_kind, + ) + .await?; if shared_workspace_lockfile || !has_workspace { let lo_written = write_lockfile_dir_remapped( lockfile_dir, diff --git a/crates/aube/src/commands/mod.rs b/crates/aube/src/commands/mod.rs index 385060f45..a0424594b 100644 --- a/crates/aube/src/commands/mod.rs +++ b/crates/aube/src/commands/mod.rs @@ -55,6 +55,7 @@ pub mod restart; pub mod root; pub mod run; pub mod run_output; +pub mod runtime; pub mod sbom; pub mod security_scanner; pub mod sponsors; diff --git a/crates/aube/src/commands/run.rs b/crates/aube/src/commands/run.rs index 71e73c7e3..4d93b000b 100644 --- a/crates/aube/src/commands/run.rs +++ b/crates/aube/src/commands/run.rs @@ -161,6 +161,12 @@ pub async fn run( None => prompt_for_script()?, }; let node_args = node_args_from_run_flags(inspect, inspect_brk); + // Resolve the project's Node runtime before anything spawns — + // covers the warm path where no install runs (the install path + // re-enters ensure() with the lockfile pin, but the OnceCell makes + // that a no-op if we already resolved here; both read the same + // version sources so they agree). + crate::runtime::ensure_for_cwd(&crate::dirs::cwd()?).await?; let recursive = RecursiveOpts { // pnpm parity: topo sort is on by default. `--sort` and // `--no-sort` use clap `overrides_with`, so only one can land @@ -878,12 +884,18 @@ async fn build_script_command( let bin_dir = super::project_modules_dir(cwd).join(".bin"); let node_gyp_bin_dir = super::install::node_gyp_bootstrap::lazy_shim_bin_dir(&bin_dir)?; + let runtime_bin_dirs = crate::runtime::path_entries(); let path = std::env::var_os("PATH").unwrap_or_default(); - let mut entries = Vec::with_capacity(2 + usize::from(node_gyp_bin_dir.is_some())); + let mut entries = + Vec::with_capacity(2 + usize::from(node_gyp_bin_dir.is_some()) + runtime_bin_dirs.len()); entries.push(bin_dir); if let Some(dir) = node_gyp_bin_dir { entries.push(dir); } + // The switched Node runtime beats the inherited PATH but loses to + // project-local bins — scripts running `node` get the pinned + // version. + entries.extend(runtime_bin_dirs); entries.extend(std::env::split_paths(&path)); let new_path = std::env::join_paths(entries).unwrap_or(path); let script_dir = if cwd.is_absolute() { @@ -901,6 +913,7 @@ async fn build_script_command( // while working fine under `aube install` postinstall. npm // and pnpm set these on every script exec. let mut command = aube_scripts::spawn_shell(&shell_cmd); + crate::runtime::apply_child_env(&mut command); command .env("PATH", &new_path) .current_dir(cwd) diff --git a/crates/aube/src/commands/runtime.rs b/crates/aube/src/commands/runtime.rs new file mode 100644 index 000000000..cfebdc04c --- /dev/null +++ b/crates/aube/src/commands/runtime.rs @@ -0,0 +1,379 @@ +//! `aube runtime` — manage the project's Node.js runtime (pnpm 11's +//! `pnpm runtime` surface). +//! +//! `set` pins a version in `package.json`'s `devEngines.runtime` +//! (OpenJS shape) and chains an install so the runtime is fetched and +//! the resolved version lands in the lockfile. `list` shows what the +//! current project resolves to and which versions are installed. + +use clap::Args; +use miette::{Context, IntoDiagnostic, miette}; + +use super::install; + +#[derive(Debug, Args)] +pub struct RuntimeArgs { + #[command(subcommand)] + pub command: RuntimeCommand, +} + +#[derive(Debug, clap::Subcommand)] +pub enum RuntimeCommand { + /// Show the resolved runtime and installed versions + #[command(visible_alias = "ls")] + List(RuntimeListArgs), + /// Pin a runtime in package.json devEngines.runtime and install it + Set(RuntimeSetArgs), +} + +#[derive(Debug, Args)] +pub struct RuntimeSetArgs { + /// Runtime name (only `node` is supported) + pub name: String, + /// Version request: an exact version, a range (`^24`, `22`), `lts`, + /// `latest`, or an LTS codename (`lts/jod`) + // Explicit id: clap reserves the `version` id for the global + // `--version` flag, and a positional with the same id panics at + // dispatch with a bool/String downcast mismatch. + #[arg(id = "runtime-version", value_name = "VERSION")] + pub version: String, + /// Install for the user instead of the project (delegates to + /// `mise use -g node@` when mise manages installs) + #[arg(short = 'g', long)] + pub global: bool, + /// `onFail` policy written to devEngines.runtime + #[arg(long, value_name = "POLICY", default_value = "download")] + pub on_fail: String, + /// Pin the exact resolved version instead of a caret range + #[arg(long)] + pub save_exact: bool, +} + +#[derive(Debug, Args)] +pub struct RuntimeListArgs {} + +pub async fn run(args: RuntimeArgs) -> miette::Result<()> { + match args.command { + RuntimeCommand::Set(set) => run_set(set).await, + RuntimeCommand::List(_) => run_list().await, + } +} + +async fn run_set(args: RuntimeSetArgs) -> miette::Result<()> { + if args.name != "node" { + return Err(miette!( + "aube only manages the `node` runtime (got `{}`); deno/bun pins are not supported", + args.name + )); + } + let on_fail: aube_manifest::OnFail = args + .on_fail + .parse() + .map_err(|e: String| miette!("--on-fail: {e}"))?; + let spec = aube_runtime::NodeSpec::parse(&args.version) + .map_err(|e| miette!(code = e.code(), "{e}"))?; + + // Resolve the request to an exact version up front — `set lts` + // must write a concrete range, and a typo'd version should fail + // before the manifest is touched. + let cwd = crate::dirs::cwd()?; + let settings = super::with_settings_ctx(&cwd, crate::runtime::RuntimeSettings::from_ctx); + let cfg = aube_runtime::RuntimeConfig { + installer: settings.installer, + mirror: settings.mirror.clone(), + network: aube_runtime::NetworkMode::Online, + retries: 2, + }; + let pin = aube_runtime::NodeRuntime::new(cfg) + .resolve_for_lockfile(&spec) + .await + .map_err(|e| miette!(code = e.code(), "{e}"))?; + let resolved = pin.version.to_string(); + + // The range written to devEngines (pnpm's rule): an exact input is + // kept verbatim; anything else becomes a caret range on the + // resolved version. `--save-exact` forces the exact form. + let range = if args.save_exact { + resolved.clone() + } else { + match &spec { + aube_runtime::NodeSpec::Exact(v) => v.to_string(), + _ => format!("^{resolved}"), + } + }; + + if args.global { + return run_set_global(&settings, &resolved).await; + } + + let project_dir = crate::dirs::find_project_root(&cwd).ok_or_else(|| { + miette!( + "aube runtime set: no package.json found in {}", + cwd.display() + ) + })?; + let manifest_path = project_dir.join("package.json"); + let on_fail_str = on_fail.to_string(); + super::manifest_io::update_manifest_json_object(&manifest_path, |obj| { + write_dev_engines_runtime(obj, &range, &on_fail_str) + }) + .wrap_err("failed to update package.json devEngines")?; + println!("devEngines.runtime: node {range} (onFail: {on_fail_str})"); + + // Chain an install: resolves the runtime (installing it via the + // configured installer) and records the pin in the lockfile. + let opts = + install::InstallOptions::with_mode(super::chained_frozen_mode(install::FrozenMode::Prefer)); + install::run(opts).await?; + + let ctx = crate::runtime::current(); + if let Some(ctx) = ctx + && let Some(version) = &ctx.version + { + println!("node {version} ready ({})", ctx.provenance.label()); + } + Ok(()) +} + +/// `-g`: hand the install to mise when it manages runtimes (one Node +/// store on disk, and mise's activation puts it on PATH globally). +/// Under `runtimeInstaller=aube` there are no shims by design, so the +/// install lands in aube's runtime dir and the user wires PATH. +async fn run_set_global( + settings: &crate::runtime::RuntimeSettings, + resolved: &str, +) -> miette::Result<()> { + let use_mise = match settings.installer { + aube_runtime::InstallerMode::Aube => false, + aube_runtime::InstallerMode::Mise => true, + aube_runtime::InstallerMode::Auto => aube_runtime::mise_on_path().is_some(), + }; + if use_mise { + let Some(mise) = aube_runtime::mise_on_path() else { + return Err(miette!( + code = aube_codes::errors::ERR_AUBE_RUNTIME_MISE_INSTALL_FAILED, + "runtimeInstaller=mise but mise is not on PATH" + )); + }; + let status = tokio::process::Command::new(&mise) + .args(["use", "-g", &format!("node@{resolved}")]) + .status() + .await + .into_diagnostic() + .wrap_err("failed to spawn mise")?; + if !status.success() { + return Err(miette!( + code = aube_codes::errors::ERR_AUBE_RUNTIME_MISE_INSTALL_FAILED, + "mise use -g node@{resolved} failed (exit {:?})", + status.code() + )); + } + println!("node {resolved} set globally via mise"); + return Ok(()); + } + + // aube-managed global install: fetch into the runtime dir and tell + // the user how to reach it — aube deliberately ships no shims or + // shell activation. + let version: node_semver::Version = resolved.parse().into_diagnostic()?; + let cfg = aube_runtime::RuntimeConfig { + installer: aube_runtime::InstallerMode::Aube, + mirror: settings.mirror.clone(), + network: aube_runtime::NetworkMode::Online, + retries: 2, + }; + let request = aube_runtime::NodeRequest { + spec: aube_runtime::NodeSpec::Exact(version), + raw: resolved.to_string(), + on_fail: aube_manifest::OnFail::Download, + source: aube_runtime::RequestSource::DevEngines, + origin: std::path::PathBuf::from("aube runtime set -g"), + }; + let resolution = aube_runtime::NodeRuntime::new(cfg) + .resolve(&request, None, &aube_runtime::NoopProgress) + .await + .map_err(|e| miette!(code = e.code(), "{e}"))? + .ok_or_else(|| miette!("runtime resolution returned no install"))?; + println!( + "node {resolved} installed to {}", + resolution.node_bin.display() + ); + if let Some(bin_dir) = &resolution.bin_dir { + println!( + "aube has no shims — projects running through aube pick it up automatically;\n\ + to use it outside aube, add it to PATH: export PATH=\"{}:$PATH\"", + bin_dir.display() + ); + } + Ok(()) +} + +/// Merge a node entry into `devEngines.runtime`, preserving the +/// existing shape: a non-node single object becomes a two-entry +/// array, an array gets its node entry replaced (or appended), and +/// unrelated devEngines slots (os/cpu/packageManager) are untouched. +fn write_dev_engines_runtime( + obj: &mut serde_json::Map, + range: &str, + on_fail: &str, +) -> miette::Result<()> { + use serde_json::{Value, json}; + let node_entry = json!({ + "name": "node", + "version": range, + "onFail": on_fail, + }); + let dev_engines = obj + .entry("devEngines") + .or_insert_with(|| Value::Object(Default::default())); + let Value::Object(dev_engines) = dev_engines else { + return Err(miette!( + "package.json devEngines must be an object to update it" + )); + }; + match dev_engines.get_mut("runtime") { + None => { + dev_engines.insert("runtime".to_string(), node_entry); + } + Some(Value::Object(existing)) => { + if existing.get("name").and_then(Value::as_str) == Some("node") { + dev_engines.insert("runtime".to_string(), node_entry); + } else { + let prior = Value::Object(existing.clone()); + dev_engines.insert("runtime".to_string(), Value::Array(vec![prior, node_entry])); + } + } + Some(Value::Array(entries)) => { + if let Some(slot) = entries + .iter_mut() + .find(|e| e.get("name").and_then(Value::as_str) == Some("node")) + { + *slot = node_entry; + } else { + entries.push(node_entry); + } + } + Some(other) => { + return Err(miette!( + "package.json devEngines.runtime has an unsupported shape ({other}); fix it manually" + )); + } + } + Ok(()) +} + +async fn run_list() -> miette::Result<()> { + let cwd = crate::dirs::cwd()?; + let ctx = crate::runtime::ensure_for_cwd(&cwd).await?; + + match (&ctx.requested, &ctx.version) { + (Some(requested), Some(version)) => { + println!( + "node {version} (requested {requested} via {}, provided by {})", + ctx.source.label(), + ctx.provenance.label() + ); + } + (Some(requested), None) => { + println!( + "node: requested {requested} via {} but unsatisfied (running on PATH node)", + ctx.source.label() + ); + } + _ => match aube_runtime::probe_path_node() { + Some((version, path)) => { + println!("node {version} (no project pin; PATH: {})", path.display()); + } + None => println!("node: none found on PATH and no project pin"), + }, + } + if let Some(bin) = &ctx.node_bin { + println!(" bin: {}", bin.display()); + } + + let installed = aube_runtime::list_installed(); + if installed.is_empty() { + println!("\nno managed node installs found (aube or mise)"); + } else { + println!("\ninstalled:"); + for node in installed { + println!( + " {} ({}) {}", + node.version, + node.origin.label(), + node.install_dir.display() + ); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Value, json}; + + fn apply(initial: Value, range: &str) -> Value { + let Value::Object(mut obj) = initial else { + panic!("test input must be an object") + }; + write_dev_engines_runtime(&mut obj, range, "download").unwrap(); + Value::Object(obj) + } + + #[test] + fn creates_dev_engines_from_scratch() { + let out = apply(json!({"name": "x"}), "^24.4.1"); + assert_eq!( + out["devEngines"]["runtime"], + json!({"name": "node", "version": "^24.4.1", "onFail": "download"}) + ); + } + + #[test] + fn replaces_existing_node_object() { + let out = apply( + json!({"devEngines": {"runtime": {"name": "node", "version": "^20"}}}), + "^24.4.1", + ); + assert_eq!(out["devEngines"]["runtime"]["version"], "^24.4.1"); + } + + #[test] + fn non_node_object_becomes_array() { + let out = apply( + json!({"devEngines": {"runtime": {"name": "bun", "version": "^1.2"}}}), + "^24.4.1", + ); + let arr = out["devEngines"]["runtime"].as_array().unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0]["name"], "bun"); + assert_eq!(arr[1]["name"], "node"); + } + + #[test] + fn array_node_entry_is_replaced_in_place() { + let out = apply( + json!({"devEngines": {"runtime": [ + {"name": "deno", "version": "^2"}, + {"name": "node", "version": "^20"} + ]}}), + "^24.4.1", + ); + let arr = out["devEngines"]["runtime"].as_array().unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[1]["version"], "^24.4.1"); + } + + #[test] + fn sibling_dev_engines_slots_survive() { + let out = apply( + json!({"devEngines": { + "packageManager": {"name": "pnpm", "version": "^10"}, + "runtime": {"name": "node", "version": "^20"} + }}), + "^24.4.1", + ); + assert_eq!(out["devEngines"]["packageManager"]["name"], "pnpm"); + } +} diff --git a/crates/aube/src/commands/script_settings.rs b/crates/aube/src/commands/script_settings.rs index 0b42628cb..c5a75ecd7 100644 --- a/crates/aube/src/commands/script_settings.rs +++ b/crates/aube/src/commands/script_settings.rs @@ -10,11 +10,19 @@ pub(crate) fn configure_script_settings(ctx: &aube_settings::ResolveCtx<'_>) { .and_then(|s| non_empty_string(s).map(Into::into)); let unsafe_perm = aube_settings::resolved::unsafe_perm(ctx); let shell_emulator = aube_settings::resolved::shell_emulator(ctx); + // Runtime switching: `crate::runtime::ensure` must have run before + // this for lifecycle scripts to see the pinned node — the install + // driver resolves the runtime early, then configures script + // settings. When no context exists (or no switching is active) + // these stay `None` and scripts inherit PATH untouched. + let runtime = crate::runtime::current(); aube_scripts::set_script_settings(aube_scripts::ScriptSettings { node_options, script_shell, unsafe_perm, shell_emulator, + node_bin_dir: runtime.and_then(|r| r.bin_dir.clone()), + node_exe: runtime.and_then(|r| r.node_bin.clone()), }); } diff --git a/crates/aube/src/commands/security_scanner.rs b/crates/aube/src/commands/security_scanner.rs index 10a9e94f9..6dae21745 100644 --- a/crates/aube/src/commands/security_scanner.rs +++ b/crates/aube/src/commands/security_scanner.rs @@ -236,7 +236,7 @@ async fn invoke( .map_err(|e| format!("failed to encode scanner request: {e}"))?; let bridge = write_bridge_dir()?; - let mut cmd = tokio::process::Command::new("node"); + let mut cmd = tokio::process::Command::new(crate::runtime::node_program()); cmd.current_dir(cwd) // `--experimental-strip-types` lets node import `.ts` // entrypoints (Socket's scanner package is the canonical @@ -530,7 +530,7 @@ mod tests { /// Returns true iff `node --version` exits 0. e2e tests gate /// on this — CI runners without node skip rather than fail. fn node_available() -> bool { - std::process::Command::new("node") + std::process::Command::new(crate::runtime::node_program()) .arg("--version") .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) @@ -827,7 +827,7 @@ export const scanner = { // --experimental-strip-types. Cheap probe: `node // --experimental-strip-types -e ''` is a no-op on // supported versions and exits non-zero on unsupported. - let probe = std::process::Command::new("node") + let probe = std::process::Command::new(crate::runtime::node_program()) .arg("--experimental-strip-types") .arg("-e") .arg("''") diff --git a/crates/aube/src/engines.rs b/crates/aube/src/engines.rs index c9d4d9ec8..470bb4215 100644 --- a/crates/aube/src/engines.rs +++ b/crates/aube/src/engines.rs @@ -83,6 +83,27 @@ pub fn resolve_node_version(override_: Option<&str>) -> Option { PROBED.get_or_init(probe_node_version).clone() } +/// The Node version aube should report for engines checks once +/// runtime switching is in play: +/// +/// 1. an explicit `node-version` `.npmrc` override always wins +/// (validation-only knob, pnpm semantics); +/// 2. otherwise the resolved runtime context's version — engines must +/// validate the node scripts will actually run on, not whatever +/// PATH happened to carry; +/// 3. otherwise the memoized `node --version` probe. +pub fn effective_node_version(override_: Option<&str>) -> Option { + if override_.is_some() { + return resolve_node_version(override_); + } + if let Some(rt) = crate::runtime::current() + && let Some(v) = &rt.version + { + return Some(v.clone()); + } + resolve_node_version(None) +} + fn probe_node_version() -> Option { let output = std::process::Command::new("node") .arg("--version") @@ -430,6 +451,7 @@ mod tests { update_config: None, scripts: Default::default(), engines: Default::default(), + dev_engines: None, workspaces: None, bundled_dependencies: None, extra: Default::default(), diff --git a/crates/aube/src/main.rs b/crates/aube/src/main.rs index cac1b2281..e77b34d22 100644 --- a/crates/aube/src/main.rs +++ b/crates/aube/src/main.rs @@ -8,6 +8,8 @@ mod engines; mod patches; mod pnpmfile; mod progress; +mod runtime; +mod self_version; mod startup; mod state; mod update_check; @@ -454,6 +456,9 @@ enum Commands { /// Run a script defined in package.json #[command(alias = "run-script")] Run(commands::run::RunArgs), + /// Manage the project's Node.js runtime (pin, install, inspect) + #[command(visible_alias = "rt")] + Runtime(commands::runtime::RuntimeArgs), /// Generate a Software Bill of Materials (CycloneDX or SPDX) Sbom(commands::sbom::SbomArgs), /// Search the registry for packages (not implemented — use `npm search`) @@ -739,6 +744,11 @@ async fn async_main(cli: Cli) -> miette::Result> { commands::set_skip_auto_install_on_package_manager_mismatch(false); if command_needs_package_manager_guard(cli.command.as_ref()) { + // Self-version switch first: when the project pins aube and + // the pinned version resolves, this re-execs and never + // returns. The guard below then only sees matching (or + // policy-softened) states. + self_version::maybe_switch(&settings).await?; let guard = enforce_package_manager_guardrails(&settings, cli.command.as_ref())?; commands::set_skip_auto_install_on_package_manager_mismatch( guard == PackageManagerGuard::WarnRunOnly, @@ -911,6 +921,7 @@ async fn async_main(cli: Cli) -> miette::Result> { } Some(Commands::Root(args)) => commands::root::run(args).await?, Some(Commands::Run(args)) => commands::run::run(args, effective_filter.clone()).await?, + Some(Commands::Runtime(args)) => commands::runtime::run(args).await?, Some(Commands::Sbom(args)) => commands::sbom::run(args).await?, Some(Commands::Search(args)) => { return Ok(Some(commands::npm_fallback::run("search", &args)?)); diff --git a/crates/aube/src/pnpmfile.rs b/crates/aube/src/pnpmfile.rs index 256414f41..f3c8245dc 100644 --- a/crates/aube/src/pnpmfile.rs +++ b/crates/aube/src/pnpmfile.rs @@ -411,7 +411,7 @@ async fn run_one_shot_hook( ) -> Result> { tracing::debug!("running pnpmfile hook {hook_name} ({})", pnpmfile.display()); - let mut cmd = tokio::process::Command::new("node"); + let mut cmd = tokio::process::Command::new(crate::runtime::node_program()); cmd.arg("-e") .arg(one_shot_hook_shim()) .env("AUBE_PNPMFILE", pnpmfile) @@ -728,7 +728,7 @@ impl ReadPackageHost { "spawning pnpmfile readPackage host ({})", pnpmfile.display() ); - let mut cmd = tokio::process::Command::new("node"); + let mut cmd = tokio::process::Command::new(crate::runtime::node_program()); cmd.arg("-e") .arg(read_package_shim()) .env("AUBE_PNPMFILE", pnpmfile) diff --git a/crates/aube/src/runtime.rs b/crates/aube/src/runtime.rs new file mode 100644 index 000000000..57be8971b --- /dev/null +++ b/crates/aube/src/runtime.rs @@ -0,0 +1,518 @@ +//! Process-global Node runtime context: which node this aube process +//! should put on PATH for scripts, exec, dlx, and lifecycle hooks. +//! +//! Resolution happens once per process via [`ensure`]; every spawn +//! site reads the snapshot through [`current`] / [`path_entries`] / +//! [`node_program`] / [`apply_child_env`]. A project with no runtime +//! configuration resolves to a pass-through context — PATH untouched, +//! behavior identical to aube before runtime switching existed. + +use aube_manifest::PackageJson; +use aube_settings::ResolveCtx; +use miette::miette; +use std::path::{Path, PathBuf}; + +/// Where the version requirement came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeSource { + DevEngines, + NodeVersionFile, + Nvmrc, + /// No requirement configured (or policy said keep the ambient + /// node) — PATH is left alone. + PathFallback, +} + +impl RuntimeSource { + pub fn label(self) -> &'static str { + match self { + RuntimeSource::DevEngines => "devEngines.runtime", + RuntimeSource::NodeVersionFile => ".node-version", + RuntimeSource::Nvmrc => ".nvmrc", + RuntimeSource::PathFallback => "PATH", + } + } +} + +/// Who provided the resolved node binary. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeProvenance { + Mise, + AubeManaged, + System, +} + +impl RuntimeProvenance { + pub fn label(self) -> &'static str { + match self { + RuntimeProvenance::Mise => "mise", + RuntimeProvenance::AubeManaged => "aube", + RuntimeProvenance::System => "system", + } + } +} + +#[derive(Debug, Clone)] +pub struct RuntimeContext { + /// Directory to prepend to PATH for child processes. `None` means + /// no switching (ambient node already satisfies, or no config). + pub bin_dir: Option, + /// Absolute path of the selected node binary, when one resolved. + pub node_bin: Option, + /// Exact resolved version (`"24.4.1"`), when known. + pub version: Option, + /// The requested range/spec as written (`"^24.4.0"`, `"lts/jod"`). + pub requested: Option, + pub source: RuntimeSource, + pub provenance: RuntimeProvenance, + /// Full per-platform pin computed during a network resolve — + /// the install pipeline records it into the lockfile. + pub fresh_pin: Option, +} + +impl RuntimeContext { + /// Pass-through context: PATH untouched, no probing. Deliberately + /// lazy — `aubr