Repository: denoland/deno
Pull request: feat(cli): add `deno create` command

Closes #31793

## Summary
- Add deno create as a first-class CLI subcommand
- Route it to the existing init package path (`InitFlags.package`)
- Keep npm-only behavior for `create` (no JSR support yet).
- Keep `--npm` support (similar to `deno init`) while also supporting explicit `npm:` package input.
- Fix npm-prefixed normalization to avoid `npm:create-npm:*`

## Test plan
- [x] `cargo check -p deno` passes
- [x] `cargo test -p deno create --lib` passes
- [x] `cargo test -p deno npm_name_to_create_package_test --lib` passes

## Manual check
- `deno create --yes vite`
Parser error: Missing `npm:` prefix. For example: `deno create npm:vite`.
- `deno create --yes --npm vite`
Resolves to `create-vite`.
- `deno create --yes npm:vite`
Resolves to `create-vite`.
- `deno create --yes npm:vite my-project`
Parser error (missing `--` before package args).
- `deno create --yes npm:vite -- my-project`
Resolves to `create-vite` with forwarded arg `my-project`.
- `deno create --yes jsr:@std/http`
Parser error: JSR packages are not supported by `deno create` yet. Use npm: or --npm.
- `deno create --yes --jsr @std/http`
Parser error: unexpected argument `--jsr`.
- `deno create --yes npm:`
Parser error: Missing package name after `'npm:'` prefix.

