Repository: denoland/deno
Pull request: fix(add): Better error message when missing npm specifier

Before:
<img width="278" alt="Screenshot 2024-08-09 at 3 15 01 PM" src="https://github.com/user-attachments/assets/91b0ada6-93ee-4be6-a996-078aef98c2a9">

After:
<img width="888" alt="Screenshot 2024-08-09 at 3 52 15 PM" src="https://github.com/user-attachments/assets/3c88a0e8-c761-4f70-88bf-109355ac12f0">




Changed files:
filename: cli/main.rs
patch: @@ -98,7 +98,7 @@ fn spawn_subcommand<F: Future<Output = T> + 'static, T: SubcommandOutput>(
 async fn run_subcommand(flags: Arc<Flags>) -> Result<i32, AnyError> {
   let handle = match flags.subcommand.clone() {
     DenoSubcommand::Add(add_flags) => spawn_subcommand(async {
-      tools::registry::add(flags, add_flags).await
+      tools::registry::add(flags, add_flags, tools::registry::AddCommandName::Add).await
     }),
     DenoSubcommand::Bench(bench_flags) => spawn_subcommand(async {
       if bench_flags.watch.is_some() {

filename: cli/tools/installer.rs
patch: @@ -266,7 +266,12 @@ async fn install_local(
   maybe_add_flags: Option<AddFlags>,
 ) -> Result<(), AnyError> {
   if let Some(add_flags) = maybe_add_flags {
-    return super::registry::add(flags, add_flags).await;
+    return super::registry::add(
+      flags,
+      add_flags,
+      super::registry::AddCommandName::Install,
+    )
+    .await;
   }
 
   let factory = CliFactory::from_flags(flags);

filename: cli/tools/registry/mod.rs
patch: @@ -64,6 +64,7 @@ mod unfurl;
 use auth::get_auth_method;
 use auth::AuthMethod;
 pub use pm::add;
+pub use pm::AddCommandName;
 use publish_order::PublishOrderGraph;
 use unfurl::SpecifierUnfurler;
 

filename: cli/tools/registry/pm.rs
patch: @@ -175,9 +175,26 @@ fn package_json_dependency_entry(
   }
 }
 
+#[derive(Clone, Copy)]
+/// The name of the subcommand invoking the `add` operation.
+pub enum AddCommandName {
+  Add,
+  Install,
+}
+
+impl std::fmt::Display for AddCommandName {
+  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+    match self {
+      AddCommandName::Add => write!(f, "add"),
+      AddCommandName::Install => write!(f, "install"),
+    }
+  }
+}
+
 pub async fn add(
   flags: Arc<Flags>,
   add_flags: AddFlags,
+  cmd_name: AddCommandName,
 ) -> Result<(), AnyError> {
   let (config_file, cli_factory) =
     DenoOrPackageJson::from_flags(flags.clone())?;
@@ -234,8 +251,16 @@ pub async fn add(
     let package_and_version = package_and_version_result?;
 
     match package_and_version {
-      PackageAndVersion::NotFound(package_name) => {
-        bail!("{} was not found.", crate::colors::red(package_name));
+      PackageAndVersion::NotFound {
+        package: package_name,
+        found_npm_package,
+        package_req,
+      } => {
+        if found_npm_package {
+          bail!("{} was not found, but a matching npm package exists. Did you mean `{}`?", crate::colors::red(package_name), crate::colors::yellow(format!("deno {cmd_name} npm:{package_req}")));
+        } else {
+          bail!("{} was not found.", crate::colors::red(package_name));
+        }
       }
       PackageAndVersion::Selected(selected) => {
         selected_packages.push(selected);
@@ -327,7 +352,11 @@ struct SelectedPackage {
 }
 
 enum PackageAndVersion {
-  NotFound(String),
+  NotFound {
+    package: String,
+    found_npm_package: bool,
+    package_req: PackageReq,
+  },
   Selected(SelectedPackage),
 }
 
@@ -340,7 +369,19 @@ async fn find_package_and_select_version_for_req(
     AddPackageReqValue::Jsr(req) => {
       let jsr_prefixed_name = format!("jsr:{}", &req.name);
       let Some(nv) = jsr_resolver.req_to_nv(&req).await else {
-        return Ok(PackageAndVersion::NotFound(jsr_prefixed_name));
+        if npm_resolver.req_to_nv(&req).await.is_some() {
+          return Ok(PackageAndVersion::NotFound {
+            package: jsr_prefixed_name,
+            found_npm_package: true,
+            package_req: req,
+          });
+        }
+
+        return Ok(PackageAndVersion::NotFound {
+          package: jsr_prefixed_name,
+          found_npm_package: false,
+          package_req: req,
+        });
       };
       let range_symbol = if req.version_req.version_text().starts_with('~') {
         '~'
@@ -357,7 +398,11 @@ async fn find_package_and_select_version_for_req(
     AddPackageReqValue::Npm(req) => {
       let npm_prefixed_name = format!("npm:{}", &req.name);
       let Some(nv) = npm_resolver.req_to_nv(&req).await else {
-        return Ok(PackageAndVersion::NotFound(npm_prefixed_name));
+        return Ok(PackageAndVersion::NotFound {
+          package: npm_prefixed_name,
+          found_npm_package: false,
+          package_req: req,
+        });
       };
       let range_symbol = if req.version_req.version_text().starts_with('~') {
         '~'

filename: tests/specs/add/missing_npm_specifier/__test__.jsonc
patch: @@ -0,0 +1,28 @@
+{
+  "tempDir": true,
+  "steps": [
+    {
+      "args": "add ajv@latest",
+      "output": "error: jsr:ajv was not found, but a matching npm package exists. Did you mean `deno add npm:ajv@latest`?\n",
+      "exitCode": 1
+    },
+    {
+      "args": "add ajv",
+      "output": "error: jsr:ajv was not found, but a matching npm package exists. Did you mean `deno add npm:ajv`?\n",
+      "exitCode": 1
+    },
+    {
+      "args": "add ajv@8.11.0",
+      "output": "error: jsr:ajv was not found, but a matching npm package exists. Did you mean `deno add npm:ajv@8.11.0`?\n",
+      "exitCode": 1
+    },
+    {
+      "envs": {
+        "DENO_FUTURE": "1"
+      },
+      "args": "install ajv@latest",
+      "output": "error: jsr:ajv was not found, but a matching npm package exists. Did you mean `deno install npm:ajv@latest`?\n",
+      "exitCode": 1
+    }
+  ]
+}