feat(runtime): node version switching and aube self-version management#861
Conversation
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 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (19)
✅ Files skipped from review due to trivial changes (4)
🚧 Files skipped from review as they are similar to previous changes (12)
📝 WalkthroughWalkthroughAdds manifest/devEngines parsing, a new aube-runtime crate for Node resolution/installation, lockfile runtime-pin support, CLI runtime commands, wiring to switch/propagate Node runtimes, and comprehensive tests and docs. ChangesNode runtime switching
Sequence Diagram(s)sequenceDiagram
participant CLI
participant Manifest
participant Runtime
participant Index
participant Installer
CLI->>Manifest: read devEngines.runtime / packageManager
CLI->>Runtime: ensure_for_cwd / ensure
Runtime->>Index: load_index / load_shasums
Runtime->>Installer: install (mise or aube)
Installer->>Runtime: installed node path + version
Runtime->>CLI: provide path_entries, node_program, and apply_child_env
Estimated code review effort🎯 5 (Critical) | ⏱️ ~90+ minutes Poem
|
GitHub computes a server-side SHA-256 for every release asset and exposes it as `assets[].digest` (tamper-evident under immutable releases), so self-version downloads verify against that instead of aube publishing checksum files — covering every release that already exists, with no release-workflow change. A `.sha256` sibling remains the fallback for custom mirrors, then TLS-only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Greptile SummaryThis PR adds a new
Confidence Score: 4/5Safe to merge with one fix recommended: lockfile_node_pin in runtime.rs should probe only the file parse_lockfile will actually read, to avoid silently dropping a pin when both aube-lock.yaml and pnpm-lock.yaml coexist. The implementation is thorough and well-tested. The core download, verification, and re-exec logic is correct. One reproducibility defect exists: lockfile_node_pin probes both lockfiles for the runtime-pin substring but parse_lockfile only reads the highest-precedence file — if an older aube-lock.yaml exists without a pin while pnpm-lock.yaml has one, frozen installs silently resolve from the range instead of the exact pinned version. crates/aube/src/runtime.rs — the lockfile_node_pin probe/parse inconsistency. Important Files Changed
Reviews (4): Last reviewed commit: "fix(runtime): address second-round revie..." | Re-trigger Greptile |
The digest lookup is the only GitHub API call in runtime switching and already degrades gracefully on 403/429, but unauthenticated calls share a 60/hr per-IP limit — exactly where cold self-installs cluster (ephemeral CI runners, NATed offices). Attach GITHUB_TOKEN/GH_TOKEN when present, and only for the real api.github.com host so an AUBE_SELF_API_BASE override can never siphon the token. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mise-versions.jdx.dev serves a CDN-cached, rate-limit-free mirror of the release version list and GitHub release metadata (including asset digests) — the same service mise itself consults. Use it as the primary source for self-version pins: - range pins now resolve against the full published version list (max-satisfying) instead of only the latest announcement - digest lookups hit the versions-host release proxy first; the GitHub API (with GITHUB_TOKEN) is the fallback, then .sha256 siblings for mirrors, then TLS-only - release metadata must echo the requested tag, guarding against stale or mis-keyed proxy cache entries Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- drift: a devEngines.runtime entry that names node but declares no version carries no concrete range — resolution ignores it, so it no longer reads as a removed pin (frozen installs stopped failing over a field that changes nothing) [cursor] - merge: same-version runtime pins now union per-platform variants (dst wins on target collisions) and report specifier disagreements instead of silently dropping the incoming side [cursor] - resolver: alias specs (lts/latest/codenames) consult the dist index under every onFail policy before concluding mismatch — an installed node that IS the latest LTS no longer draws a false warning; under warn/ignore an unreachable index degrades instead of blocking [greptile] - style: annotate the bin-form unwrap, drop the dummy 0.0.0 version by making DownloadProgress::on_phase take Option<&Version> [greptile] Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 2a13e87. Configure here.
There was a problem hiding this comment.
Actionable comments posted: 15
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (5)
.rules (1)
26-38:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winAdd
aube-utilto the crate inventory.
Cargo.tomlstill listsaube-utilas a workspace crate, so this inventory no longer describes the full workspace. If the cardinality incrates[12]is meant to stay accurate, it needs to becomecrates[13]once the missing entry is added.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.rules around lines 26 - 38, The crate inventory block labeled "crates[12]{name,role}" is missing the aube-util entry (Cargo.toml still references aube-util), so either add an entry for "aube-util" with its short role description into that list or update the cardinality to "crates[13]" and append the new line; locate the inventory block (the header "crates[12]{name,role}" and the following crate lines) and insert a concise "aube-util,..." line describing its role or increment the index and include the aube-util entry to keep the workspace inventory consistent with Cargo.toml.crates/aube-scripts/src/lib.rs (1)
887-920:⚠️ Potential issue | 🟠 Major | ⚡ Quick winJailed scripts still lose
npm_node_execpathandNODE.These vars are set before
apply_jail_env(), butapply_jail_env()callsenv_clear(). That means root scripts keep the switched-node env, while jailed dependency scripts silently drop it, which breaks the new “export node execpath for every script” behavior.Suggested fix
fn apply_script_settings_env(cmd: &mut tokio::process::Command, settings: &ScriptSettings) { // Strip credentials that aube itself owns before we spawn any // lifecycle script. AUBE_AUTH_TOKEN is aube's own registry login // token. No transitive postinstall has any business reading it. @@ if settings.shell_emulator { cmd.env("npm_config_shell_emulator", "true"); } + if let Some(node_exe) = settings.node_exe.as_deref() { + cmd.env("npm_node_execpath", node_exe) + .env("NODE", node_exe); + } } @@ - // 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🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/aube-scripts/src/lib.rs` around lines 887 - 920, The node execpath env vars (npm_node_execpath and NODE) are set on cmd before apply_jail_env(), but apply_jail_env() calls env_clear() which removes them for jailed scripts; update the code so the node execpath is preserved for jailed scripts by moving the logic that sets npm_node_execpath/NODE to after apply_jail_env() is invoked (or alternatively, modify apply_jail_env() to re-add these vars when settings.node_exe is present). Specifically adjust the block that references settings.node_exe and node_exe so it runs after apply_jail_env(&mut cmd, ...), or ensure apply_jail_env preserves or re-inserts those env vars when jail is Some.crates/aube/src/commands/install/fetch.rs (1)
91-103:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winUpdate the spawn error text to match runtime switching behavior.
After Line 91 switched to
crate::runtime::node_program(), Line 101’s"from PATH"wording can be incorrect when Node comes from a resolved/pinned runtime. Please make the message runtime-source agnostic (or include the resolved program path).Suggested patch
- miette!( - "execute {} with Node.js from PATH: {e}{chain}", - local.specifier() - ) + miette!( + "execute {} with resolved Node.js runtime: {e}{chain}", + local.specifier() + )🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/aube/src/commands/install/fetch.rs` around lines 91 - 103, The spawn error message for the Command created via tokio::process::Command::new(crate::runtime::node_program()) is misleading because it hardcodes "from PATH"; update the miette! error construction in the .map_err closure to be runtime-source agnostic by either removing "from PATH" or including the resolved program path (crate::runtime::node_program() or a local variable holding it) in the message so it accurately reflects whether Node was taken from PATH or a pinned runtime; adjust the miette! invocation that references local.specifier() accordingly.crates/aube/src/commands/exec.rs (1)
296-315:⚠️ Potential issue | 🟠 Major | ⚡ Quick winMirror the runtime injection in the non-status exec path.
This helper still launches the single-package path without the runtime PATH prepend or
apply_child_env, so directaube exec <bin>can miss the switched Node while the parallel/status path works. That breaks#!/usr/bin/env nodeshims and also leaks into callers likeaube run <bin>.Suggested fix
} else { let exec_path = resolve_exec_shim(bin_path); let mut cmd = tokio::process::Command::new(exec_path); cmd.args(args); + 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); let status = command .current_dir(cwd) .stderr(aube_scripts::child_stderr())🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/aube/src/commands/exec.rs` around lines 296 - 315, The non-status exec branch that builds the tokio::process::Command (created via resolve_exec_shim) currently skips the runtime PATH injection and environment adjustments; update that branch so it mirrors the status/parallel path by computing bin_dir = super::project_modules_dir(cwd).join(".bin"), combining it with crate::runtime::path_entries(), building new_path via aube_scripts::prepend_paths(&path_dirs), setting cmd.env("PATH", &new_path), and then calling the same apply_child_env(command) (or the appropriate function used elsewhere to inject runtime env) on the built Command before returning; keep the existing cmd.args(args) and ensure you reference resolve_exec_shim, project_modules_dir, crate::runtime::path_entries, aube_scripts::prepend_paths, and apply_child_env to locate where to apply these changes.crates/aube/src/commands/dlx.rs (1)
149-167:⚠️ Potential issue | 🟠 Major | ⚡ Quick winResolve the runtime before the local-bin fast path returns.
aube dlx <cmd>can return through the localnode_modules/.binshortcut before Line 167 runs, so that execution path never honors the project's runtime pin. The new resolution needs to happen before any executable return, not just before the scratch-dir flow.Suggested fix
// Bin name is only used in the non-shell path. Under shell-mode the // user assembles their own line and we run it through `sh -c`, so any // bin lookup is the shell's job. let bin_name = bin_name_for(&command); + crate::runtime::ensure_for_cwd(&crate::dirs::cwd()?).await?; if !explicit_package && !shell_mode && can_use_local_bin(&command) { let initial_cwd = crate::dirs::cwd()?; if let Some(project_dir) = crate::dirs::find_project_root(&initial_cwd) { let bin_path = super::project_modules_dir(&project_dir) .join(".bin") @@ - crate::runtime::ensure_for_cwd(&crate::dirs::cwd()?).await?; - let tmp = tempfile::Builder::new()🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/aube/src/commands/dlx.rs` around lines 149 - 167, The local-bin fast path can return before runtime resolution runs, so move the call to crate::runtime::ensure_for_cwd(&crate::dirs::cwd()?).await? to occur before the early return branch: call ensure_for_cwd right after computing initial_cwd (or at the top of the block guarded by !explicit_package && !shell_mode && can_use_local_bin(&command)) and before checking project root / bin_path and before invoking super::exec::exec_bin, so that the runtime is resolved for the user's project (respecting .nvmrc/devEngines) regardless of whether exec_bin returns early.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/aube-lockfile/src/merge.rs`:
- Around line 267-284: The runtime merge loop in merge.rs (the for (name,
incoming) in src.runtimes and Entry::Occupied branch) only checks
slot.get().version against incoming.version and therefore misses conflicts in
other fields; update the Occupied branch to compare all relevant Runtime fields
(specifier, dev, has_bin, variants, and version) between slot.get() and
incoming, and if any of those differ push a clear conflict message (e.g.,
"runtime `{}`: kept <existing> over <incoming> (diffs: ...)" or list which
fields differ) while still retaining the existing entry; ensure equality across
all fields avoids reporting a conflict.
In `@crates/aube-runtime/src/discover.rs`:
- Around line 117-123: The discovery currently accepts any regular file at
node_bin (node_paths_in) which lets non-executable/corrupt extracts pass
validate_install and enter the installed-runtime set; update the check that
currently uses node_bin.is_file() to also verify the file is executable
(platform-appropriate permission check) so validate_install() rejects files
without execute bits before selecting the runtime. Locate the node_paths_in /
discovery branch in discover.rs and replace the simple is_file() test with a
combined is_file() && is_executable check (using
std::os::unix::fs::PermissionsExt or equivalent on Windows) so non-executable
node binaries are filtered out.
In `@crates/aube-runtime/src/error.rs`:
- Around line 57-74: The Error::Io variant is currently mapped to
errors::ERR_AUBE_RUNTIME_DOWNLOAD_FAILED in impl Error::code(), which
misclassifies lock/acquire, mkdir, archive-open, and publish/rename I/O
failures; change this by either (A) adding a new stable code constant (e.g.
errors::ERR_AUBE_RUNTIME_IO) and return it from Error::Io in Error::code(), or
(B) split Error::Io into narrower variants (e.g. IoLock, IoCreateDir,
IoOpenArchive, IoPublishRename) and map each new variant to its own ERR_AUBE_*
constant; update the errors module to declare any new ERR_AUBE_* identifiers and
adjust any tests or places that pattern-match on Error::Io or expect the old
code.
In `@crates/aube-runtime/src/index.rs`:
- Around line 86-173: The code in load_index (and similarly install /
download_verify_extract) performs blocking std::fs I/O on async paths (e.g.,
std::fs::read, std::fs::create_dir_all, aube_util::fs_atomic::atomic_write)
which can block Tokio workers; change those to async-aware operations by either
using tokio::fs equivalents or moving the blocking sections into
tokio::task::spawn_blocking closures that return the same results, e.g., wrap
the initial cache read/deserialization, the cache write/parent-dir creation, and
any other std::fs usages in spawn_blocking (or replace with
tokio::fs::read/to_vec/create_dir_all and an async atomic-write helper) so
load_index, install, and download_verify_extract no longer perform blocking
filesystem calls on the async runtime.
- Around line 45-46: The is_lts method currently only treats
LtsField::Codename(_) as LTS, so LtsField::Flag(true) values from mirrors are
ignored; update is_lts (in the implementation for LtsField/where NodeSpec::Lts
flows through) to also return true for LtsField::Flag(true) (or remove the Flag
variant if you intend not to support boolean flags) — locate the is_lts function
and the LtsField enum and add a match arm for LtsField::Flag(true) (or remove
references to Flag and adjust callers like NodeSpec::Lts accordingly) so boolean
LTS indicators are handled consistently.
In `@crates/aube-runtime/src/self_install.rs`:
- Around line 50-53: The self_dir function currently treats an empty
AUBE_SELF_DIR value as a valid path (resolving to the working directory); update
self_dir to ignore empty environment values by checking the OsString returned
from std::env::var_os("AUBE_SELF_DIR") and returning None if it is empty (i.e.,
treat empty AUBE_SELF_DIR as unset) before converting to a PathBuf; adjust the
early-return logic in self_dir accordingly so only non-empty values are turned
into PathBufs.
- Around line 135-137: The current find call that assigns exe (the array of
dir.join(exe_name) and dir.join("bin").join(exe_name)) uses only p.is_file() so
it can pick non-executable files; change the predicate to p.is_file() &&
is_executable(p). Implement a small helper is_executable(path: &Path) -> bool
and use it in the closure passed to find: on Unix (cfg(unix)) check
metadata().permissions().mode() & 0o111 != 0 via
std::os::unix::fs::PermissionsExt, and on Windows (cfg(windows)) treat files
with executable extensions (e.g. .exe, .bat, .cmd) as executable (or call
appropriate WinAPI/MetadataExt if preferred). Ensure the new predicate is used
where exe is selected so only usable binaries are returned.
In `@crates/aube-runtime/src/shasums.rs`:
- Around line 70-76: The cached SHASUMS read path uses Shasums::parse and
returns immediately even when the parsed result contains no valid entries;
change the logic after calling Shasums::parse to validate the parsed object has
at least one entry (e.g. check a entries/len/is_empty accessor) and only return
Ok(parsed) if non-empty; otherwise treat it as a cache miss (optionally delete
the corrupted cache_path) so the code falls through to the network fetch logic
that already rejects empty responses. Ensure the same validation is applied to
the cached-path branch and mirrors the non-empty check used around the network
handling (the code near the existing network-response rejection).
In `@crates/aube-settings/settings.toml`:
- Around line 2000-2010: Update the docs for managePackageManagerVersions to
clarify that self-version switching of the aube binary is performed by aube
itself (mise/installer logic) and that runtimeInstaller refers only to the Node
runtime installer policy; adjust the paragraph mentioning runtimeInstaller so it
does not imply ownership of downloading pinned aube binaries, and explicitly
state how packageManager (pins like `aube@…`), packageManagerStrict and
packageManagerStrictVersion behave with respect to validation vs switching and
who performs the download/re-exec.
In `@crates/aube/src/commands/install/mod.rs`:
- Around line 397-415: Root-level preinstall hooks are run before the project's
Node runtime is resolved; move the runtime resolution so lifecycle scripts run
with the correct switched runtime. Relocate the crate::runtime::ensure(...) call
(passing &cwd, Some(&manifest), RuntimeSettings::from_ctx(&settings_ctx), and
lockfile_pre_parse...runtimes.get("node")) and the subsequent
super::configure_script_settings(&settings_ctx) so they execute before the
run_root_lifecycle(...) invocation, ensuring the runtime is resolved and script
settings are updated prior to running root lifecycle hooks.
In `@crates/aube/src/commands/runtime.rs`:
- Around line 175-196: The current branch uses
NodeRuntime::new(cfg).resolve(&request, None, ...) which still applies
PATH-first and reuse logic; when installer is aube (cfg.installer ==
aube_runtime::InstallerMode::Aube) you must force aube-managed install instead
of letting resolve pick an ambient/node reuse. Change the call so it bypasses
PATH/reuse — e.g., set the RuntimeConfig or request flags that disable reuse (a
"force" or allow_reuse = false / prefer_download = true option) or call the API
variant that mandates download/install rather than standard resolve; update the
construction around aube_runtime::RuntimeConfig, aube_runtime::NodeRequest, and
the NodeRuntime::resolve invocation to use that force-install path when
InstallerMode::Aube is selected.
In `@crates/aube/src/runtime.rs`:
- Around line 92-93: The process-global RUNTIME can be initialized without a
lockfile pin by runtime::ensure_for_cwd(...) which wins over a later
install::run that wants to call runtime::ensure(..., lock_pin=Some(...)); fix by
changing call order in callers (crate::commands::run.rs and
crate::commands::exec.rs) to invoke ensure_installed(no_install).await? (which
may run install::run and thus call runtime::ensure with a lockfile pin) before
calling crate::runtime::ensure_for_cwd(&crate::dirs::cwd()?) so the
lockfile-pinned ensure runs first and initializes static RUNTIME with the pinned
version.
In `@crates/aube/src/self_version.rs`:
- Around line 64-75: The current check uses
std::env::var_os(REEXEC_GUARD_ENV).is_some() which treats any re-exec marker as
"already switched"; instead read the guard value
(std::env::var_os(REEXEC_GUARD_ENV)), compare it against the target version/pin
(use pin.raw or the exact version string produced by reexec()), and only skip
switching when they are equal; if the values differ, clear/unset the guard or
proceed to reexec; apply the same fix to the other occurrence referenced (around
lines 222-224) so REEXEC_GUARD_ENV/AUBE_SELF_SWITCHED is scoped to the requested
version rather than presence alone.
- Around line 77-109: When resolving range pins in this block (match on
pin.spec) avoid calling aube_runtime::latest_aube_version() before honoring the
pin.on_fail policy: if no installed version matches (result of
aube_runtime::list_installed_aube() filtering), check pin.on_fail and perform
the fallback (warn/ignore/error) immediately for non-download modes instead of
fetching from the network; only call aube_runtime::latest_aube_version(2).await
when pin.on_fail indicates a download/remote resolution path. Update the logic
around pin, pin.spec, best_installed, aube_runtime::latest_aube_version, and the
self_pin_unsatisfied returns so that on_fail is consulted first and triggers the
correct self_pin_unsatisfied behavior or fallback before any remote network
call.
In `@README.md`:
- Line 261: The README has inconsistent pnpm compatibility claims: update either
the lockfile table (the `pnpm-lock.yaml v9` entry) to indicate support for pnpm
11 lockfiles or narrow the sentence that claims "aube also matches pnpm 11's
runtime surface" so it only refers to the runtime surface (e.g., "matches pnpm
11's runtime surface but uses pnpm-lock.yaml v9") to avoid conflict; locate the
sentence that begins "aube also matches pnpm 11's runtime surface" and the
lockfile table entry `pnpm-lock.yaml v9` and make them consistent.
---
Outside diff comments:
In @.rules:
- Around line 26-38: The crate inventory block labeled "crates[12]{name,role}"
is missing the aube-util entry (Cargo.toml still references aube-util), so
either add an entry for "aube-util" with its short role description into that
list or update the cardinality to "crates[13]" and append the new line; locate
the inventory block (the header "crates[12]{name,role}" and the following crate
lines) and insert a concise "aube-util,..." line describing its role or
increment the index and include the aube-util entry to keep the workspace
inventory consistent with Cargo.toml.
In `@crates/aube-scripts/src/lib.rs`:
- Around line 887-920: The node execpath env vars (npm_node_execpath and NODE)
are set on cmd before apply_jail_env(), but apply_jail_env() calls env_clear()
which removes them for jailed scripts; update the code so the node execpath is
preserved for jailed scripts by moving the logic that sets
npm_node_execpath/NODE to after apply_jail_env() is invoked (or alternatively,
modify apply_jail_env() to re-add these vars when settings.node_exe is present).
Specifically adjust the block that references settings.node_exe and node_exe so
it runs after apply_jail_env(&mut cmd, ...), or ensure apply_jail_env preserves
or re-inserts those env vars when jail is Some.
In `@crates/aube/src/commands/dlx.rs`:
- Around line 149-167: The local-bin fast path can return before runtime
resolution runs, so move the call to
crate::runtime::ensure_for_cwd(&crate::dirs::cwd()?).await? to occur before the
early return branch: call ensure_for_cwd right after computing initial_cwd (or
at the top of the block guarded by !explicit_package && !shell_mode &&
can_use_local_bin(&command)) and before checking project root / bin_path and
before invoking super::exec::exec_bin, so that the runtime is resolved for the
user's project (respecting .nvmrc/devEngines) regardless of whether exec_bin
returns early.
In `@crates/aube/src/commands/exec.rs`:
- Around line 296-315: The non-status exec branch that builds the
tokio::process::Command (created via resolve_exec_shim) currently skips the
runtime PATH injection and environment adjustments; update that branch so it
mirrors the status/parallel path by computing bin_dir =
super::project_modules_dir(cwd).join(".bin"), combining it with
crate::runtime::path_entries(), building new_path via
aube_scripts::prepend_paths(&path_dirs), setting cmd.env("PATH", &new_path), and
then calling the same apply_child_env(command) (or the appropriate function used
elsewhere to inject runtime env) on the built Command before returning; keep the
existing cmd.args(args) and ensure you reference resolve_exec_shim,
project_modules_dir, crate::runtime::path_entries, aube_scripts::prepend_paths,
and apply_child_env to locate where to apply these changes.
In `@crates/aube/src/commands/install/fetch.rs`:
- Around line 91-103: The spawn error message for the Command created via
tokio::process::Command::new(crate::runtime::node_program()) is misleading
because it hardcodes "from PATH"; update the miette! error construction in the
.map_err closure to be runtime-source agnostic by either removing "from PATH" or
including the resolved program path (crate::runtime::node_program() or a local
variable holding it) in the message so it accurately reflects whether Node was
taken from PATH or a pinned runtime; adjust the miette! invocation that
references local.specifier() accordingly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: f4f34e8a-c95c-4802-956d-69d1f6f2d8d5
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (73)
.rulesCargo.tomlREADME.mdaube.usage.kdlcrates/aube-codes/src/errors.rscrates/aube-codes/src/warnings.rscrates/aube-lockfile/src/drift.rscrates/aube-lockfile/src/lib.rscrates/aube-lockfile/src/merge.rscrates/aube-lockfile/src/pnpm/format.rscrates/aube-lockfile/src/pnpm/raw.rscrates/aube-lockfile/src/pnpm/read.rscrates/aube-lockfile/src/pnpm/tests.rscrates/aube-lockfile/src/pnpm/write.rscrates/aube-lockfile/src/yarn/tests.rscrates/aube-manifest/src/dev_engines.rscrates/aube-manifest/src/lib.rscrates/aube-manifest/src/workspace/config.rscrates/aube-resolver/src/peer_context.rscrates/aube-resolver/src/resolve.rscrates/aube-resolver/src/tests.rscrates/aube-runtime/Cargo.tomlcrates/aube-runtime/src/discover.rscrates/aube-runtime/src/error.rscrates/aube-runtime/src/extract.rscrates/aube-runtime/src/http.rscrates/aube-runtime/src/index.rscrates/aube-runtime/src/installer.rscrates/aube-runtime/src/lib.rscrates/aube-runtime/src/mise.rscrates/aube-runtime/src/paths.rscrates/aube-runtime/src/platform.rscrates/aube-runtime/src/progress.rscrates/aube-runtime/src/resolver.rscrates/aube-runtime/src/self_install.rscrates/aube-runtime/src/shasums.rscrates/aube-runtime/src/sources.rscrates/aube-runtime/src/spec.rscrates/aube-scripts/src/lib.rscrates/aube-settings/settings.tomlcrates/aube/Cargo.tomlcrates/aube/src/commands/dlx.rscrates/aube/src/commands/doctor.rscrates/aube/src/commands/exec.rscrates/aube/src/commands/install/fetch.rscrates/aube/src/commands/install/mod.rscrates/aube/src/commands/install/resolve.rscrates/aube/src/commands/mod.rscrates/aube/src/commands/run.rscrates/aube/src/commands/runtime.rscrates/aube/src/commands/script_settings.rscrates/aube/src/commands/security_scanner.rscrates/aube/src/engines.rscrates/aube/src/main.rscrates/aube/src/pnpmfile.rscrates/aube/src/runtime.rscrates/aube/src/self_version.rscrates/aube/src/startup.rsdocs/.vitepress/config.mtsdocs/cli/commands.jsondocs/cli/index.mddocs/cli/runtime.mddocs/cli/runtime/list.mddocs/cli/runtime/set.mddocs/error-codes.data.jsondocs/package-manager/lockfiles.mddocs/package-manager/node-runtime.mddocs/pnpm-users.mddocs/settings/index.mdtest/guardrails.batstest/runtime.batstest/runtime_download.batstest/self_version.bats
| 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 | ||
| )); | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Runtime merge conflict detection is too narrow.
Line 274 only compares version. If two branch lockfiles keep the same version but differ in specifier, dev, has_bin, or variants, one side is silently ignored with no conflict report.
Suggested fix
- Entry::Occupied(slot) => {
- if slot.get().version != incoming.version {
+ Entry::Occupied(slot) => {
+ if slot.get().specifier != incoming.specifier
+ || slot.get().version != incoming.version
+ || slot.get().dev != incoming.dev
+ || slot.get().has_bin != incoming.has_bin
+ || slot.get().variants != incoming.variants
+ {
report.conflicts.push(format!(
"runtime `{}`: kept {} over {}",
slot.key(),
slot.get().version,
incoming.version
));
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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 | |
| )); | |
| } | |
| } | |
| } | |
| } | |
| 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().specifier != incoming.specifier | |
| || slot.get().version != incoming.version | |
| || slot.get().dev != incoming.dev | |
| || slot.get().has_bin != incoming.has_bin | |
| || slot.get().variants != incoming.variants | |
| { | |
| report.conflicts.push(format!( | |
| "runtime `{}`: kept {} over {}", | |
| slot.key(), | |
| slot.get().version, | |
| incoming.version | |
| )); | |
| } | |
| } | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/aube-lockfile/src/merge.rs` around lines 267 - 284, The runtime merge
loop in merge.rs (the for (name, incoming) in src.runtimes and Entry::Occupied
branch) only checks slot.get().version against incoming.version and therefore
misses conflicts in other fields; update the Occupied branch to compare all
relevant Runtime fields (specifier, dev, has_bin, variants, and version) between
slot.get() and incoming, and if any of those differ push a clear conflict
message (e.g., "runtime `{}`: kept <existing> over <incoming> (diffs: ...)" or
list which fields differ) while still retaining the existing entry; ensure
equality across all fields avoids reporting a conflict.
| pub(crate) async fn load_index(http: &Http, cfg: &RuntimeConfig) -> Result<Vec<IndexEntry>, Error> { | ||
| let base = cfg.mirror_base(); | ||
| let cache_path = paths::index_cache_path(&base); | ||
| let cached: Option<CachedIndex> = 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<IndexEntry> = | ||
| 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) |
There was a problem hiding this comment.
Runtime cache/install paths share the same blocking-I/O-on-async root cause.
load_index() in crates/aube-runtime/src/index.rs and install() / download_verify_extract() in crates/aube-runtime/src/installer.rs all use std::fs on async paths. The common fix is to move the runtime cache/setup/publish filesystem work behind tokio::fs or a few dedicated spawn_blocking helpers so runtime switching does not pin Tokio workers.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/aube-runtime/src/index.rs` around lines 86 - 173, The code in
load_index (and similarly install / download_verify_extract) performs blocking
std::fs I/O on async paths (e.g., std::fs::read, std::fs::create_dir_all,
aube_util::fs_atomic::atomic_write) which can block Tokio workers; change those
to async-aware operations by either using tokio::fs equivalents or moving the
blocking sections into tokio::task::spawn_blocking closures that return the same
results, e.g., wrap the initial cache read/deserialization, the cache
write/parent-dir creation, and any other std::fs usages in spawn_blocking (or
replace with tokio::fs::read/to_vec/create_dir_all and an async atomic-write
helper) so load_index, install, and download_verify_extract no longer perform
blocking filesystem calls on the async runtime.
Source: Coding guidelines
| // 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"))?; |
There was a problem hiding this comment.
Don't use the normal PATH-first resolver for the aube-managed global install path.
This branch promises an aube-managed global install, but NodeRuntime::resolve(...) follows the normal precedence from the PR: ambient PATH and reused installs win before download. So aube runtime set -g can "succeed" against /usr/bin/node or an existing mise install and never materialize anything under aube's runtime directory.
Use a force-install path here, or bypass PATH/mise reuse when runtimeInstaller=aube is selected.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/aube/src/commands/runtime.rs` around lines 175 - 196, The current
branch uses NodeRuntime::new(cfg).resolve(&request, None, ...) which still
applies PATH-first and reuse logic; when installer is aube (cfg.installer ==
aube_runtime::InstallerMode::Aube) you must force aube-managed install instead
of letting resolve pick an ambient/node reuse. Change the call so it bypasses
PATH/reuse — e.g., set the RuntimeConfig or request flags that disable reuse (a
"force" or allow_reuse = false / prefer_download = true option) or call the API
variant that mandates download/install rather than standard resolve; update the
construction around aube_runtime::RuntimeConfig, aube_runtime::NodeRequest, and
the NodeRuntime::resolve invocation to use that force-install path when
InstallerMode::Aube is selected.
| // Resolve the pin to an exact version: exact pins directly, ranges | ||
| // against installed versions first, then the latest published | ||
| // release. | ||
| let target = match &pin.spec { | ||
| aube_runtime::NodeSpec::Exact(v) => v.clone(), | ||
| spec => { | ||
| let best_installed = aube_runtime::list_installed_aube() | ||
| .into_iter() | ||
| .filter(|i| spec.satisfied_by(&i.version) == Some(true)) | ||
| .map(|i| i.version) | ||
| .max(); | ||
| match best_installed { | ||
| Some(v) => v, | ||
| None => match aube_runtime::latest_aube_version(2).await { | ||
| Ok(latest) if spec.satisfied_by(&latest) == Some(true) => latest, | ||
| Ok(latest) => { | ||
| return self_pin_unsatisfied( | ||
| &pin, | ||
| format!( | ||
| "no installed aube satisfies {} and the newest release is {latest}", | ||
| pin.raw | ||
| ), | ||
| ); | ||
| } | ||
| Err(e) => { | ||
| return self_pin_unsatisfied( | ||
| &pin, | ||
| format!("could not resolve {}: {e}", pin.raw), | ||
| ); | ||
| } | ||
| }, | ||
| } | ||
| } |
There was a problem hiding this comment.
Honor onFail before resolving range pins over the network.
For devEngines.packageManager ranges, this branch calls latest_aube_version() whenever no installed version matches, and only consults pin.on_fail afterward. In warn / ignore / error modes that means an offline machine, or a range that no longer includes the newest release, turns into a hard failure before the configured fallback behavior is reached. Only the download path should need remote target resolution.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/aube/src/self_version.rs` around lines 77 - 109, When resolving range
pins in this block (match on pin.spec) avoid calling
aube_runtime::latest_aube_version() before honoring the pin.on_fail policy: if
no installed version matches (result of aube_runtime::list_installed_aube()
filtering), check pin.on_fail and perform the fallback (warn/ignore/error)
immediately for non-download modes instead of fetching from the network; only
call aube_runtime::latest_aube_version(2).await when pin.on_fail indicates a
download/remote resolution path. Update the logic around pin, pin.spec,
best_installed, aube_runtime::latest_aube_version, and the self_pin_unsatisfied
returns so that on_fail is consulted first and triggers the correct
self_pin_unsatisfied behavior or fallback before any remote network call.
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (5)
crates/aube-runtime/src/self_install.rs (2)
58-61:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winEmpty
AUBE_SELF_DIRvalue still accepted.The past review comment regarding empty
AUBE_SELF_DIRvalues remains unaddressed. Line 59 accepts an emptyOsString, which resolves to the current working directory and could misdirect self-install artifacts.Proposed fix
pub fn self_dir() -> Option<PathBuf> { if let Some(dir) = std::env::var_os("AUBE_SELF_DIR") { - return Some(PathBuf::from(dir)); + if !dir.is_empty() { + return Some(PathBuf::from(dir)); + } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/aube-runtime/src/self_install.rs` around lines 58 - 61, The current self_dir() accepts an empty AUBE_SELF_DIR value which std::env::var_os returns as Some("") and then PathBuf::from("") resolves to the current working directory; modify self_dir() to treat empty or all-whitespace AUBE_SELF_DIR as unset by checking the OsString value (e.g., convert to string lossily or check is_empty on OsStr) before returning Some(PathBuf), and only return Some(PathBuf::from(dir)) when the variable is non-empty; otherwise continue to fall through to the non-env resolution path.
143-145:⚠️ Potential issue | 🟠 Major | ⚡ Quick winExecutable usability check still missing.
The past review comment about validating executable permissions remains unaddressed. A non-executable file named
aubepasses theis_file()check and will fail at re-exec time.Proposed fix
let exe = [dir.join(exe_name), dir.join("bin").join(exe_name)] .into_iter() - .find(|p| p.is_file())?; + .find(|p| is_usable_executable(p))?; Some(InstalledAube { // ... }) } + +fn is_usable_executable(path: &Path) -> bool { + if !path.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Ok(meta) = std::fs::metadata(path) { + return (meta.permissions().mode() & 0o111) != 0; + } + return false; + } + #[cfg(not(unix))] + { + true + } +}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/aube-runtime/src/self_install.rs` around lines 143 - 145, The current finder lets non-executable files pass because it only checks is_file(); create and use an is_executable(path: &Path) helper and change the find closure used when computing exe to require both is_file() and is_executable(). Implement is_executable to, on Unix (use std::os::unix::fs::PermissionsExt), check metadata()?.permissions().mode() & 0o111 != 0, and on Windows check common executable extensions (e.g., "exe","bat","cmd") or use Windows-specific metadata if preferred; update the code that computes exe (the let exe = ... .find(|p| ... )?) to call this helper so only truly executable candidates are selected.crates/aube/src/self_version.rs (2)
67-75:⚠️ Potential issue | 🟠 Major | ⚡ Quick winRe-exec guard still checks presence only, not version match.
The past review concern remains: the guard at line 67 checks
is_some()rather than comparing the guard value against the target version. A nestedaubeinvocation from a different project (pinning a different version) will skip switching becauseAUBE_SELF_SWITCHEDis set, even though it contains the wrong version.Proposed fix
- if std::env::var_os(REEXEC_GUARD_ENV).is_some() { + let already_switched = std::env::var(REEXEC_GUARD_ENV).ok(); + // Only skip if we already switched TO THIS EXACT version + if already_switched.as_deref() == Some(&target.to_string()) { + return Ok(()); + } + // If we switched to a DIFFERENT version and still don't satisfy, warn + if already_switched.is_some() { tracing::warn!( code = aube_codes::warnings::WARN_AUBE_RUNTIME_VERSION_MISMATCH, requested = pin.raw, running = running_version(), "switched aube binary still does not satisfy the project's pin; continuing" ); return Ok(()); }Note: This requires moving the guard check after target resolution, or storing the requested pin rather than the resolved version in the guard.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/aube/src/self_version.rs` around lines 67 - 75, The re-exec guard currently only checks presence via std::env::var_os(REEXEC_GUARD_ENV).is_some() and should instead compare the guard's value to the intended target version; change the logic so after you've resolved the target pin (use pin.raw or the resolved identifier you choose) you read std::env::var(REEXEC_GUARD_ENV) and only early-return if the env value equals that target string, otherwise proceed with switching; alternatively, set REEXEC_GUARD_ENV to the requested pin (pin.raw) when spawning the nested aube so the existing presence check remains valid, and update references around REEXEC_GUARD_ENV, pin.raw, and running_version() accordingly.
88-118:⚠️ Potential issue | 🟠 Major | ⚡ Quick winNetwork call precedes
onFailcheck for range pins.The past review concern persists: when no installed version satisfies a range pin,
available_aube_versions(2).awaitis called beforeonFailis consulted. If the network call fails (lines 112-117),self_pin_unsatisfiedreturns an error even whenonFailiswarnorignore.Proposed fix
match best_installed { Some(v) => v, - None => match aube_runtime::available_aube_versions(2).await { + None => { + // Check onFail before network call for non-download modes + match pin.on_fail { + aube_manifest::OnFail::Ignore => return Ok(()), + aube_manifest::OnFail::Warn => { + tracing::warn!( + code = aube_codes::warnings::WARN_AUBE_RUNTIME_VERSION_MISMATCH, + requested = pin.raw, + running = running_version(), + source = pin.source, + "no installed aube satisfies {} (onFail: warn); continuing", + pin.raw + ); + return Ok(()); + } + aube_manifest::OnFail::Error => { + return self_pin_unsatisfied( + &pin, + format!("no installed aube satisfies {} and onFail is \"error\"", pin.raw), + ); + } + aube_manifest::OnFail::Download => {} + } + match aube_runtime::available_aube_versions(2).await { // ... rest of the match + } + } }crates/aube/src/runtime.rs (1)
92-93:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftLockfile pin bypass via
OnceCellinitialization order persists.The past review concern about the process-global
RUNTIMEinitialization order remains. Whenrun/execcommands callensure_for_cwd(lock_pin=None)before auto-install triggersinstall::run(..., lock_pin=Some(...)), theOnceCellfirst-write semantics cause the lockfile pin to be silently ignored.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/aube/src/runtime.rs` around lines 92 - 93, The process-global static RUNTIME (tokio::sync::OnceCell<RuntimeContext>) allows a first-write to silently fix the lock pin, so when run/exec call ensure_for_cwd(lock_pin=None) before auto-install calls install::run(..., lock_pin=Some(...)) the real pin is ignored; change initialization so the RuntimeContext creation path honors a later-provided lock_pin instead of being permanently set by the first writer: update the code paths that set RUNTIME (the OnceCell initializer and the function that constructs RuntimeContext) to accept and propagate an optional lock_pin, and replace the current blind OnceCell::set usage with a compare-and-update or lazy initializer that merges or prefers non-None lock_pin (e.g., try_insert/update the existing RuntimeContext to inject the lock_pin when a prior empty/None pin exists) so ensure_for_cwd, install::run, and RuntimeContext all consistently observe the correct lock_pin.
🧹 Nitpick comments (2)
crates/aube-lockfile/src/pnpm/tests.rs (1)
3317-3389: ⚖️ Poor tradeoffExtend merge test coverage to multi-target variants and overlapping targets.
The test validates the basic union path (non-overlapping, single-target variants), but doesn't cover:
- Multi-target variants: A variant with
targets: [darwin/arm64, darwin/x64]to verify that partial target overlap is handled correctly.- Overlapping targets: Both branches having a variant for the same target (e.g.,
darwin/arm64) to confirm "dst wins" behavior and conflict reporting.- Differing
dev/has_binflags: Variants with the same version but different metadata to verify conflict detection.Adding these cases would catch the
.any()bug identified inmerge.rsline 298 and validate the conflict-reporting logic.💡 Suggested test outline
#[test] fn runtime_pin_merge_handles_multi_target_variants() { // dst: variant with [darwin/arm64] // incoming: variant with [darwin/arm64, darwin/x64] // Expected: both variants present (or merged intelligently), // no data loss of darwin/x64 } #[test] fn runtime_pin_merge_reports_conflict_on_target_collision() { // dst: variant for darwin/arm64 with URL=U1 // incoming: variant for darwin/arm64 with URL=U2 // Expected: keep dst, report conflict }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/aube-lockfile/src/pnpm/tests.rs` around lines 3317 - 3389, Add two focused tests next to runtime_pin_merge_unions_variants to cover multi-target and overlapping-target cases: (1) a test named runtime_pin_merge_handles_multi_target_variants that creates a dst RuntimePin with a RuntimeVariant containing targets [darwin/arm64] and an incoming RuntimePin whose RuntimeVariant contains targets [darwin/arm64, darwin/x64], then runs merge_branch_lockfiles and asserts the merged LockfileGraph retains darwin/x64 (no silent drop); (2) a test named runtime_pin_merge_reports_conflict_on_target_collision that creates dst and incoming RuntimePins both targeting darwin/arm64 but with differing archive/url/integrity or differing dev/has_bin flags, then runs merge_branch_lockfiles and asserts the merged graph keeps dst’s variant while the returned report contains a conflict entry for the runtime pin; reference the RuntimePin, RuntimeVariant, RuntimeTarget types and use parse/write/merge_branch_lockfiles as in the existing test to exercise the code path around the .any() logic in merge.rs so overlapping-target logic and dev/has_bin flag disagreements are asserted.crates/aube-lockfile/src/merge.rs (1)
273-282: ⚡ Quick winConsider reporting conflicts when
devorhas_bindiffer.When versions match but
devorhas_binflags differ between branches, the merge silently keeps dst's values. For consistency with the specifier conflict reporting (lines 288–295), consider logging a conflict when these fields differ, even though dst wins.📋 Suggested addition
After line 282, before the specifier check:
continue; } + if slot.get().dev != incoming.dev { + report.conflicts.push(format!( + "runtime `{}` dev flag: kept {} over {}", + slot.key(), + slot.get().dev, + incoming.dev + )); + } + if slot.get().has_bin != incoming.has_bin { + report.conflicts.push(format!( + "runtime `{}` has_bin: kept {} over {}", + slot.key(), + slot.get().has_bin, + incoming.has_bin + )); + } // Same resolved version: a silently-kept dst would🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/aube-lockfile/src/merge.rs` around lines 273 - 282, When merging runtime entries in Entry::Occupied, detect and report conflicts if slot.get().dev != incoming.dev or slot.get().has_bin != incoming.has_bin even when versions match; update the block handling the occupied slot (where slot.get() and incoming are compared and report.conflicts is pushed for version mismatches) to also push descriptive conflict messages for dev and has_bin differences (similar style to the existing specifier conflict reporting) before continuing to the specifier check so the conflict log records these differing flags while still keeping dst's values.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/aube-lockfile/src/merge.rs`:
- Around line 298-306: The variant-union check misuses .any(), causing an
incoming Variant (variable name variant) to be skipped if any one of its targets
is present in dst_pin.variants; change the predicate so we only skip the
incoming variant when all of its targets are already present in dst_pin (i.e.,
replace the .any() check with an .all() style check over variant.targets against
dst_pin.variants/dv.targets), so dst_pin.variants.push(variant) only happens
when at least one target in variant is uncovered.
---
Duplicate comments:
In `@crates/aube-runtime/src/self_install.rs`:
- Around line 58-61: The current self_dir() accepts an empty AUBE_SELF_DIR value
which std::env::var_os returns as Some("") and then PathBuf::from("") resolves
to the current working directory; modify self_dir() to treat empty or
all-whitespace AUBE_SELF_DIR as unset by checking the OsString value (e.g.,
convert to string lossily or check is_empty on OsStr) before returning
Some(PathBuf), and only return Some(PathBuf::from(dir)) when the variable is
non-empty; otherwise continue to fall through to the non-env resolution path.
- Around line 143-145: The current finder lets non-executable files pass because
it only checks is_file(); create and use an is_executable(path: &Path) helper
and change the find closure used when computing exe to require both is_file()
and is_executable(). Implement is_executable to, on Unix (use
std::os::unix::fs::PermissionsExt), check metadata()?.permissions().mode() &
0o111 != 0, and on Windows check common executable extensions (e.g.,
"exe","bat","cmd") or use Windows-specific metadata if preferred; update the
code that computes exe (the let exe = ... .find(|p| ... )?) to call this helper
so only truly executable candidates are selected.
In `@crates/aube/src/runtime.rs`:
- Around line 92-93: The process-global static RUNTIME
(tokio::sync::OnceCell<RuntimeContext>) allows a first-write to silently fix the
lock pin, so when run/exec call ensure_for_cwd(lock_pin=None) before
auto-install calls install::run(..., lock_pin=Some(...)) the real pin is
ignored; change initialization so the RuntimeContext creation path honors a
later-provided lock_pin instead of being permanently set by the first writer:
update the code paths that set RUNTIME (the OnceCell initializer and the
function that constructs RuntimeContext) to accept and propagate an optional
lock_pin, and replace the current blind OnceCell::set usage with a
compare-and-update or lazy initializer that merges or prefers non-None lock_pin
(e.g., try_insert/update the existing RuntimeContext to inject the lock_pin when
a prior empty/None pin exists) so ensure_for_cwd, install::run, and
RuntimeContext all consistently observe the correct lock_pin.
In `@crates/aube/src/self_version.rs`:
- Around line 67-75: The re-exec guard currently only checks presence via
std::env::var_os(REEXEC_GUARD_ENV).is_some() and should instead compare the
guard's value to the intended target version; change the logic so after you've
resolved the target pin (use pin.raw or the resolved identifier you choose) you
read std::env::var(REEXEC_GUARD_ENV) and only early-return if the env value
equals that target string, otherwise proceed with switching; alternatively, set
REEXEC_GUARD_ENV to the requested pin (pin.raw) when spawning the nested aube so
the existing presence check remains valid, and update references around
REEXEC_GUARD_ENV, pin.raw, and running_version() accordingly.
---
Nitpick comments:
In `@crates/aube-lockfile/src/merge.rs`:
- Around line 273-282: When merging runtime entries in Entry::Occupied, detect
and report conflicts if slot.get().dev != incoming.dev or slot.get().has_bin !=
incoming.has_bin even when versions match; update the block handling the
occupied slot (where slot.get() and incoming are compared and report.conflicts
is pushed for version mismatches) to also push descriptive conflict messages for
dev and has_bin differences (similar style to the existing specifier conflict
reporting) before continuing to the specifier check so the conflict log records
these differing flags while still keeping dst's values.
In `@crates/aube-lockfile/src/pnpm/tests.rs`:
- Around line 3317-3389: Add two focused tests next to
runtime_pin_merge_unions_variants to cover multi-target and overlapping-target
cases: (1) a test named runtime_pin_merge_handles_multi_target_variants that
creates a dst RuntimePin with a RuntimeVariant containing targets [darwin/arm64]
and an incoming RuntimePin whose RuntimeVariant contains targets [darwin/arm64,
darwin/x64], then runs merge_branch_lockfiles and asserts the merged
LockfileGraph retains darwin/x64 (no silent drop); (2) a test named
runtime_pin_merge_reports_conflict_on_target_collision that creates dst and
incoming RuntimePins both targeting darwin/arm64 but with differing
archive/url/integrity or differing dev/has_bin flags, then runs
merge_branch_lockfiles and asserts the merged graph keeps dst’s variant while
the returned report contains a conflict entry for the runtime pin; reference the
RuntimePin, RuntimeVariant, RuntimeTarget types and use
parse/write/merge_branch_lockfiles as in the existing test to exercise the code
path around the .any() logic in merge.rs so overlapping-target logic and
dev/has_bin flag disagreements are asserted.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: dce194bf-7aed-4fe4-af57-cf0dabb485d9
📒 Files selected for processing (13)
crates/aube-lockfile/src/drift.rscrates/aube-lockfile/src/merge.rscrates/aube-lockfile/src/pnpm/tests.rscrates/aube-lockfile/src/pnpm/write.rscrates/aube-runtime/src/http.rscrates/aube-runtime/src/installer.rscrates/aube-runtime/src/lib.rscrates/aube-runtime/src/progress.rscrates/aube-runtime/src/resolver.rscrates/aube-runtime/src/self_install.rscrates/aube/src/runtime.rscrates/aube/src/self_version.rstest/runtime_download.bats
🚧 Files skipped from review as they are similar to previous changes (8)
- crates/aube-runtime/src/progress.rs
- crates/aube-runtime/src/http.rs
- crates/aube-runtime/src/lib.rs
- crates/aube-runtime/src/installer.rs
- crates/aube-lockfile/src/drift.rs
- test/runtime_download.bats
- crates/aube-lockfile/src/pnpm/write.rs
- crates/aube-runtime/src/resolver.rs
mise draws its own download UI: delegation now inherits stderr (any live aube progress renderer is paused around the child via new on_external_tool hooks) while stdout stays piped into tracing so commands whose stdout is a contract (aubx tool | jq) never see mise output. Self-downloads get a real clx progress bar — spinner, byte counts, verifying/extracting phase labels — degrading to plain lines in text mode or off-terminal, and aube self-version downloads share the same reporter. Also fixes the CI lint failures: needless_match in the linux-only detect_musl (never compiled by local macOS clippy) and shellcheck SC2034 on the mirror-wait loop variable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Structural (cursor medium / coderabbit major): - the lockfile runtime pin is now read on every resolution path: ensure_for_cwd probes the lockfile itself (substring-gated parse, cheap on unpinned hot paths), so `aubr` resolves the same exact version `aube install` pinned instead of best-in-range — and the install pipeline resolves the runtime *before* root preinstall hooks, which previously ran on the ambient node - `--offline` now blocks runtime downloads like registry fetches (caches still serve); the install's network mode threads into runtime resolution - the self-version re-exec guard is scoped to the resolved target version, so a nested aube invocation in another project can still switch; range pins honor onFail before failing on an unreachable version list - `aube runtime set -g` reports what actually happened (already on PATH / already installed via mise / freshly installed) instead of claiming an install Smaller (coderabbit): - branch-lockfile variant union keeps multi-target variants whose targets are only partially covered (.any → .all) - runtime store I/O errors get their own ERR_AUBE_RUNTIME_IO (generic exit) instead of masquerading as download failures - `lts: true` from a mirror counts as LTS; corrupted SHASUMS cache entries refetch instead of failing until manually deleted - discovery requires the exec bit on unix; empty AUBE_RUNTIME_DIR / AUBE_SELF_DIR env values no longer resolve to the working dir - docs: /settings links fixed (vitepress dead-link CI failure), runtimeInstaller documents that it also governs aube self-installs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…val jdx#858/jdx#860 Merge (not rebase) of jdx/aube main @ ab844b5 into nub-integration. Resolved 9 conflicts preserving nub's embedder behavior: - errors.rs / error-codes.data.json: kept nub's lockfile codes (OUTDATED/DECLARATION_MISMATCH/AMBIGUOUS, exit 13/14/15) AND took upstream's ERR_AUBE_RESOLUTION_SHAPE_MISMATCH, reassigning its exit code 13->16 to avoid colliding with nub's OUTDATED_LOCKFILE. - aube-scripts/lib.rs: ScriptSettings carries BOTH nub's env_overlay/path_prepends embedder overlay AND upstream's node_bin_dir/node_exe; run_script composes both PATH layers. - script_settings.rs: carry-forward embedder overlay + upstream runtime. - lifecycle.rs / default_trust.rs: adopt jdx#860 decide_package(source_key) AND keep nub's defaultTrust floor (Unspecified => floor.trusts) arm; decide_with_floor now threads the source key internally. - install/mod.rs + resolve.rs: kept nub's default_lockfile_kind seam, added upstream's refresh_lockfile_pin (inert under nub). - startup.rs: kept nub's configurable PackageManagerNames policy, folded in upstream's managePackageManagerVersions self-switch guard. - main.rs stays nub's thin lib-wrapper; ported jdx#861 CLI surface (Runtime subcommand, self_version::maybe_switch) into lib.rs; added mod runtime / mod self_version to lib.rs. jdx#861 dormancy gate (the one aube behavior change, default==upstream): runtime::set_runtime_switching_enabled(bool) OnceLock, default TRUE. ensure / ensure_for_cwd / refresh_lockfile_pin short-circuit to path_fallback when false, before any .nvmrc/.node-version/devEngines read. Re-exported from lib.rs; nub flips it false so aube's runtime resolver stays compiled-but-inert and nub keeps owning Node. Tests: aube cargo test 1965 passed / 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

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. mise users keep a single store on disk: installed versions under~/.local/share/mise/installs/are reused read-only, and missing installs are delegated tomise installby default.Node runtime switching
devEngines.runtime(OpenJS spec, pnpm 10.14+/11 compatible) >.node-version>.nvmrc(searched upward, stopping at$HOME). Requests: exact versions, ranges,lts,latest, codenames (lts/jod).incompletemarkers and alias symlinks — plus aube's own~/.local/share/aube/nodejs/) → download per the newruntimeInstallersetting (auto|mise|aube, defaultauto= delegate tomise install node@<exact>when mise is on PATH, falling back to a SHASUMS256-verified nodejs.org download;nodeDownloadMirrors.releaseis now honored).aube run/aubr,aube exec(incl.#!/usr/bin/env nodeshebangs),aubx/dlx(resolved from the original cwd, not the scratch dir), root + dep lifecycle scripts, and the build jail.npm_node_execpath/NODEexported npm-style. Projects with no pin behave exactly as before — not even anode --versionprobe.node@runtime:shape (importer dep +variationsresolution with per-platform URLs andsha256-integrity), verified against pnpm's own types. Parsing these entries doubles as a compat fix — aube previously misread pnpm-11 lockfiles carrying them as registry deps. npm/yarn/bun formats skip the pin (warned once per install).engines.node/engineStrictnow validate against the switched node; thenodeVersionsetting stays validation-only (pnpm semantics).runtimeOnFail(download|error|warn|ignore) is the air-gapped-CI override; version files default todownload, bare devEngines to the spec'serror.aube runtime set node <version>(pnpm 11 parity — writes devEngines, installs, records the pin;-gdelegates tomise use -g) andaube runtime list;aube doctorgainsnode-source/node-requested/node-provenance/node-binrows.aube self-version switching (corepack semantics)
managePackageManagerVersions— previously a documented no-op stub — now does what it says (default on, pnpm 10 parity): when the running aube doesn't satisfydevEngines.packageManager(ranges) orpackageManager: "aube@<exact>", aube locates the pinned version (mise installs reused; GitHub release archives downloaded into~/.local/share/aube/self/otherwise) and re-execs it with the same argv, preserving theaube/aubr/aubxmulticall name. Runs before dispatch, soaube installand chained auto-installs all execute under the pinned binary. A guard env degrades a broken install to a warning — never an exec loop.packageManagerStrictVersionhard-error is superseded while switching is on;managePackageManagerVersions=falserestores validation-only behavior.assets[].digest, tamper-evident under immutable releases) — covering every release that already exists, with no checksum publishing needed in the release workflow. Release metadata (and the full version list for range pins) comes from mise-versions.jdx.dev — CDN-cached, no rate limits, the same service mise consults — with the GitHub API as the fallback (honoringGITHUB_TOKEN/GH_TOKEN, attached only to the real api.github.com host), then.sha256siblings for custom mirrors, then TLS-only. Returned metadata must echo the requested tag. Everything degrades gracefully on misses rather than failing an install.Testing
cargo clippy --all-targets -- -D warningsandcargo fmt --checkclean.runtime.bats(hermetic: source precedence, mise discovery/incomplete/symlink rules, onFail matrix, pnpm-11 lockfile compat),runtime_download.bats(local static mirror: download, checksum-mismatch rejection, mise-stub delegation + fallback, pin recording, offline pin reuse), andself_version.bats(re-exec, multicall preservation, range→best-installed, loop guard, setting off).guardrails.bats'spackageManagerStrictVersion rejects mismatched aube versionnow pinsmanage-package-manager-versions=false, since rejection-on-mismatch is no longer the default — switching is.devEngines.runtimeentries no longer read as drift, branch-lockfile merges union runtime variants across platforms, and alias specs (lts/latest) consult the index under everyonFailpolicy before concluding mismatch — each with a regression test..nvmrcresolving to a mise-installed node with zero downloads, and apackageManager: "aube@1.16.1"pin re-exec'ing mise's installed 1.16.1.🤖 Generated with Claude Code
Note
High Risk
Touches every script/binary spawn path, download integrity, and optional self-re-exec; lockfile/runtime drift behavior affects frozen CI installs.
Overview
Adds built-in Node.js runtime management so projects no longer depend on mise shims for version switching: pins from
devEngines.runtime,.node-version, or.nvmrcare resolved (PATH → mise/aube installs → verified download), injected when running scripts and binaries, and optionally recorded in lockfiles using pnpm 11’snode@runtime:/variationsencoding.The new
aube-runtimecrate owns discovery, nodejs.org index/SHASUMS fetching, extraction, mise delegation (runtimeInstaller), and related settings (nodeDownloadMirrors,runtimeOnFail).aube manifestgains tolerantdevEnginesparsing;aube-lockfilelifts runtime pins out of ordinary deps (fixing mis-read of pnpm 10.14+ lockfiles), round-trips them, merges cross-platform variants, and treats manifest/lock runtime drift on existing pins only.CLI surface:
aube runtime list/runtime set node <version>(writesdevEngines, installs, updates pins). Docs and usage KDL updated; stable runtime error/warning codes added.managePackageManagerVersionsis wired to re-exec a pinned aube binary (mise reuse + GitHub/mise-versions downloads) per the broader PR scope beyond the lockfile/manifest hunks shown.Reviewed by Cursor Bugbot for commit 0075d96. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by CodeRabbit
New Features
aube runtime(list/set) to inspect, pin, and install project Node versions; per-project Node switching (devEngines.runtime, .node-version, .nvmrc) with ranges/LTS/latest support.Behavior Changes
Documentation
Tests