feat(pacquet): port runtime command (#12571)

Port the runtime command to pacquet by adding a runtime/rt CLI entry and routing runtime set <name> <version> through the existing add pipeline as <name>@runtime:<version>.

The command matches pnpm's local save behavior: devEngines.runtime is the default, --save-prod targets engines.runtime, and --save-dev wins when both flags are present. The global path is parsed but rejected because pacquet does not yet support global package management, matching the existing update/outdated global boundary.

Add the manifest writer conversion that folds runtime:<version> dependencies back into devEngines.runtime and engines.runtime before saving, complementing the existing read-time conversion used by install and lockfile checks.
This commit is contained in:
Zoltan Kochan
2026-06-22 14:25:49 +02:00
committed by GitHub
parent be60cd63eb
commit 0ec878d829
18 changed files with 858 additions and 22 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/workspace.project-manifest-reader": patch
"pnpm": patch
---
Removing a runtime dependency now removes the matching `devEngines.runtime` or `engines.runtime` entry that was materialized from it.

View File

@@ -12,6 +12,7 @@ pub mod recursive;
pub mod remove;
pub mod restart;
pub mod run;
pub mod runtime;
pub mod sanitize;
pub mod stop;
pub mod store;
@@ -43,6 +44,7 @@ use pacquet_reporter::{
use remove::RemoveArgs;
use restart::RestartArgs;
use run::RunArgs;
use runtime::RuntimeArgs;
use serde_json::Value;
use std::{
fs,
@@ -167,6 +169,9 @@ pub enum CliCommand {
Restart(RestartArgs),
/// Lists the packages that include the file with the specified hash.
FindHash(FindHashArgs),
/// Manage runtimes.
#[clap(visible_alias = "rt")]
Runtime(RuntimeArgs),
/// Managing the package store.
#[clap(subcommand)]
Store(StoreCommand),
@@ -257,7 +262,8 @@ impl CliArgs {
| CliCommand::Remove(_)
| CliCommand::Install(_)
| CliCommand::Dlx(_)
| CliCommand::Create(_),
| CliCommand::Create(_)
| CliCommand::Runtime(_),
);
let manifest_path = || dir.join("package.json");
// Resolve `.npmrc` / `pnpm-workspace.yaml` from the canonicalized
@@ -476,6 +482,20 @@ impl CliArgs {
CliCommand::FindHash(args) => {
args.run(|| config().map(|m| &*m))?;
}
CliCommand::Runtime(args) => {
args.reject_unsupported_global()?;
match reporter {
ReporterType::Default | ReporterType::AppendOnly => {
Box::pin(args.run::<DefaultReporter>(state(false)?)).await?;
}
ReporterType::Ndjson => {
Box::pin(args.run::<NdjsonReporter>(state(false)?)).await?;
}
ReporterType::Silent => {
Box::pin(args.run::<SilentReporter>(state(false)?)).await?;
}
}
}
CliCommand::Store(command) => command.run(|| config().map(|m| &*m))?,
CliCommand::Cache(command) => command.run(config()?)?,
CliCommand::CatFile(args) => {

View File

@@ -0,0 +1,111 @@
use crate::{State, cli_args::add::add_package};
use clap::Args;
use derive_more::{Display, Error};
use miette::Diagnostic;
use pacquet_package_manifest::DependencyGroup;
use pacquet_registry::PinnedVersion;
use pacquet_reporter::Reporter;
/// Manage runtimes.
#[derive(Debug, Args)]
pub struct RuntimeArgs {
/// Install the runtime globally.
#[clap(short = 'g', long)]
pub global: bool,
/// Save the runtime to `devEngines.runtime`. This is the default.
#[clap(short = 'D', long = "save-dev")]
pub save_dev: bool,
/// Save the runtime to `engines.runtime`.
#[clap(short = 'P', long = "save-prod")]
pub save_prod: bool,
/// Runtime subcommand and arguments.
pub params: Vec<String>,
}
#[derive(Debug, Display, Error, Diagnostic, PartialEq, Eq)]
#[non_exhaustive]
pub enum RuntimeError {
#[display("Please specify the subcommand")]
#[diagnostic(code(ERR_PNPM_RUNTIME_NO_SUBCOMMAND))]
NoSubcommand,
#[display("Unknown subcommand: {subcommand}")]
#[diagnostic(code(ERR_PNPM_RUNTIME_UNKNOWN_SUBCOMMAND))]
UnknownSubcommand {
#[error(not(source))]
subcommand: String,
},
#[display(
r#""pnpm runtime set <name> <version>" requires a runtime name (e.g. node, deno, bun)"#
)]
#[diagnostic(code(ERR_PNPM_MISSING_RUNTIME_NAME))]
MissingRuntimeName,
#[display(
"`pacquet runtime set --global` is not supported yet; global package management has not been ported to pacquet."
)]
#[diagnostic(code(pacquet_cli::runtime_global_unsupported))]
GlobalUnsupported,
}
#[derive(Debug)]
struct RuntimeSetRequest {
package_name: String,
dependency_group: DependencyGroup,
}
impl RuntimeArgs {
pub fn reject_unsupported_global(&self) -> Result<(), RuntimeError> {
if self.global {
return Err(RuntimeError::GlobalUnsupported);
}
Ok(())
}
/// Execute the subcommand.
pub async fn run<Reporter: self::Reporter + 'static>(self, state: State) -> miette::Result<()> {
let request = self.set_request()?;
add_package::<Reporter, _, _>(
state,
&request.package_name,
PinnedVersion::Major,
None,
false,
None,
|| std::iter::once(request.dependency_group),
)
.await
}
fn set_request(&self) -> Result<RuntimeSetRequest, RuntimeError> {
let Some(subcommand) = self.params.first() else {
return Err(RuntimeError::NoSubcommand);
};
if subcommand != "set" {
return Err(RuntimeError::UnknownSubcommand { subcommand: subcommand.clone() });
}
let runtime_name = self
.params
.get(1)
.map(|name| name.trim())
.filter(|name| !name.is_empty())
.ok_or(RuntimeError::MissingRuntimeName)?;
let version_spec = self.params.get(2).map_or("", |version| version.trim());
let dependency_group = if self.save_dev || !self.save_prod {
DependencyGroup::Dev
} else {
DependencyGroup::Prod
};
Ok(RuntimeSetRequest {
package_name: format!("{runtime_name}@runtime:{version_spec}"),
dependency_group,
})
}
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,76 @@
use pacquet_package_manifest::DependencyGroup;
use super::{RuntimeArgs, RuntimeError};
fn args(params: &[&str]) -> RuntimeArgs {
RuntimeArgs {
global: false,
save_dev: false,
save_prod: false,
params: params.iter().map(|param| (*param).to_string()).collect(),
}
}
#[test]
fn set_request_defaults_to_dev_engines_runtime() {
let request = args(&["set", "node", "22"]).set_request().unwrap();
assert_eq!(request.package_name, "node@runtime:22");
assert_eq!(request.dependency_group, DependencyGroup::Dev);
}
#[test]
fn set_request_saves_prod_when_save_prod_is_set() {
let request =
RuntimeArgs { save_prod: true, ..args(&["set", "node", "22"]) }.set_request().unwrap();
assert_eq!(request.package_name, "node@runtime:22");
assert_eq!(request.dependency_group, DependencyGroup::Prod);
}
#[test]
fn set_request_prefers_save_dev_over_save_prod() {
let request = RuntimeArgs { save_dev: true, save_prod: true, ..args(&["set", "node", "22"]) }
.set_request()
.unwrap();
assert_eq!(request.package_name, "node@runtime:22");
assert_eq!(request.dependency_group, DependencyGroup::Dev);
}
#[test]
fn set_request_allows_missing_version_spec() {
let request = args(&["set", "node"]).set_request().unwrap();
assert_eq!(request.package_name, "node@runtime:");
assert_eq!(request.dependency_group, DependencyGroup::Dev);
}
#[test]
fn set_request_works_with_deno() {
let request = args(&["set", "deno", "2"]).set_request().unwrap();
assert_eq!(request.package_name, "deno@runtime:2");
assert_eq!(request.dependency_group, DependencyGroup::Dev);
}
#[test]
fn set_request_fails_without_subcommand() {
let err = args(&[]).set_request().unwrap_err();
assert_eq!(err, RuntimeError::NoSubcommand);
}
#[test]
fn set_request_fails_for_unknown_subcommand() {
let err = args(&["foo"]).set_request().unwrap_err();
assert_eq!(err, RuntimeError::UnknownSubcommand { subcommand: "foo".to_string() });
}
#[test]
fn set_request_fails_without_runtime_name() {
let err = args(&["set"]).set_request().unwrap_err();
assert_eq!(err, RuntimeError::MissingRuntimeName);
}
#[test]
fn global_is_rejected_before_state_initialization() {
let err = RuntimeArgs { global: true, ..args(&["set", "node", "22"]) }
.reject_unsupported_global()
.unwrap_err();
assert_eq!(err, RuntimeError::GlobalUnsupported);
}

View File

@@ -60,6 +60,30 @@ fn filter_flag_split_across_subcommand_keeps_only_subcommand_side() {
assert_eq!(parsed.filter, ["b"], "global-side `a` is dropped");
}
#[test]
fn runtime_alias_and_flags_parse() {
let parsed = CliArgs::try_parse_from(["pacquet", "rt", "set", "node", "22", "-P"])
.expect("parses runtime alias");
let CliCommand::Runtime(args) = parsed.command else {
panic!("expected runtime command");
};
assert!(!args.global);
assert!(!args.save_dev);
assert!(args.save_prod);
assert_eq!(args.params, ["set", "node", "22"]);
}
#[test]
fn runtime_global_flag_parses_after_version() {
let parsed = CliArgs::try_parse_from(["pacquet", "runtime", "set", "node", "22", "-g"])
.expect("parses runtime global flag after params");
let CliCommand::Runtime(args) = parsed.command else {
panic!("expected runtime command");
};
assert!(args.global);
assert_eq!(args.params, ["set", "node", "22"]);
}
#[test]
fn package_manager_to_sync_preserves_dev_engine_specifier() {
let root = TempDir::new().expect("tmp dir");

View File

@@ -75,6 +75,7 @@ impl BunResolver {
let Some(version_spec) = bare_runtime_spec(wanted_dependency, "bun") else {
return Ok(None);
};
let version_spec = normalize_runtime_spec(version_spec);
let npm_result = self
.npm_resolver
@@ -127,7 +128,8 @@ impl BunResolver {
return Ok(None);
};
let version_spec =
if query.compatible { manifest_spec.to_string() } else { "latest".to_string() };
if query.compatible { normalize_runtime_spec(manifest_spec) } else { "latest" }
.to_string();
let mut resolve_opts = opts.clone();
if !query.compatible {
resolve_opts.update = UpdateBehavior::Latest;
@@ -172,6 +174,10 @@ fn bare_runtime_spec<'a>(wanted: &'a WantedDependency, expected_alias: &str) ->
wanted.bare_specifier.as_deref().and_then(|spec| spec.strip_prefix(BARE_SPEC_PREFIX))
}
fn normalize_runtime_spec(version_spec: &str) -> &str {
if version_spec.is_empty() { "latest" } else { version_spec }
}
fn bun_bin_for_current_os(platform: &str) -> &'static str {
if platform == "win32" { "bun.exe" } else { "bun" }
}

View File

@@ -1,4 +1,4 @@
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use pacquet_network::ThrottledClient;
use pacquet_resolving_resolver_base::{
@@ -26,6 +26,29 @@ impl Resolver for StubResolver {
}
}
#[derive(Default)]
struct CapturingResolver {
seen: Mutex<Vec<Option<String>>>,
}
impl Resolver for CapturingResolver {
fn resolve<'a>(
&'a self,
wanted_dependency: &'a WantedDependency,
_opts: &'a ResolveOptions,
) -> ResolveFuture<'a> {
self.seen.lock().unwrap().push(wanted_dependency.bare_specifier.clone());
Box::pin(async { Ok::<Option<ResolveResult>, ResolveError>(None) })
}
fn resolve_latest<'a>(
&'a self,
_query: &'a pacquet_resolving_resolver_base::LatestQuery,
_opts: &'a ResolveOptions,
) -> ResolveLatestFuture<'a> {
Box::pin(async { Ok::<Option<LatestInfo>, ResolveError>(None) })
}
}
fn resolver() -> BunResolver {
BunResolver::new(Arc::new(ThrottledClient::new_for_installs()), Arc::new(StubResolver))
}
@@ -49,3 +72,21 @@ async fn declines_bun_without_runtime_prefix() {
};
assert!(resolver().resolve(&wanted, &ResolveOptions::default()).await.unwrap().is_none());
}
#[tokio::test]
async fn empty_runtime_spec_delegates_latest_to_npm_resolver() {
let npm_resolver = Arc::new(CapturingResolver::default());
let resolver = BunResolver::new(
Arc::new(ThrottledClient::new_for_installs()),
Arc::<CapturingResolver>::clone(&npm_resolver),
);
let wanted = WantedDependency {
alias: Some("bun".to_string()),
bare_specifier: Some("runtime:".to_string()),
..WantedDependency::default()
};
resolver.resolve(&wanted, &ResolveOptions::default()).await.unwrap_err();
assert_eq!(npm_resolver.seen.lock().unwrap().clone(), vec![Some("latest".to_string())]);
}

