fix(install): handle when bin entry info isn't present in package.json but is in registry (#28822)

Apparently things like the `bin` field can appear in the version info
from the registry, but not the package's `package.json`. I'm still not
sure how you actually achieve this, but it's the case for
`esbuild-wasm`. This fixes the following panic:

```
❯ deno i --node-modules-dir npm:esbuild-wasm
Add npm:esbuild-wasm@0.25.2
Initialize ⣯ [00:00]
 - esbuild-wasm@0.25.2



============================================================
Deno has panicked. This is a bug in Deno. Please report this
at https://github.com/denoland/deno/issues/new.
If you can reliably reproduce this panic, include the
reproduction steps and re-run with the RUST_BACKTRACE=1 env
var set and include the backtrace in your report.

Platform: macos aarch64
Version: 2.2.8+58c6c0b
Args: ["deno", "i", "--node-modules-dir", "npm:esbuild-wasm"]

View stack trace at:
https://panic.deno.com/v2.2.8+58c6c0bc9c1b4ee08645be936ff9268f17028f0f/aarch64-apple-darwin/g4h6Jo393pB4k4kqBo-3kqBg6klqBogtyLg13yLw_t0Lw549Hgj8-Hgw__H428-F4yv_HgjkpKww7gIon4gIw54rKwi5MorzMw5y7G42g7Iw---I40s-I4vu4Jw2rEw8z7Dwnr6J4tp7Bo_vvK

thread 'main' panicked at cli/npm/installer/common/bin_entries.rs:108:30:
called `Option::unwrap()` on a `None` value
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
```
This commit is contained in:
Nathan Whitaker 2025-04-10 09:36:26 -07:00 committed by GitHub
parent ac5c6018a8
commit ce5b9da11b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 226 additions and 38 deletions

View file

