Skip to content
Merged
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,52 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic
- [ ] Update the `[Unreleased]` compare link to the new tag.
- [ ] Create and push a signed `vX.Y.Z` tag from `master`.

### Added

- `runner doctor` (and `info --json`) now classify PATH-probe hits that
are Volta shims and resolve them to the real provisioned binary via
`volta which`: the `PATH probe` line shows
`npm=<shim> -> <real bin> (volta)`, or
`(volta shim, not provisioned)` when Volta fronts a tool it has no
version of. JSON gains an additive `signals.node.volta_shims` map
(omitted on hosts without Volta; no schema bump). Display only —
execution still spawns the shim, which performs Volta's per-project
version selection.

### Changed

- `runner install` now honors the `--pm`/`RUNNER_PM` override: when set,
only that package manager installs (previously the override was
ignored and every detected PM installed — e.g. a project with both
`bun.lock` and `deno.json` always ran `deno install` too, writing an
unwanted `deno.lock`). An override naming a PM that detection did not
find refuses the install with exit code 2. runner.toml
`[pm].node`/`[pm].python` continue to scope script dispatch only.
- Invalid `--pm`/`RUNNER_PM`/`--runner`/`RUNNER_RUNNER` values now produce
a readable error: the message names the source that carried the value,
escapes control characters (no more raw ANSI codes), truncates long
garbage, and — when the value contains line breaks — hints that it
looks like captured command output with the correctly quoted PowerShell
spelling. (An unquoted `$env:RUNNER_PM=deno` executes deno and assigns
its REPL banner to the variable.)

### Fixed

- `runner doctor` no longer dies when a `RUNNER_*` override variable
holds an unparseable value — the condition it exists to diagnose. The
invalid value is ignored for the report and surfaced as an `env:`
warning (human output and the `warnings` array of `doctor --json`,
additively — no schema bump). Every other command, and an explicit bad
`--pm`/`--runner` flag even on doctor, still fails fast.
- Node version constraints are now evaluated with real range semantics
(via the `semver` crate) instead of a prefix match that treated
`>=22.22.2` as `=22.22.2`. Operators (`>=`, `>`, `<=`, `<`, `=`),
caret/tilde ranges, space-separated AND comparators, `||` unions,
hyphen ranges, and `x` wildcards all match per node-semver rules, so
`engines.node: ">=22.22.2"` no longer warns on Node 22.22.3 or 25.9.0.
Bare versions (`.nvmrc` `20.11`) keep the stricter
prefix-at-segment-boundary behavior; unevaluable inputs (`lts/*`) fall
back to the previous prefix match.
- Task dispatch now prepends every existing `node_modules/.bin` between
the project directory and the filesystem root (nearest first) to the
child's `PATH`, the way `npm run` / `pnpm run` / `bun run` do for
Expand Down
73 changes: 68 additions & 5 deletions src/cmd/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ pub(crate) fn doctor(
json: bool,
schema_version: u32,
) -> Result<()> {
let project = Project::build_with_schema(ctx, overrides, schema_version);
let project = Project::build_with_schema(ctx, overrides, schema_version, true);

if json {
println!("{}", serde_json::to_string_pretty(&project)?);
Expand Down Expand Up @@ -176,13 +176,11 @@ fn print_human(report: &Value, overrides: &ResolutionOverrides) {
);
}
if let Some(probe) = node["path_probe"].as_object() {
let shims = node["volta_shims"].as_object();
let parts: Vec<String> = probe
.iter()
.map(|(bin, path)| {
let val = path
.as_str()
.map_or_else(|| "not found".dimmed().to_string(), ToOwned::to_owned);
format!("{bin}={val}")
format_probe_entry(bin, path.as_str(), shims.and_then(|s| s.get(bin)))
})
.collect();
writeln_field(out, "PATH probe", &parts.join(", "));
Expand Down Expand Up @@ -218,6 +216,22 @@ fn print_human(report: &Value, overrides: &ResolutionOverrides) {
}
}

/// Render one `PATH probe` entry. Four cases:
/// `npm=not found` (dimmed), `bun=<path>`,
/// `npm=<shim> -> <real> (volta)` for a provisioned Volta shim, and
/// `pnpm=<shim> (volta shim, not provisioned)` (dimmed suffix) when
/// Volta fronts the tool but has no version of it.
fn format_probe_entry(bin: &str, path: Option<&str>, shim: Option<&Value>) -> String {
let Some(path) = path else {
return format!("{bin}={}", "not found".dimmed());
};
match shim.map(|s| s["resolved"].as_str()) {
Some(Some(real)) => format!("{bin}={path} -> {real} {}", "(volta)".dimmed()),
Some(None) => format!("{bin}={path} {}", "(volta shim, not provisioned)".dimmed()),
None => format!("{bin}={path}"),
}
}

fn print_section<F>(title: &str, fill: F)
where
F: FnOnce(&mut String),
Expand Down Expand Up @@ -257,6 +271,55 @@ mod tests {
}
}