View File

@@ -81,6 +81,7 @@ impl DenoResolver {
let Some(version_spec) = bare_runtime_spec(wanted_dependency, "deno") else {
return Ok(None);
};
let version_spec = normalize_runtime_spec(version_spec);
let npm_result = self
.npm_resolver
@@ -134,7 +135,8 @@ impl DenoResolver {
return Ok(None);
};
let version_spec =
if query.compatible { manifest_spec.to_string() } else { "latest".to_string() };
if query.compatible { normalize_runtime_spec(manifest_spec) } else { "latest" }
.to_string();
let mut resolve_opts = opts.clone();
if !query.compatible {
resolve_opts.update = UpdateBehavior::Latest;
@@ -179,6 +181,10 @@ fn bare_runtime_spec<'a>(wanted: &'a WantedDependency, expected_alias: &str) ->
wanted.bare_specifier.as_deref().and_then(|spec| spec.strip_prefix(BARE_SPEC_PREFIX))
}
fn normalize_runtime_spec(version_spec: &str) -> &str {
if version_spec.is_empty() { "latest" } else { version_spec }
}
fn deno_bin_for_current_os(platform: &str) -> &'static str {
if platform == "win32" { "deno.exe" } else { "deno" }
}

View File

@@ -1,4 +1,4 @@
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use pacquet_network::ThrottledClient;
use pacquet_resolving_resolver_base::{
@@ -26,6 +26,29 @@ impl Resolver for StubResolver {
}
}
#[derive(Default)]
struct CapturingResolver {
seen: Mutex<Vec<Option<String>>>,
}
impl Resolver for CapturingResolver {
fn resolve<'a>(
&'a self,
wanted_dependency: &'a WantedDependency,
_opts: &'a ResolveOptions,
) -> ResolveFuture<'a> {
self.seen.lock().unwrap().push(wanted_dependency.bare_specifier.clone());
Box::pin(async { Ok::<Option<ResolveResult>, ResolveError>(None) })
}
fn resolve_latest<'a>(
&'a self,
_query: &'a pacquet_resolving_resolver_base::LatestQuery,
_opts: &'a ResolveOptions,
) -> ResolveLatestFuture<'a> {
Box::pin(async { Ok::<Option<LatestInfo>, ResolveError>(None) })
}
}
fn resolver() -> DenoResolver {
DenoResolver::new(Arc::new(ThrottledClient::new_for_installs()), Arc::new(StubResolver))
}
@@ -49,3 +72,21 @@ async fn declines_deno_without_runtime_prefix() {
};
assert!(resolver().resolve(&wanted, &ResolveOptions::default()).await.unwrap().is_none());
}
#[tokio::test]
async fn empty_runtime_spec_delegates_latest_to_npm_resolver() {
let npm_resolver = Arc::new(CapturingResolver::default());
let resolver = DenoResolver::new(
Arc::new(ThrottledClient::new_for_installs()),
Arc::<CapturingResolver>::clone(&npm_resolver),
);
let wanted = WantedDependency {
alias: Some("deno".to_string()),
bare_specifier: Some("runtime:".to_string()),
..WantedDependency::default()
};
resolver.resolve(&wanted, &ResolveOptions::default()).await.unwrap_err();
assert_eq!(npm_resolver.seen.lock().unwrap().clone(), vec![Some("latest".to_string())]);
}