@ -1225,7 +1225,15 @@ impl CliOptions {
} }
pub fn default_npm_caching_strategy(&self) -> NpmCachingStrategy { pub fn default_npm_caching_strategy(&self) -> NpmCachingStrategy {
if self.flags.unstable_config.npm_lazy_caching { if matches!(
self.sub_command(),
DenoSubcommand::Install(InstallFlags::Local(
InstallFlagsLocal::TopLevel | InstallFlagsLocal::Add(_)
)) | DenoSubcommand::Add(_)
| DenoSubcommand::Outdated(_)
) {
NpmCachingStrategy::Manual
} else if self.flags.unstable_config.npm_lazy_caching {
NpmCachingStrategy::Lazy NpmCachingStrategy::Lazy
} else { } else {
NpmCachingStrategy::Eager NpmCachingStrategy::Eager

View file

@ -437,7 +437,10 @@ async fn resolve_custom_commands_from_packages<
extra.clone() extra.clone()
} else { } else {
let Ok(extra) = provider let Ok(extra) = provider
.get_package_extra_info(&package.id.nv, package.is_deprecated) .get_package_extra_info(
&package.id.nv,
super::ExpectedExtraInfo::from_package(package),
)
.await .await
else { else {
continue; continue;

View file

@ -8,6 +8,7 @@ use deno_core::parking_lot::RwLock;
use deno_error::JsErrorBox; use deno_error::JsErrorBox;
use deno_npm::registry::NpmRegistryApi; use deno_npm::registry::NpmRegistryApi;
use deno_npm::NpmPackageExtraInfo; use deno_npm::NpmPackageExtraInfo;
use deno_npm::NpmResolutionPackage;
use deno_semver::package::PackageNv; use deno_semver::package::PackageNv;
use super::PackageCaching; use super::PackageCaching;
@ -29,7 +30,7 @@ pub trait NpmPackageExtraInfoProvider: std::fmt::Debug + Send + Sync {
async fn get_package_extra_info( async fn get_package_extra_info(
&self, &self,
package_id: &PackageNv, package_id: &PackageNv,
is_deprecated: bool, expected: ExpectedExtraInfo,
) -> Result<deno_npm::NpmPackageExtraInfo, JsErrorBox>; ) -> Result<deno_npm::NpmPackageExtraInfo, JsErrorBox>;
} }
@ -39,11 +40,11 @@ impl<T: NpmPackageExtraInfoProvider + ?Sized> NpmPackageExtraInfoProvider
async fn get_package_extra_info( async fn get_package_extra_info(
&self, &self,
package_id: &PackageNv, package_id: &PackageNv,
is_deprecated: bool, expected: ExpectedExtraInfo,
) -> Result<deno_npm::NpmPackageExtraInfo, JsErrorBox> { ) -> Result<deno_npm::NpmPackageExtraInfo, JsErrorBox> {
self self
.as_ref() .as_ref()
.get_package_extra_info(package_id, is_deprecated) .get_package_extra_info(package_id, expected)
.await .await
} }
} }
@ -76,19 +77,28 @@ impl ExtraInfoProvider {
} }
} }
impl super::common::NpmPackageExtraInfoProvider for ExtraInfoProvider { #[derive(Debug, Clone, Copy, Default)]
async fn get_package_extra_info( pub struct ExpectedExtraInfo {
&self, pub deprecated: bool,
package_nv: &PackageNv, pub bin: bool,
deprecated: bool, pub scripts: bool,
) -> Result<NpmPackageExtraInfo, JsErrorBox> {
if let Some(extra_info) = self.cache.read().get(package_nv) {
return Ok(extra_info.clone());
} }
if deprecated { impl ExpectedExtraInfo {
// we need the registry version info to get the deprecated string, since it's not in the pub fn from_package(package: &NpmResolutionPackage) -> Self {
// package's package.json Self {
deprecated: package.is_deprecated,
bin: package.has_bin,
scripts: package.has_scripts,
}
}
}
impl ExtraInfoProvider {
async fn fetch_from_registry(
&self,
package_nv: &PackageNv,
) -> Result<NpmPackageExtraInfo, JsErrorBox> {
let package_info = self let package_info = self
.npm_registry_info_provider .npm_registry_info_provider
.package_info(&package_nv.name) .package_info(&package_nv.name)
@ -103,14 +113,56 @@ impl super::common::NpmPackageExtraInfoProvider for ExtraInfoProvider {
bin: version_info.bin.clone(), bin: version_info.bin.clone(),
scripts: version_info.scripts.clone(), scripts: version_info.scripts.clone(),
}) })
} else { }
async fn fetch_from_package_json(
&self,
package_nv: &PackageNv,
) -> Result<NpmPackageExtraInfo, JsErrorBox> {
let folder_path = self.npm_cache.package_folder_for_nv(package_nv); let folder_path = self.npm_cache.package_folder_for_nv(package_nv);
let package_json_path = folder_path.join("package.json"); let package_json_path = folder_path.join("package.json");
let extra_info: NpmPackageExtraInfo =
tokio::task::spawn_blocking(move || {
let package_json = std::fs::read_to_string(&package_json_path) let package_json = std::fs::read_to_string(&package_json_path)
.map_err(JsErrorBox::from_err)?; .map_err(JsErrorBox::from_err)?;
let extra_info: NpmPackageExtraInfo = let extra_info: NpmPackageExtraInfo =
deno_core::serde_json::from_str(&package_json) deno_core::serde_json::from_str(&package_json)
.map_err(JsErrorBox::from_err)?; .map_err(JsErrorBox::from_err)?;
Ok::<_, JsErrorBox>(extra_info)
})
.await
.map_err(JsErrorBox::from_err)??;
Ok(extra_info)
}
}
impl super::common::NpmPackageExtraInfoProvider for ExtraInfoProvider {
async fn get_package_extra_info(
&self,
package_nv: &PackageNv,
expected: ExpectedExtraInfo,
) -> Result<NpmPackageExtraInfo, JsErrorBox> {
if let Some(extra_info) = self.cache.read().get(package_nv) {
return Ok(extra_info.clone());
}
let extra_info = if expected.deprecated {
// we need the registry version info to get the deprecated string, since it's not in the
// package's package.json
self.fetch_from_registry(package_nv).await?
} else {
let extra_info = self.fetch_from_package_json(package_nv).await?;
// some packages have bin in registry but not in package.json (e.g. esbuild-wasm)
// still not sure how that happens
if (expected.bin && extra_info.bin.is_none())
|| (expected.scripts && extra_info.scripts.is_empty())
{
self.fetch_from_registry(package_nv).await?
} else {
extra_info
}
};
self self
.cache .cache
.write() .write()
@ -118,4 +170,3 @@ impl super::common::NpmPackageExtraInfoProvider for ExtraInfoProvider {
Ok(extra_info) Ok(extra_info)
} }
} }
}

View file

