From fb31ae73e40896c1d1dfdb26c265222f49907d32 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= <biwanczuk@gmail.com>
Date: Thu, 29 Feb 2024 19:12:04 +0000
Subject: [PATCH] feat(unstable): `deno add` subcommand (#22520)

This commit adds "deno add" subcommand that has a basic support for
adding "jsr:" packages to "deno.json" file.

This currently doesn't support "npm:" specifiers and specifying version
constraints.
---
 cli/args/flags.rs                             |  71 ++++-
 cli/lsp/mod.rs                                |   4 +-
 cli/main.rs                                   |   3 +
 cli/tools/registry/mod.rs                     |   2 +
 cli/tools/registry/pm.rs                      | 290 ++++++++++++++++++
 tests/integration/mod.rs                      |   2 +
 tests/integration/pm_tests.rs                 | 108 +++++++
 .../0.1.0/mod.ts                              |   0
 .../0.1.0_meta.json                           |   0
 .../meta.json                                 |   0
 .../0.1.0/mod.ts                              |   0
 .../0.1.0_meta.json                           |   0
 .../meta.json                                 |   0
 .../jsr/subset_type_graph/main.check.out      |  18 +-
 tests/testdata/jsr/subset_type_graph/main.ts  |   4 +-
 tests/util/server/src/servers/registry.rs     |   7 +-
 16 files changed, 492 insertions(+), 17 deletions(-)
 create mode 100644 cli/tools/registry/pm.rs
 create mode 100644 tests/integration/pm_tests.rs
 rename tests/testdata/jsr/registry/@denotest/{subset_type_graph_invalid => subset-type-graph-invalid}/0.1.0/mod.ts (100%)
 rename tests/testdata/jsr/registry/@denotest/{subset_type_graph => subset-type-graph-invalid}/0.1.0_meta.json (100%)
 rename tests/testdata/jsr/registry/@denotest/{subset_type_graph => subset-type-graph-invalid}/meta.json (100%)
 rename tests/testdata/jsr/registry/@denotest/{subset_type_graph => subset-type-graph}/0.1.0/mod.ts (100%)
 rename tests/testdata/jsr/registry/@denotest/{subset_type_graph_invalid => subset-type-graph}/0.1.0_meta.json (100%)
 rename tests/testdata/jsr/registry/@denotest/{subset_type_graph_invalid => subset-type-graph}/meta.json (100%)

diff --git a/cli/args/flags.rs b/cli/args/flags.rs
index ec4433f580..05d9a39732 100644
--- a/cli/args/flags.rs
+++ b/cli/args/flags.rs
@@ -35,6 +35,11 @@ pub struct FileFlags {
   pub include: Vec<String>,
 }
 
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct AddFlags {
+  pub packages: Vec<String>,
+}
+
 #[derive(Clone, Debug, Default, Eq, PartialEq)]
 pub struct BenchFlags {
   pub files: FileFlags,
@@ -307,6 +312,7 @@ pub struct PublishFlags {
 
 #[derive(Clone, Debug, Eq, PartialEq)]
 pub enum DenoSubcommand {
+  Add(AddFlags),
   Bench(BenchFlags),
   Bundle(BundleFlags),
   Cache(CacheFlags),
@@ -760,9 +766,9 @@ impl Flags {
       | Test(_) | Bench(_) | Repl(_) | Compile(_) | Publish(_) => {
         std::env::current_dir().ok()
       }
-      Bundle(_) | Completions(_) | Doc(_) | Fmt(_) | Init(_) | Install(_)
-      | Uninstall(_) | Jupyter(_) | Lsp | Lint(_) | Types | Upgrade(_)
-      | Vendor(_) => None,
+      Add(_) | Bundle(_) | Completions(_) | Doc(_) | Fmt(_) | Init(_)
+      | Install(_) | Uninstall(_) | Jupyter(_) | Lsp | Lint(_) | Types
+      | Upgrade(_) | Vendor(_) => None,
     }
   }
 
@@ -923,6 +929,7 @@ pub fn flags_from_vec(args: Vec<String>) -> clap::error::Result<Flags> {
 
   if let Some((subcommand, mut m)) = matches.remove_subcommand() {
     match subcommand.as_str() {
+      "add" => add_parse(&mut flags, &mut m),
       "bench" => bench_parse(&mut flags, &mut m),
       "bundle" => bundle_parse(&mut flags, &mut m),
       "cache" => cache_parse(&mut flags, &mut m),
@@ -1078,6 +1085,7 @@ fn clap_root() -> Command {
     .subcommand(run_subcommand())
     .defer(|cmd| {
       cmd
+        .subcommand(add_subcommand())
         .subcommand(bench_subcommand())
         .subcommand(bundle_subcommand())
         .subcommand(cache_subcommand())
@@ -1107,6 +1115,30 @@ fn clap_root() -> Command {
     .after_help(ENV_VARIABLES_HELP)
 }
 
+fn add_subcommand() -> Command {
+  Command::new("add")
+    .about("Add dependencies")
+    .long_about(
+      "Add dependencies to the configuration file.
+
+  deno add @std/path
+
+You can add multiple dependencies at once:
+
+  deno add @std/path @std/assert
+",
+    )
+    .defer(|cmd| {
+      cmd.arg(
+        Arg::new("packages")
+          .help("List of packages to add")
+          .required(true)
+          .num_args(1..)
+          .action(ArgAction::Append),
+      )
+    })
+}
+
 fn bench_subcommand() -> Command {
   Command::new("bench")
     .about("Run benchmarks")
@@ -3218,6 +3250,11 @@ fn unsafely_ignore_certificate_errors_arg() -> Arg {
     .value_parser(flags_net::validator)
 }
 
+fn add_parse(flags: &mut Flags, matches: &mut ArgMatches) {
+  let packages = matches.remove_many::<String>("packages").unwrap().collect();
+  flags.subcommand = DenoSubcommand::Add(AddFlags { packages });
+}
+
 fn bench_parse(flags: &mut Flags, matches: &mut ArgMatches) {
   flags.type_check_mode = TypeCheckMode::Local;
 
@@ -8599,4 +8636,32 @@ mod tests {
       }
     );
   }
+
+  #[test]
+  fn add_subcommand() {
+    let r = flags_from_vec(svec!["deno", "add"]);
+    r.unwrap_err();
+
+    let r = flags_from_vec(svec!["deno", "add", "@david/which"]);
+    assert_eq!(
+      r.unwrap(),
+      Flags {
+        subcommand: DenoSubcommand::Add(AddFlags {
+          packages: svec!["@david/which"],
+        }),
+        ..Flags::default()
+      }
+    );
+
+    let r = flags_from_vec(svec!["deno", "add", "@david/which", "@luca/hello"]);
+    assert_eq!(
+      r.unwrap(),
+      Flags {
+        subcommand: DenoSubcommand::Add(AddFlags {
+          packages: svec!["@david/which", "@luca/hello"],
+        }),
+        ..Flags::default()
+      }
+    );
+  }
 }
diff --git a/cli/lsp/mod.rs b/cli/lsp/mod.rs
index f15d2a3658..a2d0854642 100644
--- a/cli/lsp/mod.rs
+++ b/cli/lsp/mod.rs
@@ -21,7 +21,7 @@ mod completions;
 mod config;
 mod diagnostics;
 mod documents;
-mod jsr;
+pub mod jsr;
 pub mod language_server;
 mod logging;
 mod lsp_custom;
@@ -32,7 +32,7 @@ mod performance;
 mod refactor;
 mod registries;
 mod repl;
-mod search;
+pub mod search;
 mod semantic_tokens;
 mod testing;
 mod text;
diff --git a/cli/main.rs b/cli/main.rs
index 5e446efb8f..60d10badcb 100644
--- a/cli/main.rs
+++ b/cli/main.rs
@@ -88,6 +88,9 @@ fn spawn_subcommand<F: Future<Output = T> + 'static, T: SubcommandOutput>(
 
 async fn run_subcommand(flags: Flags) -> Result<i32, AnyError> {
   let handle = match flags.subcommand.clone() {
+    DenoSubcommand::Add(add_flags) => spawn_subcommand(async {
+      tools::registry::add(flags, add_flags).await
+    }),
     DenoSubcommand::Bench(bench_flags) => spawn_subcommand(async {
       if bench_flags.watch.is_some() {
         tools::bench::run_benchmarks_with_watch(flags, bench_flags).await
diff --git a/cli/tools/registry/mod.rs b/cli/tools/registry/mod.rs
index 4e1b9d5e14..bb8f62a5ed 100644
--- a/cli/tools/registry/mod.rs
+++ b/cli/tools/registry/mod.rs
@@ -50,6 +50,7 @@ mod auth;
 mod diagnostics;
 mod graph;
 mod paths;
+mod pm;
 mod provenance;
 mod publish_order;
 mod tar;
@@ -57,6 +58,7 @@ mod unfurl;
 
 use auth::get_auth_method;
 use auth::AuthMethod;
+pub use pm::add;
 use publish_order::PublishOrderGraph;
 pub use unfurl::deno_json_deps;
 use unfurl::SpecifierUnfurler;
diff --git a/cli/tools/registry/pm.rs b/cli/tools/registry/pm.rs
new file mode 100644
index 0000000000..a3fa8a0f3f
--- /dev/null
+++ b/cli/tools/registry/pm.rs
@@ -0,0 +1,290 @@
+// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
+
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+use deno_ast::TextChange;
+use deno_config::FmtOptionsConfig;
+use deno_core::anyhow::bail;
+use deno_core::anyhow::Context;
+use deno_core::error::AnyError;
+use deno_core::futures::FutureExt;
+use deno_core::futures::StreamExt;
+use deno_core::serde_json;
+use deno_semver::jsr::JsrPackageReqReference;
+use deno_semver::npm::NpmPackageReqReference;
+use deno_semver::package::PackageReq;
+use jsonc_parser::ast::ObjectProp;
+use jsonc_parser::ast::Value;
+
+use crate::args::AddFlags;
+use crate::args::CacheSetting;
+use crate::args::Flags;
+use crate::factory::CliFactory;
+use crate::file_fetcher::FileFetcher;
+use crate::lsp::jsr::CliJsrSearchApi;
+use crate::lsp::search::PackageSearchApi;
+
+pub async fn add(flags: Flags, add_flags: AddFlags) -> Result<(), AnyError> {
+  let cli_factory = CliFactory::from_flags(flags.clone()).await?;
+  let cli_options = cli_factory.cli_options();
+
+  let Some(config_file) = cli_options.maybe_config_file() else {
+    tokio::fs::write(cli_options.initial_cwd().join("deno.json"), "{}\n")
+      .await
+      .context("Failed to create deno.json file")?;
+    log::info!("Created deno.json configuration file.");
+    return add(flags, add_flags).boxed_local().await;
+  };
+
+  if config_file.specifier.scheme() != "file" {
+    bail!("Can't add dependencies to a remote configuration file");
+  }
+  let config_file_path = config_file.specifier.to_file_path().unwrap();
+
+  let http_client = cli_factory.http_client();
+
+  let mut selected_packages = Vec::with_capacity(add_flags.packages.len());
+  let mut package_reqs = Vec::with_capacity(add_flags.packages.len());
+
+  for package_name in add_flags.packages.iter() {
+    let req = if package_name.starts_with("npm:") {
+      let pkg_req = NpmPackageReqReference::from_str(package_name)
+        .with_context(|| {
+          format!("Failed to parse package required: {}", package_name)
+        })?;
+      AddPackageReq::Npm(pkg_req)
+    } else {
+      let pkg_req = JsrPackageReqReference::from_str(&format!(
+        "jsr:{}",
+        package_name.strip_prefix("jsr:").unwrap_or(package_name)
+      ))
+      .with_context(|| {
+        format!("Failed to parse package required: {}", package_name)
+      })?;
+      AddPackageReq::Jsr(pkg_req)
+    };
+
+    package_reqs.push(req);
+  }
+
+  let deps_http_cache = cli_factory.global_http_cache()?;
+  let mut deps_file_fetcher = FileFetcher::new(
+    deps_http_cache.clone(),
+    CacheSetting::ReloadAll,
+    true,
+    http_client.clone(),
+    Default::default(),
+    None,
+  );
+  deps_file_fetcher.set_download_log_level(log::Level::Trace);
+  let jsr_search_api = CliJsrSearchApi::new(deps_file_fetcher);
+
+  let package_futures = package_reqs
+    .into_iter()
+    .map(|package_req| {
+      find_package_and_select_version_for_req(
+        jsr_search_api.clone(),
+        package_req,
+      )
+      .boxed_local()
+    })
+    .collect::<Vec<_>>();
+
+  let stream_of_futures = deno_core::futures::stream::iter(package_futures);
+  let mut buffered = stream_of_futures.buffer_unordered(10);
+
+  while let Some(package_and_version_result) = buffered.next().await {
+    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::Selected(selected) => {
+        selected_packages.push(selected);
+      }
+    }
+  }
+
+  let config_file_contents =
+    tokio::fs::read_to_string(&config_file_path).await.unwrap();
+  let ast = jsonc_parser::parse_to_ast(
+    &config_file_contents,
+    &Default::default(),
+    &Default::default(),
+  )?;
+
+  let obj = match ast.value {
+    Some(Value::Object(obj)) => obj,
+    _ => bail!("Failed updating config file due to no object."),
+  };
+
+  let mut existing_imports =
+    if let Some(imports) = config_file.json.imports.clone() {
+      match serde_json::from_value::<HashMap<String, String>>(imports) {
+        Ok(i) => i,
+        Err(_) => bail!("Malformed \"imports\" configuration"),
+      }
+    } else {
+      HashMap::default()
+    };
+
+  for selected_package in selected_packages {
+    log::info!(
+      "Add {} - {}@{}",
+      crate::colors::green(&selected_package.import_name),
+      selected_package.package_name,
+      selected_package.version_req
+    );
+    existing_imports.insert(
+      selected_package.import_name,
+      format!(
+        "{}@{}",
+        selected_package.package_name, selected_package.version_req
+      ),
+    );
+  }
+  let mut import_list: Vec<(String, String)> =
+    existing_imports.into_iter().collect();
+
+  import_list.sort_by(|(k1, _), (k2, _)| k1.cmp(k2));
+  let generated_imports = generate_imports(import_list);
+
+  let fmt_config_options = config_file
+    .to_fmt_config()
+    .ok()
+    .flatten()
+    .map(|config| config.options)
+    .unwrap_or_default();
+
+  let new_text = update_config_file_content(
+    obj,
+    &config_file_contents,
+    generated_imports,
+    fmt_config_options,
+  );
+
+  tokio::fs::write(&config_file_path, new_text)
+    .await
+    .context("Failed to update configuration file")?;
+
+  // TODO(bartlomieju): we should now cache the imports from the config file.
+
+  Ok(())
+}
+
+struct SelectedPackage {
+  import_name: String,
+  package_name: String,
+  version_req: String,
+}
+
+enum PackageAndVersion {
+  NotFound(String),
+  Selected(SelectedPackage),
+}
+
+async fn jsr_find_package_and_select_version(
+  jsr_search_api: CliJsrSearchApi,
+  req: &PackageReq,
+) -> Result<PackageAndVersion, AnyError> {
+  let jsr_prefixed_name = format!("jsr:{}", req.name);
+
+  // TODO(bartlomieju): Need to do semver as well - @luca/flag@^0.14 should use to
+  // highest possible `0.14.x` version.
+  let version_req = req.version_req.version_text();
+  if version_req != "*" {
+    bail!("Specifying version constraints is currently not supported. Package: {}@{}", jsr_prefixed_name, version_req);
+  }
+
+  let Ok(versions) = jsr_search_api.versions(&req.name).await else {
+    return Ok(PackageAndVersion::NotFound(jsr_prefixed_name));
+  };
+
+  let Some(latest_version) = versions.first() else {
+    return Ok(PackageAndVersion::NotFound(jsr_prefixed_name));
+  };
+
+  Ok(PackageAndVersion::Selected(SelectedPackage {
+    import_name: req.name.to_string(),
+    package_name: jsr_prefixed_name,
+    // TODO(bartlomieju): fix it, it should not always be caret
+    version_req: format!("^{}", latest_version),
+  }))
+}
+
+async fn find_package_and_select_version_for_req(
+  jsr_search_api: CliJsrSearchApi,
+  add_package_req: AddPackageReq,
+) -> Result<PackageAndVersion, AnyError> {
+  match add_package_req {
+    AddPackageReq::Jsr(pkg_ref) => {
+      jsr_find_package_and_select_version(jsr_search_api, pkg_ref.req()).await
+    }
+    AddPackageReq::Npm(pkg_req) => {
+      bail!(
+        "Adding npm: packages is currently not supported. Package: npm:{}",
+        pkg_req.req().name
+      );
+    }
+  }
+}
+
+enum AddPackageReq {
+  Jsr(JsrPackageReqReference),
+  Npm(NpmPackageReqReference),
+}
+
+fn generate_imports(packages_to_version: Vec<(String, String)>) -> String {
+  let mut contents = vec![];
+  let len = packages_to_version.len();
+  for (index, (package, version)) in packages_to_version.iter().enumerate() {
+    // TODO(bartlomieju): fix it, once we start support specifying version on the cli
+    contents.push(format!("\"{}\": \"{}\"", package, version));
+    if index != len - 1 {
+      contents.push(",".to_string());
+    }
+  }
+  contents.join("\n")
+}
+
+fn update_config_file_content(
+  obj: jsonc_parser::ast::Object,
+  config_file_contents: &str,
+  generated_imports: String,
+  fmt_options: FmtOptionsConfig,
+) -> String {
+  let mut text_changes = vec![];
+
+  match obj.get("imports") {
+    Some(ObjectProp {
+      value: Value::Object(lit),
+      ..
+    }) => text_changes.push(TextChange {
+      range: (lit.range.start + 1)..(lit.range.end - 1),
+      new_text: generated_imports,
+    }),
+    None => {
+      let insert_position = obj.range.end - 1;
+      text_changes.push(TextChange {
+        range: insert_position..insert_position,
+        new_text: format!("\"imports\": {{ {} }}", generated_imports),
+      })
+    }
+    // we verified the shape of `imports` above
+    Some(_) => unreachable!(),
+  }
+
+  let new_text =
+    deno_ast::apply_text_changes(config_file_contents, text_changes);
+
+  crate::tools::fmt::format_json(
+    &PathBuf::from("deno.json"),
+    &new_text,
+    &fmt_options,
+  )
+  .ok()
+  .map(|formatted_text| formatted_text.unwrap_or_else(|| new_text.clone()))
+  .unwrap_or(new_text)
+}
diff --git a/tests/integration/mod.rs b/tests/integration/mod.rs
index 89a66385e8..9253cae32e 100644
--- a/tests/integration/mod.rs
+++ b/tests/integration/mod.rs
@@ -50,6 +50,8 @@ mod node_compat_tests;
 mod node_unit_tests;
 #[path = "npm_tests.rs"]
 mod npm;
+#[path = "pm_tests.rs"]
+mod pm;
 #[path = "publish_tests.rs"]
 mod publish;
 
diff --git a/tests/integration/pm_tests.rs b/tests/integration/pm_tests.rs
new file mode 100644
index 0000000000..4e0345331d
--- /dev/null
+++ b/tests/integration/pm_tests.rs
@@ -0,0 +1,108 @@
+// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
+
+use deno_core::serde_json::json;
+use test_util::assert_contains;
+use test_util::env_vars_for_jsr_tests;
+// use test_util::env_vars_for_npm_tests;
+// use test_util::itest;
+use test_util::TestContextBuilder;
+
+#[test]
+fn add_basic() {
+  let starting_deno_json = json!({
+    "name": "@foo/bar",
+    "version": "1.0.0",
+    "exports": "./mod.ts",
+  });
+  let context = pm_context_builder().build();
+  let temp_dir = context.temp_dir().path();
+  temp_dir.join("deno.json").write_json(&starting_deno_json);
+
+  let output = context.new_command().args("add @denotest/add").run();
+  output.assert_exit_code(0);
+  let output = output.combined_output();
+  assert_contains!(output, "Add @denotest/add");
+  temp_dir.join("deno.json").assert_matches_json(json!({
+    "name": "@foo/bar",
+    "version": "1.0.0",
+    "exports": "./mod.ts",
+    "imports": {
+      "@denotest/add": "jsr:@denotest/add@^1.0.0"
+    }
+  }));
+}
+
+#[test]
+fn add_basic_no_deno_json() {
+  let context = pm_context_builder().build();
+  let temp_dir = context.temp_dir().path();
+
+  let output = context.new_command().args("add @denotest/add").run();
+  output.assert_exit_code(0);
+  let output = output.combined_output();
+  assert_contains!(output, "Add @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!({
+    "name": "@foo/bar",
+    "version": "1.0.0",
+    "exports": "./mod.ts",
+  });
+  let context = pm_context_builder().build();
+  let temp_dir = context.temp_dir().path();
+  temp_dir.join("deno.json").write_json(&starting_deno_json);
+
+  let output = context
+    .new_command()
+    .args("add @denotest/add @denotest/subset-type-graph")
+    .run();
+  output.assert_exit_code(0);
+  let output = output.combined_output();
+  assert_contains!(output, "Add @denotest/add");
+  temp_dir.join("deno.json").assert_matches_json(json!({
+    "name": "@foo/bar",
+    "version": "1.0.0",
+    "exports": "./mod.ts",
+    "imports": {
+      "@denotest/add": "jsr:@denotest/add@^1.0.0",
+      "@denotest/subset-type-graph": "jsr:@denotest/subset-type-graph@^0.1.0"
+    }
+  }));
+}
+
+#[test]
+fn add_not_supported_npm() {
+  let context = pm_context_builder().build();
+
+  let output = context
+    .new_command()
+    .args("add @denotest/add npm:express")
+    .run();
+  output.assert_exit_code(1);
+  let output = output.combined_output();
+  assert_contains!(output, "error: Adding npm: packages is currently not supported. Package: npm:express");
+}
+
+#[test]
+fn add_not_supported_version_constraint() {
+  let context = pm_context_builder().build();
+
+  let output = context.new_command().args("add @denotest/add@1").run();
+  output.assert_exit_code(1);
+  let output = output.combined_output();
+  assert_contains!(output, "error: Specifying version constraints is currently not supported. Package: jsr:@denotest/add@1");
+}
+
+fn pm_context_builder() -> TestContextBuilder {
+  TestContextBuilder::new()
+    .use_http_server()
+    .envs(env_vars_for_jsr_tests())
+    .use_temp_cwd()
+}
diff --git a/tests/testdata/jsr/registry/@denotest/subset_type_graph_invalid/0.1.0/mod.ts b/tests/testdata/jsr/registry/@denotest/subset-type-graph-invalid/0.1.0/mod.ts
similarity index 100%
rename from tests/testdata/jsr/registry/@denotest/subset_type_graph_invalid/0.1.0/mod.ts
rename to tests/testdata/jsr/registry/@denotest/subset-type-graph-invalid/0.1.0/mod.ts
diff --git a/tests/testdata/jsr/registry/@denotest/subset_type_graph/0.1.0_meta.json b/tests/testdata/jsr/registry/@denotest/subset-type-graph-invalid/0.1.0_meta.json
similarity index 100%
rename from tests/testdata/jsr/registry/@denotest/subset_type_graph/0.1.0_meta.json
rename to tests/testdata/jsr/registry/@denotest/subset-type-graph-invalid/0.1.0_meta.json
diff --git a/tests/testdata/jsr/registry/@denotest/subset_type_graph/meta.json b/tests/testdata/jsr/registry/@denotest/subset-type-graph-invalid/meta.json
similarity index 100%
rename from tests/testdata/jsr/registry/@denotest/subset_type_graph/meta.json
rename to tests/testdata/jsr/registry/@denotest/subset-type-graph-invalid/meta.json
diff --git a/tests/testdata/jsr/registry/@denotest/subset_type_graph/0.1.0/mod.ts b/tests/testdata/jsr/registry/@denotest/subset-type-graph/0.1.0/mod.ts
similarity index 100%
rename from tests/testdata/jsr/registry/@denotest/subset_type_graph/0.1.0/mod.ts
rename to tests/testdata/jsr/registry/@denotest/subset-type-graph/0.1.0/mod.ts
diff --git a/tests/testdata/jsr/registry/@denotest/subset_type_graph_invalid/0.1.0_meta.json b/tests/testdata/jsr/registry/@denotest/subset-type-graph/0.1.0_meta.json
similarity index 100%
rename from tests/testdata/jsr/registry/@denotest/subset_type_graph_invalid/0.1.0_meta.json
rename to tests/testdata/jsr/registry/@denotest/subset-type-graph/0.1.0_meta.json
diff --git a/tests/testdata/jsr/registry/@denotest/subset_type_graph_invalid/meta.json b/tests/testdata/jsr/registry/@denotest/subset-type-graph/meta.json
similarity index 100%
rename from tests/testdata/jsr/registry/@denotest/subset_type_graph_invalid/meta.json
rename to tests/testdata/jsr/registry/@denotest/subset-type-graph/meta.json
diff --git a/tests/testdata/jsr/subset_type_graph/main.check.out b/tests/testdata/jsr/subset_type_graph/main.check.out
index 2788845794..f46610c0a5 100644
--- a/tests/testdata/jsr/subset_type_graph/main.check.out
+++ b/tests/testdata/jsr/subset_type_graph/main.check.out
@@ -1,16 +1,16 @@
-Download http://127.0.0.1:4250/@denotest/subset_type_graph/meta.json
-Download http://127.0.0.1:4250/@denotest/subset_type_graph_invalid/meta.json
-Download http://127.0.0.1:4250/@denotest/subset_type_graph/0.1.0_meta.json
-Download http://127.0.0.1:4250/@denotest/subset_type_graph_invalid/0.1.0_meta.json
+Download http://127.0.0.1:4250/@denotest/subset-type-graph/meta.json
+Download http://127.0.0.1:4250/@denotest/subset-type-graph-invalid/meta.json
+Download http://127.0.0.1:4250/@denotest/subset-type-graph/0.1.0_meta.json
+Download http://127.0.0.1:4250/@denotest/subset-type-graph-invalid/0.1.0_meta.json
 [UNORDERED_START]
-Download http://127.0.0.1:4250/@denotest/subset_type_graph/0.1.0/mod.ts
-Download http://127.0.0.1:4250/@denotest/subset_type_graph_invalid/0.1.0/mod.ts
+Download http://127.0.0.1:4250/@denotest/subset-type-graph/0.1.0/mod.ts
+Download http://127.0.0.1:4250/@denotest/subset-type-graph-invalid/0.1.0/mod.ts
 [UNORDERED_END]
 Check file:///[WILDCARD]/subset_type_graph/main.ts
 error: TS2322 [ERROR]: Type 'string' is not assignable to type 'number'.
 const invalidTypeCheck: number = "";
       ~~~~~~~~~~~~~~~~
-    at http://127.0.0.1:4250/@denotest/subset_type_graph_invalid/0.1.0/mod.ts:11:7
+    at http://127.0.0.1:4250/@denotest/subset-type-graph-invalid/0.1.0/mod.ts:11:7
 
 TS2322 [ERROR]: Type 'number' is not assignable to type 'string'.
 const error1: string = new Foo1().method();
@@ -30,7 +30,7 @@ new Foo1().method2();
     'method' is declared here.
       method(): number {
       ~~~~~~
-        at http://127.0.0.1:4250/@denotest/subset_type_graph/0.1.0/mod.ts:8:3
+        at http://127.0.0.1:4250/@denotest/subset-type-graph/0.1.0/mod.ts:8:3
 
 TS2551 [ERROR]: Property 'method2' does not exist on type 'Foo'. Did you mean 'method'?
 new Foo2().method2();
@@ -40,6 +40,6 @@ new Foo2().method2();
     'method' is declared here.
       method() {
       ~~~~~~
-        at http://127.0.0.1:4250/@denotest/subset_type_graph_invalid/0.1.0/mod.ts:2:3
+        at http://127.0.0.1:4250/@denotest/subset-type-graph-invalid/0.1.0/mod.ts:2:3
 
 Found 5 errors.
diff --git a/tests/testdata/jsr/subset_type_graph/main.ts b/tests/testdata/jsr/subset_type_graph/main.ts
index 2e1614be9c..2fff966a7a 100644
--- a/tests/testdata/jsr/subset_type_graph/main.ts
+++ b/tests/testdata/jsr/subset_type_graph/main.ts
@@ -1,5 +1,5 @@
-import { Foo as Foo1 } from "jsr:@denotest/subset_type_graph@0.1.0";
-import { Foo as Foo2 } from "jsr:@denotest/subset_type_graph_invalid@0.1.0";
+import { Foo as Foo1 } from "jsr:@denotest/subset-type-graph@0.1.0";
+import { Foo as Foo2 } from "jsr:@denotest/subset-type-graph-invalid@0.1.0";
 
 // these will both raise type checking errors
 const error1: string = new Foo1().method();
diff --git a/tests/util/server/src/servers/registry.rs b/tests/util/server/src/servers/registry.rs
index 1a0caff1ff..09b80c8d5b 100644
--- a/tests/util/server/src/servers/registry.rs
+++ b/tests/util/server/src/servers/registry.rs
@@ -142,7 +142,12 @@ async fn registry_server_handler(
   // serve the registry package files
   let mut file_path =
     testdata_path().to_path_buf().join("jsr").join("registry");
-  file_path.push(&req.uri().path()[1..].replace("%2f", "/"));
+  file_path.push(
+    &req.uri().path()[1..]
+      .replace("%2f", "/")
+      .replace("%2F", "/"),
+  );
+
   if let Ok(body) = tokio::fs::read(&file_path).await {
     let body = if let Some(version) = file_path
       .file_name()