Repository: denoland/deno
Pull request: feat(cli): add `--save-exact`/`--exact` flag to `deno add` and `install`

<!--
Before submitting a PR, please read https://docs.deno.com/runtime/manual/references/contributing

1. Give the PR a descriptive title.

  Examples of good title:
    - fix(std/http): Fix race condition in server
    - docs(console): Update docstrings
    - feat(doc): Handle nested reexports

  Examples of bad title:
    - fix #7123
    - update docs
    - fix bugs

2. Ensure there is a related issue and it is referenced in the PR text.
3. Ensure there are tests that cover the changes.
4. Ensure `cargo test` passes.
5. Ensure `./tools/format.js` passes without changing files.
6. Ensure `./tools/lint.js` passes.
7. Open as a draft PR if your work is still in progress. The CI won't run
   all steps, but you can add '[ci]' to a commit message to force it to.
8. If you would like to run the benchmarks on the CI, add the 'ci-bench' label.
-->

Fixes #30803

### Description

This PR introduces the `--save-exact` flag (and its alias `--exact`) to the `deno add`. rn, deno defaults to prepending a caret (`^`) to installed versions (e.g., `^1.0.0`). This flag allows users to opt-out of this behavior and save the exact version string (e.g., `1.0.0`) to `deno.json` or `package.json`, aligning with the behavior found in `npm`, `pnpm`, and `yarn`.

### Changes

- Updated `AddFlags` in `cli/args/flags.rs` to include `save_exact`.
- Updated `cli/tools/pm/mod.rs` to pass this flag to the version selection logic.
- Implemented logic to return an empty prefix string `""` instead of `^` when the flag is present.


Changed files:
filename: cli/args/flags.rs
patch: @@ -118,6 +118,7 @@ pub struct AddFlags {
   pub dev: bool,
   pub default_registry: Option<DefaultRegistry>,
   pub lockfile_only: bool,
+  pub save_exact: bool,
 }
 
 #[derive(Clone, Debug, Default, Eq, PartialEq)]
@@ -2221,6 +2222,13 @@ Or multiple dependencies at once:
       .args(lock_args())
       .arg(lockfile_only_arg())
       .args(default_registry_args())
+      .arg(
+        Arg::new("save-exact")
+          .long("save-exact")
+          .alias("exact")
+          .help("Save exact version without the caret (^)")
+          .action(ArgAction::SetTrue),
+      )
   })
 }
 
@@ -3465,6 +3473,15 @@ These must be added to the path manually if required."), UnstableArgsConfig::Res
         .arg(env_file_arg())
         .arg(add_dev_arg().conflicts_with("entrypoint").conflicts_with("global"))
         .args(default_registry_args().into_iter().map(|arg| arg.conflicts_with("entrypoint").conflicts_with("global")))
+        .arg(
+          Arg::new("save-exact")
+            .long("save-exact")
+            .alias("exact")
+            .help("Save exact version without the caret (^)")
+            .action(ArgAction::SetTrue)
+            .conflicts_with("entrypoint")
+            .conflicts_with("global"),
+        )
         .arg(lockfile_only_arg().conflicts_with("global"))
     })
 }
@@ -5817,6 +5834,7 @@ fn add_parse_inner(
     dev,
     default_registry,
     lockfile_only: matches.get_flag("lockfile-only"),
+    save_exact: matches.get_flag("save-exact"),
   }
 }
 
@@ -13312,6 +13330,7 @@ mod tests {
             dev: false, // default is false
             default_registry: None,
             lockfile_only: false,
+            save_exact: false,
           })
         );
       }
@@ -13329,6 +13348,7 @@ mod tests {
           dev: false,
           default_registry: None,
           lockfile_only: true,
+          save_exact: false,
         });
         expected_flags.frozen_lockfile = Some(true);
         assert_eq!(r.unwrap(), expected_flags);
@@ -13342,6 +13362,7 @@ mod tests {
             dev: true,
             default_registry: None,
             lockfile_only: false,
+            save_exact: false,
           }),
         );
       }