#[test]
fn format_probe_entry_renders_all_four_cases() {
use serde_json::json;

use super::format_probe_entry;

// Strip color control codes by asserting on substrings only.
let not_found = format_probe_entry("npm", None, None);
assert!(not_found.starts_with("npm="), "{not_found}");
assert!(not_found.contains("not found"), "{not_found}");

let plain = format_probe_entry("bun", Some(r"C:\bun\bun.EXE"), None);
assert!(plain.contains(r"bun=C:\bun\bun.EXE"), "{plain}");
assert!(!plain.contains("volta"), "{plain}");

let shim = json!({ "resolved": r"C:\Volta\image\npm\11.6.2\npm.cmd" });
let resolved = format_probe_entry("npm", Some(r"C:\Volta\npm.EXE"), Some(&shim));
assert!(
resolved.contains(r"npm=C:\Volta\npm.EXE -> C:\Volta\image\npm\11.6.2\npm.cmd"),
"{resolved}"
);
assert!(resolved.contains("(volta)"), "{resolved}");

let phantom = json!({ "resolved": null });
let unprovisioned = format_probe_entry("pnpm", Some(r"C:\Volta\pnpm.EXE"), Some(&phantom));
assert!(
unprovisioned.contains("volta shim, not provisioned"),
"{unprovisioned}"
);
}

#[test]
fn build_report_omits_volta_shims_when_not_resolving() {
let ctx = context();
let report = build_report(&ctx, &ResolutionOverrides::default());

// `Project::build` passes `resolve_shims = false`; the additive
// field must vanish entirely, keeping the v1/v2 shape untouched.
assert!(
report["signals"]["node"].get("volta_shims").is_none(),
"volta_shims must be omitted when empty: {}",
report["signals"]["node"],
);
assert!(
report["signals"]["node"].get("path_probe").is_some(),
"path_probe shape must be unchanged",
);
}