View File

@@ -5,8 +5,8 @@
//! versions, and pick the one (or the set) matching a user-supplied
//! selector. The selector may be:
//!
//! - `latest` — the first entry in the index (the newest published
//! build on that channel).
//! - `latest` or an empty selector — the first entry in the index
//! (the newest published build on that channel).
//! - `lts` — the newest entry tagged with any LTS codename.
//! - An LTS codename (`argon`, `iron`, ...) — `*` within that codename.
//! - A semver range — pick the `max_satisfying` version.
@@ -78,7 +78,7 @@ pub async fn resolve_node_version(
node_mirror_base_url: Option<&str>,
) -> Result<Option<String>, ResolveNodeVersionError> {
let all_versions = fetch_all_versions(http_client, node_mirror_base_url).await?;
if version_spec == "latest" {
if is_latest_selector(version_spec) {
return Ok(all_versions.first().map(|version| version.version.clone()));
}
let (versions, range) = filter_versions(&all_versions, version_spec);
@@ -99,7 +99,7 @@ pub async fn resolve_node_versions(
let Some(version_spec) = version_spec else {
return Ok(all_versions.into_iter().map(|version| version.version).collect());
};
if version_spec == "latest" {
if is_latest_selector(version_spec) {
return Ok(all_versions
.into_iter()
.next()
@@ -150,6 +150,10 @@ async fn fetch_all_versions(
.collect())
}
fn is_latest_selector(version_spec: &str) -> bool {
version_spec.is_empty() || version_spec == "latest"
}
/// Decode the `lts` field upstream emits as `false | string`.
fn lts_codename(value: serde_json::Value) -> Option<String> {
match value {

View File

@@ -1,6 +1,8 @@
use pretty_assertions::assert_eq;
use super::{NodeVersion, filter_versions};
use pacquet_network::ThrottledClient;
use super::{NodeVersion, filter_versions, resolve_node_version, resolve_node_versions};
fn make_versions() -> Vec<NodeVersion> {
vec![
@@ -32,3 +34,28 @@ fn semver_range_passes_through() {
assert_eq!(picked.len(), 5);
assert_eq!(range, "^20");
}
#[tokio::test]
async fn empty_selector_picks_latest_version() {
let mut server = mockito::Server::new_async().await;
let _index = server
.mock("GET", "/index.json")
.with_status(200)
.with_body(
r#"[
{ "version": "v22.1.0", "lts": false },
{ "version": "v20.10.0", "lts": "Iron" }
]"#,
)
.expect(2)
.create_async()
.await;
let base_url = format!("{}/", server.url());
let http_client = ThrottledClient::new_for_installs();
let picked = resolve_node_version(&http_client, "", Some(&base_url)).await.unwrap();
assert_eq!(picked, Some("22.1.0".to_string()));
let picked = resolve_node_versions(&http_client, Some(""), Some(&base_url)).await.unwrap();
assert_eq!(picked, vec!["22.1.0"]);
}

View File

@@ -302,7 +302,7 @@ where
.await
.map_err(AddError::Install)?;
manifest.save().map_err(AddError::SaveManifest)?;
let updated = manifest.save_and_get_written_value().map_err(AddError::SaveManifest)?;
// `pnpm:package-manifest updated` mirrors pnpm's emit at
// <https://github.com/pnpm/pnpm/blob/086c5e91e8/installing/deps-resolver/src/index.ts#L238>:
@@ -326,7 +326,7 @@ where
.into_owned();
Reporter::emit(&LogEvent::PackageManifest(PackageManifestLog {
level: LogLevel::Debug,
message: PackageManifestMessage::Updated { prefix, updated: manifest.value().clone() },
message: PackageManifestMessage::Updated { prefix, updated },
}));
Ok(())

View File

@@ -139,7 +139,7 @@ impl Remove<'_> {
.await
.map_err(RemoveError::Install)?;
manifest.save().map_err(RemoveError::SaveManifest)?;
let updated = manifest.save_and_get_written_value().map_err(RemoveError::SaveManifest)?;
// `pnpm:package-manifest updated` mirrors the post-mutation emit
// pnpm fires after rewriting the manifest. See the parallel emit
@@ -152,7 +152,7 @@ impl Remove<'_> {
.into_owned();
Reporter::emit(&LogEvent::PackageManifest(PackageManifestLog {
level: LogLevel::Debug,
message: PackageManifestMessage::Updated { prefix, updated: manifest.value().clone() },
message: PackageManifestMessage::Updated { prefix, updated },
}));
Ok(())

View File

@@ -568,7 +568,8 @@ impl Update<'_> {
.map_err(UpdateError::Install)?;
if persist_manifest {
manifest.save().map_err(UpdateError::SaveManifest)?;
let updated =
manifest.save_and_get_written_value().map_err(UpdateError::SaveManifest)?;
let prefix = manifest
.path()
@@ -578,10 +579,7 @@ impl Update<'_> {
.into_owned();
Reporter::emit(&LogEvent::PackageManifest(PackageManifestLog {
level: LogLevel::Debug,
message: PackageManifestMessage::Updated {
prefix,
updated: manifest.value().clone(),
},
message: PackageManifestMessage::Updated { prefix, updated },
}));
}

View File

@@ -153,10 +153,18 @@ impl PackageManifest {
&mut self.value
}
pub fn save(&self) -> Result<(), PackageManifestError> {
pub fn save_and_get_written_value(&self) -> Result<Value, PackageManifestError> {
let mut value = self.value.clone();
convert_dependencies_to_engines_runtime(&mut value, "devDependencies", "devEngines")?;
convert_dependencies_to_engines_runtime(&mut value, "dependencies", "engines")?;
let contents = serde_json::to_string_pretty(&value)?;
let mut file = fs::File::create(&self.path)?;
let contents = serde_json::to_string_pretty(&self.value)?;
file.write_all(contents.as_bytes())?;
Ok(value)
}
pub fn save(&self) -> Result<(), PackageManifestError> {
self.save_and_get_written_value()?;
Ok(())
}
@@ -382,6 +390,134 @@ pub fn convert_engines_runtime_to_dependencies(
}
}
/// Fold `runtime:<version>` dependency entries back into
/// `devEngines.runtime` / `engines.runtime` before writing a manifest.
///
/// Mirrors upstream's `convertDependenciesToEnginesRuntime` writer hook in
/// `workspace/project-manifest-reader`: the in-memory dependency form drives
/// resolution and lockfile checks, while the on-disk manifest keeps the
/// `devEngines.runtime` / `engines.runtime` contract.
pub fn convert_dependencies_to_engines_runtime(
manifest: &mut Value,
deps_field: &str,
engines_field: &str,
) -> Result<(), PackageManifestError> {
for runtime_name in RUNTIME_NAMES {
let version = manifest
.get(deps_field)
.and_then(|deps| deps.get(runtime_name))
.and_then(Value::as_str)
.and_then(|dep| dep.strip_prefix("runtime:"))
.map(str::to_string);
if let Some(version) = version {
upsert_runtime_entry(manifest, engines_field, runtime_name, &version)?;
if let Some(deps) = manifest.get_mut(deps_field).and_then(Value::as_object_mut) {
deps.remove(runtime_name);
}
} else {
remove_managed_runtime_entry(manifest, engines_field, runtime_name);
}
}
Ok(())
}
fn remove_managed_runtime_entry(manifest: &mut Value, engines_field: &str, runtime_name: &str) {
let Some(engines) = manifest.get_mut(engines_field).and_then(Value::as_object_mut) else {
return;
};
let remove_runtime = match engines.get_mut("runtime") {
Some(Value::Array(runtimes)) => {
runtimes.retain(|runtime| !is_managed_runtime_entry(runtime, runtime_name));
runtimes.is_empty()
}
Some(runtime) if is_managed_runtime_entry(runtime, runtime_name) => true,
_ => false,
};
if remove_runtime {
engines.remove("runtime");
}
}
fn is_managed_runtime_entry(runtime: &Value, runtime_name: &str) -> bool {
runtime.get("name").and_then(Value::as_str) == Some(runtime_name)
&& runtime.get("onFail").and_then(Value::as_str) == Some("download")
&& runtime.get("version").and_then(Value::as_str).is_some()
}
fn upsert_runtime_entry(
manifest: &mut Value,
engines_field: &str,
runtime_name: &str,
version: &str,
) -> Result<(), PackageManifestError> {
let runtime_entry = json!({
"name": runtime_name,
"version": version,
"onFail": "download",
});
let engines = ensure_object_field(manifest, engines_field)?;
match engines.get_mut("runtime") {
None | Some(Value::Null) => {
engines.insert("runtime".to_string(), runtime_entry);
}
Some(Value::Array(runtimes)) => {
if let Some(existing) = runtimes
.iter_mut()
.find(|runtime| runtime.get("name").and_then(Value::as_str) == Some(runtime_name))
{
merge_runtime_entry(existing, runtime_name, version)?;
} else {
runtimes.push(runtime_entry);
}
}
Some(Value::Object(runtime))
if runtime.get("name").and_then(Value::as_str) == Some(runtime_name) =>
{
runtime.insert("name".to_string(), Value::String(runtime_name.to_string()));
runtime.insert("version".to_string(), Value::String(version.to_string()));
runtime.insert("onFail".to_string(), Value::String("download".to_string()));
}
Some(existing) => {
*existing = Value::Array(vec![existing.clone(), runtime_entry]);
}
}
Ok(())
}
fn ensure_object_field<'a>(
manifest: &'a mut Value,
field: &str,
) -> Result<&'a mut Map<String, Value>, PackageManifestError> {
let Some(root) = manifest.as_object_mut() else {
return Err(PackageManifestError::InvalidAttribute(
"the manifest root must be an object".to_string(),
));
};
let value = root.entry(field.to_string()).or_insert_with(|| Value::Object(Map::new()));
if value.is_null() {
*value = Value::Object(Map::new());
}
value.as_object_mut().ok_or_else(|| {
PackageManifestError::InvalidAttribute(format!("the {field} field must be an object"))
})
}
fn merge_runtime_entry(
runtime: &mut Value,
runtime_name: &str,
version: &str,
) -> Result<(), PackageManifestError> {
let Some(runtime) = runtime.as_object_mut() else {
return Err(PackageManifestError::InvalidAttribute(
"runtime entries must be objects".to_string(),
));
};
runtime.insert("name".to_string(), Value::String(runtime_name.to_string()));
runtime.insert("version".to_string(), Value::String(version.to_string()));
runtime.insert("onFail".to_string(), Value::String("download".to_string()));
Ok(())
}
/// Read `<dir>/package.json` if it exists, returning `Ok(None)` when the file
/// is absent. Other IO errors and JSON parse errors propagate.
///

View File

@@ -9,7 +9,7 @@ use tempfile::{NamedTempFile, tempdir};
use super::safe_read_package_json_from_dir;
use super::{
BundleDependencies, PackageManifest, PackageManifestError,
convert_engines_runtime_to_dependencies,
convert_dependencies_to_engines_runtime, convert_engines_runtime_to_dependencies,
};
use crate::DependencyGroup;
use serde_json::json;
@@ -308,6 +308,244 @@ fn convert_engines_runtime_targets_dependencies_for_engines_field() {
);
}
#[test]
fn convert_dependencies_runtime_writes_devengines_runtime() {
let mut manifest = json!({
"devDependencies": {
"node": "runtime:22",
},
});
convert_dependencies_to_engines_runtime(&mut manifest, "devDependencies", "devEngines")
.unwrap();
assert_eq!(
manifest.get("devEngines"),
Some(&json!({
"runtime": {
"name": "node",
"version": "22",
"onFail": "download",
},
})),
);
assert_eq!(manifest.get("devDependencies"), Some(&json!({})));
}
#[test]
fn convert_dependencies_runtime_updates_existing_single_entry() {
let mut manifest = json!({
"devEngines": {
"runtime": {
"name": "node",
"version": "16",
"onFail": "warn",
},
},
"devDependencies": {
"node": "runtime:22",
},
});
convert_dependencies_to_engines_runtime(&mut manifest, "devDependencies", "devEngines")
.unwrap();
assert_eq!(
manifest.get("devEngines"),
Some(&json!({
"runtime": {
"name": "node",
"version": "22",
"onFail": "download",
},
})),
);
assert_eq!(manifest.get("devDependencies"), Some(&json!({})));
}
#[test]
fn convert_dependencies_runtime_preserves_other_single_runtime_as_array() {
let mut manifest = json!({
"devEngines": {
"runtime": {
"name": "deno",
"version": "1",
},
},
"devDependencies": {
"node": "runtime:22",
},
});
convert_dependencies_to_engines_runtime(&mut manifest, "devDependencies", "devEngines")
.unwrap();
assert_eq!(
manifest.get("devEngines"),
Some(&json!({
"runtime": [
{
"name": "deno",
"version": "1",
},
{
"name": "node",
"version": "22",
"onFail": "download",
},
],
})),
);
assert_eq!(manifest.get("devDependencies"), Some(&json!({})));
}
#[test]
fn save_converts_runtime_dependencies_before_writing() {
let dir = tempdir().unwrap();
let path = dir.path().join("package.json");
let mut manifest = PackageManifest::create_if_needed(path.clone()).unwrap();
manifest.add_dependency("node", "runtime:22", DependencyGroup::Dev).unwrap();
manifest.add_dependency("bun", "runtime:1.2.0", DependencyGroup::Prod).unwrap();
manifest.save().unwrap();
let saved: serde_json::Value =
serde_json::from_str(&read_to_string(path).unwrap()).expect("parse saved manifest");
assert_eq!(
saved.get("devEngines"),
Some(&json!({
"runtime": {
"name": "node",
"version": "22",
"onFail": "download",
},
})),
);
assert_eq!(
saved.get("engines"),
Some(&json!({
"runtime": {
"name": "bun",
"version": "1.2.0",
"onFail": "download",
},
})),
);
assert_eq!(saved.get("devDependencies"), Some(&json!({})));
assert_eq!(saved.get("dependencies"), Some(&json!({})));
}
#[test]
fn save_and_get_written_value_returns_saved_manifest() {
let dir = tempdir().unwrap();
let path = dir.path().join("package.json");
let mut manifest = PackageManifest::create_if_needed(path.clone()).unwrap();
manifest.add_dependency("node", "runtime:22", DependencyGroup::Dev).unwrap();
let written = manifest.save_and_get_written_value().unwrap();
let saved: serde_json::Value =
serde_json::from_str(&read_to_string(path).unwrap()).expect("parse saved manifest");
assert_eq!(written, saved);
assert_eq!(
saved.get("devEngines"),
Some(&json!({
"runtime": {
"name": "node",
"version": "22",
"onFail": "download",
},
})),
);
assert_eq!(saved.get("devDependencies"), Some(&json!({})));
assert_eq!(manifest.value().get("devDependencies"), Some(&json!({ "node": "runtime:22" })));
}
#[test]
fn save_prunes_removed_reified_runtime_entry() {
let dir = tempdir().unwrap();
let path = dir.path().join("package.json");
let raw = json!({
"name": "fixture",
"devEngines": {
"runtime": {
"name": "node",
"version": "22",
"onFail": "download",
},
},
});
std::fs::write(&path, serde_json::to_string_pretty(&raw).unwrap()).unwrap();
let mut manifest = PackageManifest::from_path(path.clone()).unwrap();
manifest.remove_dependencies(&["node".to_string()], Some(DependencyGroup::Dev));
manifest.save().unwrap();
let saved: serde_json::Value =
serde_json::from_str(&read_to_string(path).unwrap()).expect("parse saved manifest");
assert_eq!(saved.get("devEngines"), Some(&json!({})));
assert_eq!(saved.get("devDependencies"), Some(&json!({})));
}
#[test]
fn save_prunes_only_removed_reified_runtime_entry_from_array() {
let dir = tempdir().unwrap();
let path = dir.path().join("package.json");
let raw = json!({
"name": "fixture",
"devEngines": {
"runtime": [
{
"name": "node",
"version": "22",
"onFail": "download",
},
{
"name": "deno",
"version": "2",
"onFail": "download",
},
],
},
});
std::fs::write(&path, serde_json::to_string_pretty(&raw).unwrap()).unwrap();
let mut manifest = PackageManifest::from_path(path.clone()).unwrap();
manifest.remove_dependencies(&["node".to_string()], Some(DependencyGroup::Dev));
manifest.save().unwrap();
let saved: serde_json::Value =
serde_json::from_str(&read_to_string(path).unwrap()).expect("parse saved manifest");
assert_eq!(
saved.get("devEngines"),
Some(&json!({
"runtime": [
{
"name": "deno",
"version": "2",
"onFail": "download",
},
],
})),
);
assert_eq!(saved.get("devDependencies"), Some(&json!({})));
}
#[test]
fn failed_save_preserves_existing_file_contents() {
let dir = tempdir().unwrap();
let path = dir.path().join("package.json");
let raw = serde_json::to_string_pretty(&json!({
"name": "fixture",
"devEngines": "invalid",
"devDependencies": {
"node": "runtime:22",
},
}))
.unwrap();
std::fs::write(&path, &raw).unwrap();
let manifest = PackageManifest::from_path(path.clone()).unwrap();
assert!(matches!(manifest.save(), Err(PackageManifestError::InvalidAttribute(_))));
assert_eq!(read_to_string(path).unwrap(), raw);
}
/// Reading a manifest with `devEngines.runtime` set must apply the
/// reification automatically — that's the hook upstream wires into
/// `convertManifestAfterRead`. Verifies the `from_path` end of the

View File

@@ -279,10 +279,36 @@ function convertDependenciesToEnginesRuntime (
if (manifest[dependenciesFieldName]) {
delete manifest[dependenciesFieldName][runtimeName]
}
} else {
removeManagedRuntimeEntry(manifest[enginesFieldName], runtimeName)
}
}
}
function removeManagedRuntimeEntry (
enginesField: ProjectManifest['devEngines'] | ProjectManifest['engines'],
runtimeName: string
): void {
if (!enginesField?.runtime) return
if (Array.isArray(enginesField.runtime)) {
const runtimes = enginesField.runtime.filter((runtime) => !isManagedRuntimeEntry(runtime, runtimeName))
if (runtimes.length === 0) {
delete enginesField.runtime
} else {
enginesField.runtime = runtimes
}
} else if (isManagedRuntimeEntry(enginesField.runtime, runtimeName)) {
delete enginesField.runtime
}
}
function isManagedRuntimeEntry (runtime: EngineDependency, runtimeName: string): boolean {
return runtime.name === runtimeName &&
runtime.onFail === 'download' &&
typeof runtime.version === 'string'
}
const dependencyKeys = new Set([
'dependencies',
'devDependencies',

View File

@@ -96,6 +96,82 @@ test('readProjectManifest() converts engines runtime to dependencies', async ()
})
})
test('writeProjectManifest() removes a single devEngines runtime when its dependency was removed', async () => {
const dir = f.prepare('package-json-with-dev-engines')
const { manifest, writeProjectManifest } = await tryReadProjectManifest(dir)
delete manifest!.devDependencies!.node
await writeProjectManifest(manifest!)
const pkgJson = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
expect(pkgJson).toStrictEqual({
devEngines: {},
})
})
test('writeProjectManifest() removes an empty-version devEngines runtime when its dependency was removed', async () => {
const dir = f.prepare('package-json')
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
devDependencies: {
node: 'runtime:',
},
devEngines: {
runtime: {
name: 'node',
version: '',
onFail: 'download',
},
},
}))
const { manifest, writeProjectManifest } = await tryReadProjectManifest(dir)
delete manifest!.devDependencies!.node
await writeProjectManifest(manifest!)
const pkgJson = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
expect(pkgJson).toStrictEqual({
devEngines: {},
})
})
test('writeProjectManifest() removes only the removed runtime from a devEngines runtime array', async () => {
const dir = f.prepare('package-json')
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({
devEngines: {
runtime: [
{
name: 'node',
version: '24',
onFail: 'download',
},
{
name: 'deno',
version: '2',
onFail: 'download',
},
],
},
}))
const { manifest, writeProjectManifest } = await tryReadProjectManifest(dir)
delete manifest!.devDependencies!.node
await writeProjectManifest(manifest!)
const pkgJson = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
expect(pkgJson).toStrictEqual({
devDependencies: {},
devEngines: {
runtime: [
{
name: 'deno',
version: '2',
onFail: 'download',
},
],
},
})
})
test.each([
{
name: 'creates devEngines when it is missing',