@@ -13354,6 +13375,7 @@ mod tests {
             dev: false,
             default_registry: Some(DefaultRegistry::Npm),
             lockfile_only: false,
+            save_exact: false,
           }),
         );
       }
@@ -13366,6 +13388,7 @@ mod tests {
             dev: false,
             default_registry: Some(DefaultRegistry::Jsr),
             lockfile_only: false,
+            save_exact: false,
           }),
         );
       }

filename: cli/tools/pm/mod.rs
patch: @@ -404,6 +404,7 @@ pub async fn add(
   add_flags: AddFlags,
   cmd_name: AddCommandName,
 ) -> Result<(), AnyError> {
+  let save_exact = add_flags.save_exact;
   let (cli_factory, mut npm_config, mut deno_config) =
     load_configs(&flags, || {
       add_flags.packages.iter().any(|s| s.starts_with("jsr:"))
@@ -521,6 +522,7 @@ pub async fn add(
           jsr_resolver.clone(),
           npm_resolver.clone(),
           package_req,
+          save_exact,
         )
         .boxed_local()
       }
@@ -704,11 +706,13 @@ async fn find_package_and_select_version_for_req(
   jsr_resolver: Arc<JsrFetchResolver>,
   npm_resolver: Arc<NpmFetchResolver>,
   add_package_req: AddRmPackageReq,
+  save_exact: bool,
 ) -> Result<PackageAndVersion, AnyError> {
   async fn select<T: PackageInfoProvider, S: PackageInfoProvider>(
     main_resolver: T,
     fallback_resolver: S,
     add_package_req: AddRmPackageReq,
+    save_exact: bool,
   ) -> Result<PackageAndVersion, AnyError> {
     let req = match &add_package_req.value {
       AddRmPackageReqValue::Jsr(req) => req,
@@ -758,7 +762,9 @@ async fn find_package_and_select_version_for_req(
     };
     let range_symbol = if req.version_req.version_text().starts_with('~') {
       "~"
-    } else if req.version_req.version_text() == nv.version.to_string() {
+    } else if save_exact
+      || req.version_req.version_text() == nv.version.to_string()
+    {
       ""
     } else {
       "^"
@@ -773,10 +779,10 @@ async fn find_package_and_select_version_for_req(
 
   match &add_package_req.value {
     AddRmPackageReqValue::Jsr(_) => {
-      select(jsr_resolver, npm_resolver, add_package_req).await
+      select(jsr_resolver, npm_resolver, add_package_req, save_exact).await
     }
     AddRmPackageReqValue::Npm(_) => {
-      select(npm_resolver, jsr_resolver, add_package_req).await
+      select(npm_resolver, jsr_resolver, add_package_req, save_exact).await
     }
   }
 }

filename: tests/integration/pm_tests.rs
patch: @@ -103,6 +103,44 @@ fn add_tilde() {
   }));
 }
 
+#[test]
+fn add_save_exact() {
+  let context = pm_context_builder().build();
+  let temp_dir = context.temp_dir().path();
+
+  let output = context
+    .new_command()
+    .args("add jsr:@denotest/add --save-exact")
+    .run();
+  output.assert_exit_code(0);
+  let output = output.combined_output();
+  assert_contains!(output, "Add jsr:@denotest/add");
+  temp_dir.join("deno.json").assert_matches_json(json!({
+    "imports": {
+      "@denotest/add": "jsr:@denotest/add@1.0.0"
+    }
+  }));
+}
+
+#[test]
+fn add_exact_alias() {
+  let context = pm_context_builder().build();
+  let temp_dir = context.temp_dir().path();
+
+  let output = context
+    .new_command()
+    .args("add jsr:@denotest/add --exact")
+    .run();
+  output.assert_exit_code(0);
+  let output = output.combined_output();
+  assert_contains!(output, "Add jsr:@denotest/add");
+  temp_dir.join("deno.json").assert_matches_json(json!({
+    "imports": {
+      "@denotest/add": "jsr:@denotest/add@1.0.0"
+    }
+  }));
+}
+
 #[test]
 fn add_multiple() {
   let starting_deno_json = json!({