#[test]
fn build_report_includes_schema_version() {
let ctx = context();
Expand Down
3 changes: 2 additions & 1 deletion src/cmd/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ pub(crate) fn info(
schema_version: u32,
) -> Result<()> {
if json {
let view = Project::build_with_schema(ctx, overrides, schema_version).into_info_view();
let view =
Project::build_with_schema(ctx, overrides, schema_version, true).into_info_view();
println!("{}", serde_json::to_string_pretty(&view)?);
return Ok(());
}
Expand Down
168 changes: 149 additions & 19 deletions src/cmd/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use anyhow::{Result, bail};
use colored::Colorize;

use crate::chain::mux::{LineSink, StdioSink, prefix_width, render_prefix, spawn_readers};
use crate::resolver::ResolutionOverrides;
use crate::resolver::{ResolutionOverrides, ResolveError};
use crate::tool;
use crate::types::{PackageManager, ProjectContext, TaskRunner, version_matches};

Expand Down Expand Up @@ -47,9 +47,13 @@ pub(crate) fn install_pms(
bail!("No package manager detected.");
}

// Resolved before the GHA group opens so a refused override doesn't
// emit an empty `runner: install` group — same rationale as the
// no-PM bail above.
let pms = select_install_pms(ctx, overrides)?;

// Collapse the whole install (single- or multi-PM) under one
// `runner: install` GitHub Actions group when enabled. Opened after the
// no-PM bail so a failed detection doesn't emit an empty group.
// `runner: install` GitHub Actions group when enabled.
let _group = super::task_group(overrides, "install");

if let (Some(nv), Some(cur)) = (&ctx.node_version, &ctx.current_node)
Expand All @@ -65,20 +69,48 @@ pub(crate) fn install_pms(
suggest_version_switch(ctx);
}

if ctx.package_managers.len() == 1 {
let pm = ctx.package_managers[0];
eprintln!("{} {}", "installing with".dimmed(), pm.label().bold());
let mut cmd = build_install_command(ctx, pm, frozen);
super::configure_command(&mut cmd, &ctx.root);
let status = cmd.status()?;
return Ok(if status.success() {
0
} else {
super::exit_code(status)
});
if let [pm] = pms.as_slice() {
return install_single(ctx, *pm, frozen);
}

run_installs_parallel(ctx, frozen)
run_installs_parallel(ctx, &pms, frozen)
}

/// Which PMs this invocation installs with: the cross-ecosystem
/// `--pm`/`RUNNER_PM` override when present (which must name a detected
/// PM), else every detected PM.
///
/// `pm_by_ecosystem` (runner.toml `[pm].node`/`[pm].python`) is
/// deliberately NOT consulted: it scopes *script dispatch* to an
/// ecosystem, and filtering a polyglot install by it is ill-defined —
/// `[pm].node = "yarn"` saying anything about whether `cargo fetch`
/// runs would be surprising in both directions.
fn select_install_pms(
ctx: &ProjectContext,
overrides: &ResolutionOverrides,
) -> Result<Vec<PackageManager>, ResolveError> {
match &overrides.pm {
Some(o) if ctx.package_managers.contains(&o.pm) => Ok(vec![o.pm]),
Some(o) => Err(ResolveError::PmOverrideNotDetected {
pm: o.pm,
origin: o.origin.clone(),
detected: ctx.package_managers.clone(),
}),
None => Ok(ctx.package_managers.clone()),
}
}

/// Run a single PM's install in the foreground, inheriting stdio.
fn install_single(ctx: &ProjectContext, pm: PackageManager, frozen: bool) -> Result<i32> {
eprintln!("{} {}", "installing with".dimmed(), pm.label().bold());
let mut cmd = build_install_command(ctx, pm, frozen);
super::configure_command(&mut cmd, &ctx.root);
let status = cmd.status()?;
Ok(if status.success() {
0
} else {
super::exit_code(status)
})
}

/// Run every detected package manager's install in parallel, multiplexing
Expand All @@ -91,19 +123,23 @@ pub(crate) fn install_pms(
/// isn't exposed yet — the v1 `runner install` CLI has no flag for it,
/// and the conservative default for a top-level command is "don't tear
/// down the user's slow `cargo fetch` because `npm` blew up on a 404."
fn run_installs_parallel(ctx: &ProjectContext, frozen: bool) -> Result<i32> {
fn run_installs_parallel(
ctx: &ProjectContext,
pms: &[PackageManager],
frozen: bool,
) -> Result<i32> {
use std::process::Child;

let names: Vec<&str> = ctx.package_managers.iter().map(|pm| pm.label()).collect();
let names: Vec<&str> = pms.iter().map(|pm| pm.label()).collect();
let width = prefix_width(&names);
let colorize = colored::control::SHOULD_COLORIZE.should_colorize();
let sink: Arc<dyn LineSink> = Arc::new(StdioSink);

let mut children: Vec<(PackageManager, Child)> = Vec::with_capacity(ctx.package_managers.len());
let mut children: Vec<(PackageManager, Child)> = Vec::with_capacity(pms.len());
let mut reader_handles = Vec::new();

let spawn_outcome: Result<()> = (|| {
for pm in &ctx.package_managers {
for pm in pms {
eprintln!("{} {}", "installing with".dimmed(), pm.label().bold());
let mut cmd = build_install_command(ctx, *pm, frozen);
super::configure_command(&mut cmd, &ctx.root);
Expand Down Expand Up @@ -204,3 +240,97 @@ fn suggest_version_switch(ctx: &ProjectContext) {
};
eprintln!(" {} {}", "hint:".dimmed(), hint);
}

#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::path::PathBuf;

use super::select_install_pms;
use crate::resolver::{OverrideOrigin, PmOverride, ResolutionOverrides, ResolveError};
use crate::types::{Ecosystem, PackageManager, ProjectContext};

fn context(pms: Vec<PackageManager>) -> ProjectContext {
ProjectContext {
root: PathBuf::from("/tmp/test"),
package_managers: pms,
task_runners: Vec::new(),
tasks: Vec::new(),
node_version: None,
current_node: None,
is_monorepo: false,
warnings: Vec::new(),
}
}

fn override_pm(pm: PackageManager, origin: OverrideOrigin) -> ResolutionOverrides {
ResolutionOverrides {
pm: Some(PmOverride { pm, origin }),
..Default::default()
}
}

#[test]
fn no_override_installs_with_every_detected_pm() {
let ctx = context(vec![PackageManager::Bun, PackageManager::Deno]);
let pms = select_install_pms(&ctx, &ResolutionOverrides::default())
.expect("default selection should succeed");
assert_eq!(pms, vec![PackageManager::Bun, PackageManager::Deno]);
}

#[test]
fn detected_override_installs_with_it_alone() {
// The dreamcli CI bug: bun + deno detected, RUNNER_PM=bun set —
// deno must not install (and must not write deno.lock).
let ctx = context(vec![PackageManager::Bun, PackageManager::Deno]);
let overrides = override_pm(PackageManager::Bun, OverrideOrigin::EnvVar);
let pms = select_install_pms(&ctx, &overrides).expect("detected override should filter");
assert_eq!(pms, vec![PackageManager::Bun]);
}

#[test]
fn undetected_override_errors_with_origin_and_detected_list() {
let ctx = context(vec![PackageManager::Cargo]);
let overrides = override_pm(PackageManager::Npm, OverrideOrigin::EnvVar);
let err = select_install_pms(&ctx, &overrides).expect_err("undetected override must error");

assert!(matches!(err, ResolveError::PmOverrideNotDetected { .. }));
let msg = format!("{err}");
assert!(msg.contains("RUNNER_PM"), "should name the source: {msg}");
assert!(msg.contains("cargo"), "should list detected PMs: {msg}");
}

#[test]
fn undetected_cli_override_names_the_flag() {
let ctx = context(vec![PackageManager::Cargo]);
let overrides = override_pm(PackageManager::Npm, OverrideOrigin::CliFlag);
let err = select_install_pms(&ctx, &overrides).expect_err("undetected override must error");

let msg = format!("{err}");
assert!(msg.contains("--pm"), "should name the flag: {msg}");
}

#[test]
fn ecosystem_config_override_does_not_filter_installs() {
// Pins the documented non-goal: `[pm].node` in runner.toml scopes
// script dispatch, not the install set.
let ctx = context(vec![PackageManager::Bun, PackageManager::Cargo]);
let mut pm_by_ecosystem = HashMap::new();
pm_by_ecosystem.insert(
Ecosystem::Node,
PmOverride {
pm: PackageManager::Pnpm,
origin: OverrideOrigin::ConfigFile {
path: PathBuf::from("/tmp/test/runner.toml"),
},
},
);
let overrides = ResolutionOverrides {
pm_by_ecosystem,
..Default::default()
};

let pms = select_install_pms(&ctx, &overrides).expect("config must not filter installs");
assert_eq!(pms, vec![PackageManager::Bun, PackageManager::Cargo]);
}
}
2 changes: 1 addition & 1 deletion src/cmd/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ pub(crate) fn list(
};

if json {
let view = Project::build_with_schema(ctx, overrides, schema_version)
let view = Project::build_with_schema(ctx, overrides, schema_version, false)
.into_list_view(parsed_source);
println!("{}", serde_json::to_string_pretty(&view)?);
return Ok(());
Expand Down
Loading