@ -414,12 +414,16 @@ async fn sync_resolution_with_fs(
Ok::<_, SyncResolutionWithFsError>(()) Ok::<_, SyncResolutionWithFsError>(())
} }
}); });
let extra_fut = if package.has_bin let extra_fut = if (package.has_bin
|| package.has_scripts || package.has_scripts
|| package.is_deprecated && package.extra.is_none() || package.is_deprecated)
&& package.extra.is_none()
{ {
extra_info_provider extra_info_provider
.get_package_extra_info(&package.id.nv, package.is_deprecated) .get_package_extra_info(
&package.id.nv,
super::common::ExpectedExtraInfo::from_package(package),
)
.boxed_local() .boxed_local()
} else { } else {
std::future::ready(Ok(package.extra.clone().unwrap_or_default())) std::future::ready(Ok(package.extra.clone().unwrap_or_default()))
@ -465,7 +469,10 @@ async fn sync_resolution_with_fs(
cache_futures.push( cache_futures.push(
async move { async move {
let extra = extra_info_provider let extra = extra_info_provider
.get_package_extra_info(&package.id.nv, package.is_deprecated) .get_package_extra_info(
&package.id.nv,
super::common::ExpectedExtraInfo::from_package(package),
)
.await .await
.map_err(JsErrorBox::from_err)?; .map_err(JsErrorBox::from_err)?;

View file

@ -0,0 +1,36 @@
{
"name": "denotest-packagejson-missing-info",
"dist-tags": {
"latest": "1.0.0-missingbin"
},
"versions": {
"1.0.0-missingbin": {
"name": "denotest-packagejson-missing-info",
"version": "1.0.0-missingbin",
"bin": {
"denotest": "./bin/denotest.js"
},
"dist": {
"tarball": "http://localhost:4260/denotest-packagejson-missing-info/denotest-packagejson-missing-info-1.0.0-missingbin.tgz"
}
},
"0.5.0-missingscripts": {
"name": "denotest-packagejson-missing-info",
"version": "0.5.0-missingscripts",
"scripts": {
"postinstall": "echo 'postinstall'"
},
"dist": {
"tarball": "http://localhost:4260/denotest-packagejson-missing-info/denotest-packagejson-missing-info-0.5.0-missingscripts.tgz"
}
},
"0.2.5-missingdeprecated": {
"name": "denotest-packagejson-missing-info",
"version": "0.2.5-missingdeprecated",
"deprecated": "Use 0.5.0 or later",
"dist": {
"tarball": "http://localhost:4260/denotest-packagejson-missing-info/denotest-packagejson-missing-info-0.2.5-missingdeprecated.tgz"
}
}
}
}

View file

@ -0,0 +1,45 @@
{
"tempDir": true,
"tests": {
"missing_bin": {
"steps": [
{
"args": "install npm:denotest-packagejson-missing-info@1.0.0-missingbin",
"output": "missingbin.out"
},
{
"args": "run -A ./has-bin.ts denotest",
"output": ""
}
]
},
"missing_scripts": {
"steps": [
{
"args": "install npm:denotest-packagejson-missing-info@0.5.0-missingscripts",
"output": "missingscripts.out"
},
{
"args": "install --allow-scripts",
"output": "[WILDCARD]running 'postinstall' script\n"
}
]
},
"missing_deprecated": {
"steps": [
{
"args": "install npm:denotest-packagejson-missing-info@0.2.5-missingdeprecated",
"output": "missingdeprecated.out"
}
]
},
"missing_deprecated_with_lazy_caching": {
"steps": [
{
"args": "install --unstable-npm-lazy-caching npm:denotest-packagejson-missing-info@0.2.5-missingdeprecated",
"output": "missingdeprecated.out"
}
]
}
}
}

View file

@ -0,0 +1,3 @@
{
"nodeModulesDir": "auto"
}

View file

@ -0,0 +1,20 @@
const name = Deno.args[0].trim();
function exists(path: string) {
try {
Deno.statSync(path);
return true;
} catch (error) {
return false;
}
}
if (
!(exists(`node_modules/.bin/${name}`) ||
exists(`node_modules/.bin/${name}.cmd`))
) {
console.log("missing bin");
console.log(`node_modules/.bin/${name}`);
console.log(Deno.readDirSync("node_modules/.bin").toArray());
Deno.exit(1);
}

View file

@ -0,0 +1,4 @@
Add npm:denotest-packagejson-missing-info@1.0.0-missingbin
Download http://localhost:4260/denotest-packagejson-missing-info
Download http://localhost:4260/denotest-packagejson-missing-info/denotest-packagejson-missing-info-1.0.0-missingbin.tgz
Initialize denotest-packagejson-missing-info@1.0.0-missingbin

View file

@ -0,0 +1,6 @@
Add npm:denotest-packagejson-missing-info@0.2.5-missingdeprecated
Download http://localhost:4260/denotest-packagejson-missing-info
Download http://localhost:4260/denotest-packagejson-missing-info/denotest-packagejson-missing-info-0.2.5-missingdeprecated.tgz
Initialize denotest-packagejson-missing-info@0.2.5-missingdeprecated
Warning The following packages are deprecated:
┖─ npm:denotest-packagejson-missing-info@0.2.5-missingdeprecated (Use 0.5.0 or later)

View file

@ -0,0 +1,5 @@
Add npm:denotest-packagejson-missing-info@0.5.0-missingscripts
Download http://localhost:4260/denotest-packagejson-missing-info
Download http://localhost:4260/denotest-packagejson-missing-info/denotest-packagejson-missing-info-0.5.0-missingscripts.tgz
Initialize denotest-packagejson-missing-info@0.5.0-missingscripts
Warning The following packages contained npm lifecycle scripts (preinstall/install/postinstall) that were not executed:[WILDCARD]