Changed files:
filename: cli/args/flags.rs
patch: @@ -1862,6 +1862,7 @@ pub fn flags_from_vec_with_initial_cwd(
         "check" => check_parse(&mut flags, &mut m)?,
         "clean" => clean_parse(&mut flags, &mut m),
         "compile" => compile_parse(&mut flags, &mut m)?,
+        "create" => create_parse(&mut flags, &mut m)?,
         "completions" => completions_parse(&mut flags, &mut m, app),
         "coverage" => coverage_parse(&mut flags, &mut m)?,
         "doc" => doc_parse(&mut flags, &mut m)?,
@@ -2128,6 +2129,7 @@ pub fn clap_root() -> Command {
         .subcommand(check_subcommand())
         .subcommand(clean_subcommand())
         .subcommand(compile_subcommand())
+        .subcommand(create_subcommand())
         .subcommand(completions_subcommand())
         .subcommand(coverage_subcommand())
         .subcommand(doc_subcommand())
@@ -3327,6 +3329,42 @@ fn init_subcommand() -> Command {
   )
 }
 
+fn create_subcommand() -> Command {
+  command(
+    "create",
+    "scaffolds a project from a package",
+    UnstableArgsConfig::None,
+  )
+  .defer(|cmd| {
+    cmd
+      .arg(
+        Arg::new("package")
+          .required_unless_present("help")
+          .value_name("PACKAGE"),
+      )
+      .arg(
+        Arg::new("package_args")
+          .num_args(0..)
+          .action(ArgAction::Append)
+          .value_name("ARGS")
+          .last(true),
+      )
+      .arg(
+        Arg::new("npm")
+          .long("npm")
+          .help("Treat unprefixed package names as npm packages")
+          .action(ArgAction::SetTrue),
+      )
+      .arg(
+        Arg::new("yes")
+          .short('y')
+          .long("yes")
+          .help("Bypass the prompt and run with full permissions")
+          .action(ArgAction::SetTrue),
+      )
+  })
+}
+
 fn info_subcommand() -> Command {
   command("info",
       cstr!("Show information about a module or the cache directories.
@@ -6361,6 +6399,63 @@ fn init_parse(
   Ok(())
 }
 
+fn create_parse(
+  flags: &mut Flags,
+  matches: &mut ArgMatches,
+) -> Result<(), clap::Error> {
+  let package = matches.remove_one::<String>("package").unwrap();
+  let use_npm = matches.get_flag("npm");
+  let package_args = matches
+    .remove_many::<String>("package_args")
+    .map(|args| args.collect::<Vec<_>>())
+    .unwrap_or_default();
+  let package = normalize_create_package_name(package, use_npm)?;
+
+  // `create` is a thin alias to init's package-generator path, not template mode.
+  flags.subcommand = DenoSubcommand::Init(InitFlags {
+    package: Some(package),
+    package_args,
+    dir: None,
+    lib: false,
+    serve: false,
+    empty: false,
+    yes: matches.get_flag("yes"),
+  });
+
+  Ok(())
+}
+
+fn normalize_create_package_name(
+  package: String,
+  allow_unprefixed: bool,
+) -> Result<String, clap::Error> {
+  if package.starts_with("jsr:") {
+    return Err(clap::Error::raw(
+      clap::error::ErrorKind::InvalidValue,
+      "JSR packages are not supported by `deno create` yet. Use npm: or --npm.",
+    ));
+  }
+
+  let name = package.strip_prefix("npm:").unwrap_or(&package);
+  if name.is_empty() {
+    return Err(clap::Error::raw(
+      clap::error::ErrorKind::InvalidValue,
+      "Missing package name after 'npm:' prefix.",
+    ));
+  }
+
+  if package.starts_with("npm:") {
+    Ok(package)
+  } else if allow_unprefixed {
+    Ok(format!("npm:{package}"))
+  } else {
+    Err(clap::Error::raw(
+      clap::error::ErrorKind::InvalidValue,
+      "Missing `npm:` prefix. For example: `deno create npm:vite`.",
+    ))
+  }
+}
+
 fn info_parse(
   flags: &mut Flags,
   matches: &mut ArgMatches,
@@ -13139,6 +13234,87 @@ mod tests {
     );
   }
 
+  #[test]
+  fn create() {
+    let r = flags_from_vec(svec!["deno", "create"]);
+    assert!(r.is_err());
+
+    let r = flags_from_vec(svec!["deno", "create", "vite"]);
+    assert!(r.is_err());
+
+    let r = flags_from_vec(svec!["deno", "create", "npm:vite", "my-project"]);
+    assert!(r.is_err());
+
+    let r =
+      flags_from_vec(svec!["deno", "create", "npm:vite", "--", "my-project"]);
+    assert_eq!(
+      r.unwrap(),
+      Flags {
+        subcommand: DenoSubcommand::Init(InitFlags {
+          package: Some("npm:vite".to_string()),
+          package_args: svec!["my-project"],
+          dir: None,
+          lib: false,
+          serve: false,
+          empty: false,
+          yes: false,
+        }),
+        ..Flags::default()
+      }
+    );
+
+    let r = flags_from_vec(svec!["deno", "create", "--npm", "vite"]);
+    assert_eq!(
+      r.unwrap(),
+      Flags {
+        subcommand: DenoSubcommand::Init(InitFlags {
+          package: Some("npm:vite".to_string()),
+          package_args: vec![],
+          dir: None,
+          lib: false,
+          serve: false,
+          empty: false,
+          yes: false,
+        }),
+        ..Flags::default()
+      }
+    );
+
+    let r =
+      flags_from_vec(svec!["deno", "create", "--npm", "vite", "my-project"]);
+    assert!(r.is_err());
+
+    let r = flags_from_vec(svec!["deno", "create", "--yes", "npm:vite"]);
+    assert_eq!(
+      r.unwrap(),
+      Flags {
+        subcommand: DenoSubcommand::Init(InitFlags {
+          package: Some("npm:vite".to_string()),
+          package_args: vec![],
+          dir: None,
+          lib: false,
+          serve: false,
+          empty: false,
+          yes: true,
+        }),
+        ..Flags::default()
+      }
+    );
+
+    let r =
+      flags_from_vec(svec!["deno", "create", "jsr:@std/http/file-server"]);
+    assert!(r.is_err());
+
+    let r = flags_from_vec(svec!["deno", "create", "--jsr", "@std/http"]);
+    assert!(r.is_err());
+
+    let r = flags_from_vec(svec!["deno", "create", "npm:"]);
+    assert!(r.is_err());
+
+    let r = flags_from_vec(svec!["deno", "create", "--npm", "jsr:@std/http"]);
+    assert!(r.is_err());
+  }
+
   #[test]
   fn jupyter() {
     let r = flags_from_vec(svec!["deno", "jupyter"]);

filename: cli/tools/init/mod.rs
patch: @@ -291,6 +291,8 @@ Deno.test(function addTest() {
 }
 
 fn npm_name_to_create_package(name: &str) -> String {
+  // Prevent already-prefixed `npm:<package>` from becoming `npm:create-npm:<package>`.
+  let name = name.strip_prefix("npm:").unwrap_or(name);
   let mut s = "npm:".to_string();
 
   let mut scoped = false;
@@ -484,5 +486,13 @@ mod test {
       npm_name_to_create_package("@foo/bar@1.0.0"),
       "npm:@foo/create-bar@1.0.0".to_string()
     );
+    assert_eq!(
+      npm_name_to_create_package("npm:vite"),
+      "npm:create-vite".to_string()
+    );
+    assert_eq!(
+      npm_name_to_create_package("npm:@foo/bar@1.0.0"),
+      "npm:@foo/create-bar@1.0.0".to_string()
+    );
   }
 }