From d17fae0b614f283c17604b76a2a73a1916f9486b Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Wed, 11 Feb 2026 14:51:27 +0800 Subject: [PATCH 1/4] feat: utoo init --- crates/pm/Cargo.toml | 2 +- crates/pm/src/cmd/init.rs | 371 +++++++++++++++++++++++++++++++++++++ crates/pm/src/cmd/mod.rs | 1 + crates/pm/src/constants.rs | 4 + crates/pm/src/main.rs | 23 ++- 5 files changed, 395 insertions(+), 6 deletions(-) create mode 100644 crates/pm/src/cmd/init.rs diff --git a/crates/pm/Cargo.toml b/crates/pm/Cargo.toml index 90874cac0e..8d4aae96a7 100644 --- a/crates/pm/Cargo.toml +++ b/crates/pm/Cargo.toml @@ -38,7 +38,7 @@ reqwest = { version = "0.12", default-features = false, features = [ "stream", ] } serde = { workspace = true } -serde_json = "1.0" +serde_json = { version = "1.0", features = ["preserve_order"] } tar = "0.4" tempfile = "3.15.0" term_size = "0.3" diff --git a/crates/pm/src/cmd/init.rs b/crates/pm/src/cmd/init.rs new file mode 100644 index 0000000000..46281eac01 --- /dev/null +++ b/crates/pm/src/cmd/init.rs @@ -0,0 +1,371 @@ +use std::path::Path; + +use anyhow::{Result, bail}; +use dialoguer::Input; +use serde_json::json; + +/// Initialize a new package.json file in the given directory (or current directory). +/// +/// If `yes` is true, skip interactive prompts and use defaults. +/// If `cwd` is `None`, uses `std::env::current_dir()`. +pub async fn init(yes: bool, cwd: Option<&Path>) -> Result<()> { + let cwd = match cwd { + Some(p) => p.to_path_buf(), + None => std::env::current_dir()?, + }; + let package_json_path = cwd.join("package.json"); + + if package_json_path.exists() { + bail!("package.json already exists in {}", cwd.display()); + } + + let pkg = if yes { + build_default_package(&cwd) + } else { + build_interactive_package(&cwd)? + }; + + let content = serde_json::to_string_pretty(&pkg)? + "\n"; + + println!("About to write to {}:\n", package_json_path.display()); + println!("{content}"); + + if !yes { + let confirm: String = Input::new() + .with_prompt("Is this OK?") + .default("yes".to_string()) + .interact_text()?; + if confirm.trim().to_lowercase() != "yes" && confirm.trim().to_lowercase() != "y" { + println!("Aborted."); + return Ok(()); + } + } + + tokio::fs::write(&package_json_path, &content).await?; + Ok(()) +} + +/// Normalize a git remote URL to npm-conventional format. +/// +/// - `git@host:user/repo.git` → `git+ssh://git@host/user/repo.git` +/// - `https://host/user/repo.git` → `git+https://host/user/repo.git` +/// - `http://host/user/repo.git` → `git+http://host/user/repo.git` +/// - Already prefixed with `git+` or other formats → returned as-is +fn normalize_git_url(url: &str) -> String { + if url.starts_with("git+") { + return url.to_string(); + } + + // SSH shorthand: git@host:user/repo.git + if let Some(rest) = url.strip_prefix("git@") { + if let Some((host, path)) = rest.split_once(':') { + return format!("git+ssh://git@{host}/{path}"); + } + } + + // https:// or http:// + if url.starts_with("https://") { + return format!("git+{url}"); + } + if url.starts_with("http://") { + return format!("git+{url}"); + } + + url.to_string() +} + +fn build_default_package(cwd: &Path) -> serde_json::Value { + let name = dir_name(cwd); + let author = detect_git_author(); + let repository = detect_git_repository(cwd); + + let mut pkg = json!({ + "name": name, + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": author, + "license": "ISC" + }); + + if !repository.is_empty() { + pkg["repository"] = json!({ + "type": "git", + "url": normalize_git_url(&repository) + }); + } + + pkg +} + +fn build_interactive_package(cwd: &Path) -> Result { + let default_name = dir_name(cwd); + let default_author = detect_git_author(); + let default_repo = detect_git_repository(cwd); + + let name: String = Input::new() + .with_prompt("package name") + .default(default_name) + .interact_text()?; + + let version: String = Input::new() + .with_prompt("version") + .default("1.0.0".to_string()) + .interact_text()?; + + let description: String = Input::new() + .with_prompt("description") + .default(String::new()) + .allow_empty(true) + .interact_text()?; + + let entry_point: String = Input::new() + .with_prompt("entry point") + .default("index.js".to_string()) + .interact_text()?; + + let test_command: String = Input::new() + .with_prompt("test command") + .default(String::new()) + .allow_empty(true) + .interact_text()?; + + let git_repository: String = Input::new() + .with_prompt("git repository") + .default(default_repo) + .allow_empty(true) + .interact_text()?; + + let keywords_input: String = Input::new() + .with_prompt("keywords") + .default(String::new()) + .allow_empty(true) + .interact_text()?; + + let author: String = Input::new() + .with_prompt("author") + .default(default_author) + .allow_empty(true) + .interact_text()?; + + let license: String = Input::new() + .with_prompt("license") + .default("ISC".to_string()) + .interact_text()?; + + let test_script = if test_command.is_empty() { + "echo \"Error: no test specified\" && exit 1".to_string() + } else { + test_command + }; + + let keywords: Vec = if keywords_input.is_empty() { + vec![] + } else { + keywords_input + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }; + + let mut pkg = json!({ + "name": name, + "version": version, + "description": description, + "main": entry_point, + "scripts": { + "test": test_script + }, + "keywords": keywords, + "author": author, + "license": license + }); + + if !git_repository.is_empty() { + pkg["repository"] = json!({ + "type": "git", + "url": normalize_git_url(&git_repository) + }); + } + + Ok(pkg) +} + +fn dir_name(cwd: &Path) -> String { + cwd.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("package") + .to_string() +} + +fn detect_git_author() -> String { + let name = std::process::Command::new("git") + .args(["config", "user.name"]) + .output() + .ok() + .and_then(|o| { + if o.status.success() { + String::from_utf8(o.stdout).ok().map(|s| s.trim().to_string()) + } else { + None + } + }) + .unwrap_or_default(); + + let email = std::process::Command::new("git") + .args(["config", "user.email"]) + .output() + .ok() + .and_then(|o| { + if o.status.success() { + String::from_utf8(o.stdout).ok().map(|s| s.trim().to_string()) + } else { + None + } + }) + .unwrap_or_default(); + + match (name.is_empty(), email.is_empty()) { + (true, true) => String::new(), + (false, true) => name, + (true, false) => format!("<{email}>"), + (false, false) => format!("{name} <{email}>"), + } +} + +fn detect_git_repository(cwd: &Path) -> String { + std::process::Command::new("git") + .args(["config", "--get", "remote.origin.url"]) + .current_dir(cwd) + .output() + .ok() + .and_then(|o| { + if o.status.success() { + String::from_utf8(o.stdout) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + } else { + None + } + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dir_name() { + let path = Path::new("/home/user/my-project"); + assert_eq!(dir_name(path), "my-project"); + } + + #[test] + fn test_dir_name_root() { + let path = Path::new("/"); + // Root path has no file_name, should fallback to "package" + assert_eq!(dir_name(path), "package"); + } + + #[test] + fn test_build_default_package_structure() { + let cwd = Path::new("/tmp/test-pkg"); + let pkg = build_default_package(cwd); + + assert_eq!(pkg["name"], "test-pkg"); + assert_eq!(pkg["version"], "1.0.0"); + assert_eq!(pkg["main"], "index.js"); + assert_eq!(pkg["license"], "ISC"); + assert!(pkg["scripts"]["test"].is_string()); + assert!(pkg["keywords"].is_array()); + } + + #[tokio::test] + async fn test_init_refuses_existing_package_json() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("package.json"), "{}").unwrap(); + + let result = init(true, Some(dir.path())).await; + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("package.json already exists") + ); + } + + #[tokio::test] + async fn test_init_yes_creates_valid_package_json() { + let dir = tempfile::tempdir().unwrap(); + init(true, Some(dir.path())).await.unwrap(); + + let content = std::fs::read_to_string(dir.path().join("package.json")).unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); + + assert_eq!(pkg["name"], dir.path().file_name().unwrap().to_str().unwrap()); + assert_eq!(pkg["version"], "1.0.0"); + assert_eq!(pkg["description"], ""); + assert_eq!(pkg["main"], "index.js"); + assert_eq!(pkg["license"], "ISC"); + assert!(pkg["keywords"].is_array()); + + // Verify field order matches npm convention + let keys: Vec<&String> = pkg.as_object().unwrap().keys().collect(); + let name_idx = keys.iter().position(|k| *k == "name").unwrap(); + let version_idx = keys.iter().position(|k| *k == "version").unwrap(); + let description_idx = keys.iter().position(|k| *k == "description").unwrap(); + let main_idx = keys.iter().position(|k| *k == "main").unwrap(); + let license_idx = keys.iter().position(|k| *k == "license").unwrap(); + assert!(name_idx < version_idx); + assert!(version_idx < description_idx); + assert!(description_idx < main_idx); + assert!(main_idx < license_idx); + } + + #[test] + fn test_normalize_git_url_ssh_shorthand() { + assert_eq!( + normalize_git_url("git@github.com:user/repo.git"), + "git+ssh://git@github.com/user/repo.git" + ); + } + + #[test] + fn test_normalize_git_url_https() { + assert_eq!( + normalize_git_url("https://github.com/user/repo.git"), + "git+https://github.com/user/repo.git" + ); + } + + #[test] + fn test_normalize_git_url_http() { + assert_eq!( + normalize_git_url("http://github.com/user/repo.git"), + "git+http://github.com/user/repo.git" + ); + } + + #[test] + fn test_normalize_git_url_already_prefixed() { + let url = "git+ssh://git@github.com/user/repo.git"; + assert_eq!(normalize_git_url(url), url); + + let url2 = "git+https://github.com/user/repo.git"; + assert_eq!(normalize_git_url(url2), url2); + } + + #[test] + fn test_normalize_git_url_other_format() { + let url = "file:///path/to/repo"; + assert_eq!(normalize_git_url(url), url); + } +} diff --git a/crates/pm/src/cmd/mod.rs b/crates/pm/src/cmd/mod.rs index fa945254e8..39fd6b6c64 100644 --- a/crates/pm/src/cmd/mod.rs +++ b/crates/pm/src/cmd/mod.rs @@ -2,6 +2,7 @@ pub mod clean; pub mod config; pub mod deps; pub mod execute; +pub mod init; pub mod install; pub mod link; pub mod list; diff --git a/crates/pm/src/constants.rs b/crates/pm/src/constants.rs index 410d2351b8..91ba39870b 100644 --- a/crates/pm/src/constants.rs +++ b/crates/pm/src/constants.rs @@ -53,4 +53,8 @@ pub mod cmd { pub const LINK_NAME: &str = "link"; pub const LINK_ALIAS: &str = "ln"; pub const LINK_ABOUT: &str = "Link a package like npm link"; + + pub const INIT_NAME: &str = "init"; + pub const INIT_ALIAS: &str = "create"; + pub const INIT_ABOUT: &str = "Create a package.json file"; } diff --git a/crates/pm/src/main.rs b/crates/pm/src/main.rs index eefd308f2b..c9ece1ed1b 100644 --- a/crates/pm/src/main.rs +++ b/crates/pm/src/main.rs @@ -5,6 +5,7 @@ use clap::{Parser, Subcommand}; use cmd::config::{handle_config_get, handle_config_list, handle_config_set}; use cmd::deps::build_deps; use cmd::execute::execute; +use cmd::init::init; use cmd::install::{install, install_global_package, update_packages}; use cmd::link::{link_current_to_global, link_global_to_local}; use cmd::list::list_dependencies; @@ -30,11 +31,11 @@ mod util; use crate::constants::cmd::{ CLEAN_ABOUT, CLEAN_ALIAS, CLEAN_NAME, CONFIG_ABOUT, CONFIG_ALIAS, CONFIG_NAME, DEPS_ABOUT, - DEPS_ALIAS, DEPS_NAME, EXECUTE_ABOUT, EXECUTE_ALIAS, EXECUTE_NAME, INSTALL_ABOUT, - INSTALL_ALIAS, INSTALL_NAME, LINK_ABOUT, LINK_ALIAS, LINK_NAME, LIST_ALIAS, LIST_NAME, - REBUILD_ABOUT, REBUILD_ALIAS, REBUILD_NAME, RUN_ALIAS, RUN_NAME, UNINSTALL_ABOUT, - UNINSTALL_ALIAS, UNINSTALL_NAME, UPDATE_ABOUT, UPDATE_ALIAS, UPDATE_NAME, VIEW_ABOUT, - VIEW_ALIAS, VIEW_ALIAS_INFO, VIEW_ALIAS_SHOW, VIEW_NAME, + DEPS_ALIAS, DEPS_NAME, EXECUTE_ABOUT, EXECUTE_ALIAS, EXECUTE_NAME, INIT_ABOUT, INIT_ALIAS, + INIT_NAME, INSTALL_ABOUT, INSTALL_ALIAS, INSTALL_NAME, LINK_ABOUT, LINK_ALIAS, LINK_NAME, + LIST_ALIAS, LIST_NAME, REBUILD_ABOUT, REBUILD_ALIAS, REBUILD_NAME, RUN_ALIAS, RUN_NAME, + UNINSTALL_ABOUT, UNINSTALL_ALIAS, UNINSTALL_NAME, UPDATE_ABOUT, UPDATE_ALIAS, UPDATE_NAME, + VIEW_ABOUT, VIEW_ALIAS, VIEW_ALIAS_INFO, VIEW_ALIAS_SHOW, VIEW_NAME, }; use crate::constants::{APP_ABOUT, APP_NAME, APP_VERSION}; use crate::helper::workspace::update_cwd_to_root; @@ -248,6 +249,14 @@ enum Commands { #[command(subcommand)] command: ConfigCommands, }, + + /// Create a package.json file + #[command(name = INIT_NAME, alias = INIT_ALIAS, about = INIT_ABOUT)] + Init { + /// Skip prompts and use defaults + #[arg(long, short)] + yes: bool, + }, } fn main() { @@ -477,6 +486,10 @@ async fn async_main() -> Result<()> { } } } + Some(Commands::Init { yes }) => { + init(yes, None).await?; + log_time_end("package.json created"); + } Some(Commands::Config { command }) => match command { ConfigCommands::Set { key, value, global } => { handle_config_set(key, value, global).await?; From 5a11b867da70967ae761d29e07b9e1d500f952c4 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Wed, 11 Feb 2026 15:04:31 +0800 Subject: [PATCH 2/4] feat: init --- crates/pm/src/cmd/init.rs | 368 +--------------------------------- crates/pm/src/helper/git.rs | 138 +++++++++++++ crates/pm/src/helper/mod.rs | 1 + crates/pm/src/service/init.rs | 203 +++++++++++++++++++ crates/pm/src/service/mod.rs | 1 + 5 files changed, 345 insertions(+), 366 deletions(-) create mode 100644 crates/pm/src/helper/git.rs create mode 100644 crates/pm/src/service/init.rs diff --git a/crates/pm/src/cmd/init.rs b/crates/pm/src/cmd/init.rs index 46281eac01..64444fd1a5 100644 --- a/crates/pm/src/cmd/init.rs +++ b/crates/pm/src/cmd/init.rs @@ -1,371 +1,7 @@ use std::path::Path; -use anyhow::{Result, bail}; -use dialoguer::Input; -use serde_json::json; +use anyhow::Result; -/// Initialize a new package.json file in the given directory (or current directory). -/// -/// If `yes` is true, skip interactive prompts and use defaults. -/// If `cwd` is `None`, uses `std::env::current_dir()`. pub async fn init(yes: bool, cwd: Option<&Path>) -> Result<()> { - let cwd = match cwd { - Some(p) => p.to_path_buf(), - None => std::env::current_dir()?, - }; - let package_json_path = cwd.join("package.json"); - - if package_json_path.exists() { - bail!("package.json already exists in {}", cwd.display()); - } - - let pkg = if yes { - build_default_package(&cwd) - } else { - build_interactive_package(&cwd)? - }; - - let content = serde_json::to_string_pretty(&pkg)? + "\n"; - - println!("About to write to {}:\n", package_json_path.display()); - println!("{content}"); - - if !yes { - let confirm: String = Input::new() - .with_prompt("Is this OK?") - .default("yes".to_string()) - .interact_text()?; - if confirm.trim().to_lowercase() != "yes" && confirm.trim().to_lowercase() != "y" { - println!("Aborted."); - return Ok(()); - } - } - - tokio::fs::write(&package_json_path, &content).await?; - Ok(()) -} - -/// Normalize a git remote URL to npm-conventional format. -/// -/// - `git@host:user/repo.git` → `git+ssh://git@host/user/repo.git` -/// - `https://host/user/repo.git` → `git+https://host/user/repo.git` -/// - `http://host/user/repo.git` → `git+http://host/user/repo.git` -/// - Already prefixed with `git+` or other formats → returned as-is -fn normalize_git_url(url: &str) -> String { - if url.starts_with("git+") { - return url.to_string(); - } - - // SSH shorthand: git@host:user/repo.git - if let Some(rest) = url.strip_prefix("git@") { - if let Some((host, path)) = rest.split_once(':') { - return format!("git+ssh://git@{host}/{path}"); - } - } - - // https:// or http:// - if url.starts_with("https://") { - return format!("git+{url}"); - } - if url.starts_with("http://") { - return format!("git+{url}"); - } - - url.to_string() -} - -fn build_default_package(cwd: &Path) -> serde_json::Value { - let name = dir_name(cwd); - let author = detect_git_author(); - let repository = detect_git_repository(cwd); - - let mut pkg = json!({ - "name": name, - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" - }, - "keywords": [], - "author": author, - "license": "ISC" - }); - - if !repository.is_empty() { - pkg["repository"] = json!({ - "type": "git", - "url": normalize_git_url(&repository) - }); - } - - pkg -} - -fn build_interactive_package(cwd: &Path) -> Result { - let default_name = dir_name(cwd); - let default_author = detect_git_author(); - let default_repo = detect_git_repository(cwd); - - let name: String = Input::new() - .with_prompt("package name") - .default(default_name) - .interact_text()?; - - let version: String = Input::new() - .with_prompt("version") - .default("1.0.0".to_string()) - .interact_text()?; - - let description: String = Input::new() - .with_prompt("description") - .default(String::new()) - .allow_empty(true) - .interact_text()?; - - let entry_point: String = Input::new() - .with_prompt("entry point") - .default("index.js".to_string()) - .interact_text()?; - - let test_command: String = Input::new() - .with_prompt("test command") - .default(String::new()) - .allow_empty(true) - .interact_text()?; - - let git_repository: String = Input::new() - .with_prompt("git repository") - .default(default_repo) - .allow_empty(true) - .interact_text()?; - - let keywords_input: String = Input::new() - .with_prompt("keywords") - .default(String::new()) - .allow_empty(true) - .interact_text()?; - - let author: String = Input::new() - .with_prompt("author") - .default(default_author) - .allow_empty(true) - .interact_text()?; - - let license: String = Input::new() - .with_prompt("license") - .default("ISC".to_string()) - .interact_text()?; - - let test_script = if test_command.is_empty() { - "echo \"Error: no test specified\" && exit 1".to_string() - } else { - test_command - }; - - let keywords: Vec = if keywords_input.is_empty() { - vec![] - } else { - keywords_input - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect() - }; - - let mut pkg = json!({ - "name": name, - "version": version, - "description": description, - "main": entry_point, - "scripts": { - "test": test_script - }, - "keywords": keywords, - "author": author, - "license": license - }); - - if !git_repository.is_empty() { - pkg["repository"] = json!({ - "type": "git", - "url": normalize_git_url(&git_repository) - }); - } - - Ok(pkg) -} - -fn dir_name(cwd: &Path) -> String { - cwd.file_name() - .and_then(|n| n.to_str()) - .unwrap_or("package") - .to_string() -} - -fn detect_git_author() -> String { - let name = std::process::Command::new("git") - .args(["config", "user.name"]) - .output() - .ok() - .and_then(|o| { - if o.status.success() { - String::from_utf8(o.stdout).ok().map(|s| s.trim().to_string()) - } else { - None - } - }) - .unwrap_or_default(); - - let email = std::process::Command::new("git") - .args(["config", "user.email"]) - .output() - .ok() - .and_then(|o| { - if o.status.success() { - String::from_utf8(o.stdout).ok().map(|s| s.trim().to_string()) - } else { - None - } - }) - .unwrap_or_default(); - - match (name.is_empty(), email.is_empty()) { - (true, true) => String::new(), - (false, true) => name, - (true, false) => format!("<{email}>"), - (false, false) => format!("{name} <{email}>"), - } -} - -fn detect_git_repository(cwd: &Path) -> String { - std::process::Command::new("git") - .args(["config", "--get", "remote.origin.url"]) - .current_dir(cwd) - .output() - .ok() - .and_then(|o| { - if o.status.success() { - String::from_utf8(o.stdout) - .ok() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - } else { - None - } - }) - .unwrap_or_default() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_dir_name() { - let path = Path::new("/home/user/my-project"); - assert_eq!(dir_name(path), "my-project"); - } - - #[test] - fn test_dir_name_root() { - let path = Path::new("/"); - // Root path has no file_name, should fallback to "package" - assert_eq!(dir_name(path), "package"); - } - - #[test] - fn test_build_default_package_structure() { - let cwd = Path::new("/tmp/test-pkg"); - let pkg = build_default_package(cwd); - - assert_eq!(pkg["name"], "test-pkg"); - assert_eq!(pkg["version"], "1.0.0"); - assert_eq!(pkg["main"], "index.js"); - assert_eq!(pkg["license"], "ISC"); - assert!(pkg["scripts"]["test"].is_string()); - assert!(pkg["keywords"].is_array()); - } - - #[tokio::test] - async fn test_init_refuses_existing_package_json() { - let dir = tempfile::tempdir().unwrap(); - std::fs::write(dir.path().join("package.json"), "{}").unwrap(); - - let result = init(true, Some(dir.path())).await; - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("package.json already exists") - ); - } - - #[tokio::test] - async fn test_init_yes_creates_valid_package_json() { - let dir = tempfile::tempdir().unwrap(); - init(true, Some(dir.path())).await.unwrap(); - - let content = std::fs::read_to_string(dir.path().join("package.json")).unwrap(); - let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); - - assert_eq!(pkg["name"], dir.path().file_name().unwrap().to_str().unwrap()); - assert_eq!(pkg["version"], "1.0.0"); - assert_eq!(pkg["description"], ""); - assert_eq!(pkg["main"], "index.js"); - assert_eq!(pkg["license"], "ISC"); - assert!(pkg["keywords"].is_array()); - - // Verify field order matches npm convention - let keys: Vec<&String> = pkg.as_object().unwrap().keys().collect(); - let name_idx = keys.iter().position(|k| *k == "name").unwrap(); - let version_idx = keys.iter().position(|k| *k == "version").unwrap(); - let description_idx = keys.iter().position(|k| *k == "description").unwrap(); - let main_idx = keys.iter().position(|k| *k == "main").unwrap(); - let license_idx = keys.iter().position(|k| *k == "license").unwrap(); - assert!(name_idx < version_idx); - assert!(version_idx < description_idx); - assert!(description_idx < main_idx); - assert!(main_idx < license_idx); - } - - #[test] - fn test_normalize_git_url_ssh_shorthand() { - assert_eq!( - normalize_git_url("git@github.com:user/repo.git"), - "git+ssh://git@github.com/user/repo.git" - ); - } - - #[test] - fn test_normalize_git_url_https() { - assert_eq!( - normalize_git_url("https://github.com/user/repo.git"), - "git+https://github.com/user/repo.git" - ); - } - - #[test] - fn test_normalize_git_url_http() { - assert_eq!( - normalize_git_url("http://github.com/user/repo.git"), - "git+http://github.com/user/repo.git" - ); - } - - #[test] - fn test_normalize_git_url_already_prefixed() { - let url = "git+ssh://git@github.com/user/repo.git"; - assert_eq!(normalize_git_url(url), url); - - let url2 = "git+https://github.com/user/repo.git"; - assert_eq!(normalize_git_url(url2), url2); - } - - #[test] - fn test_normalize_git_url_other_format() { - let url = "file:///path/to/repo"; - assert_eq!(normalize_git_url(url), url); - } + crate::service::init::init(yes, cwd).await } diff --git a/crates/pm/src/helper/git.rs b/crates/pm/src/helper/git.rs new file mode 100644 index 0000000000..d2b9702e01 --- /dev/null +++ b/crates/pm/src/helper/git.rs @@ -0,0 +1,138 @@ +use std::path::Path; + +/// Get the directory name from a path, falling back to "package". +pub fn dir_name(cwd: &Path) -> String { + cwd.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("package") + .to_string() +} + +/// Detect the git author from `git config user.name` and `git config user.email`. +pub fn detect_author() -> String { + let name = git_config("user.name").unwrap_or_default(); + let email = git_config("user.email").unwrap_or_default(); + + match (name.is_empty(), email.is_empty()) { + (true, true) => String::new(), + (false, true) => name, + (true, false) => format!("<{email}>"), + (false, false) => format!("{name} <{email}>"), + } +} + +/// Detect the git remote origin URL for the given directory. +pub fn detect_repository(cwd: &Path) -> String { + std::process::Command::new("git") + .args(["config", "--get", "remote.origin.url"]) + .current_dir(cwd) + .output() + .ok() + .and_then(|o| { + if o.status.success() { + String::from_utf8(o.stdout) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + } else { + None + } + }) + .unwrap_or_default() +} + +/// Normalize a git remote URL to npm-conventional format. +/// +/// - `git@host:user/repo.git` → `git+ssh://git@host/user/repo.git` +/// - `https://host/user/repo.git` → `git+https://host/user/repo.git` +/// - `http://host/user/repo.git` → `git+http://host/user/repo.git` +/// - Already prefixed with `git+` or other formats → returned as-is +pub fn normalize_url(url: &str) -> String { + if url.starts_with("git+") { + return url.to_string(); + } + + // SSH shorthand: git@host:user/repo.git + if let Some(rest) = url.strip_prefix("git@") { + if let Some((host, path)) = rest.split_once(':') { + return format!("git+ssh://git@{host}/{path}"); + } + } + + // https:// or http:// + if url.starts_with("https://") || url.starts_with("http://") { + return format!("git+{url}"); + } + + url.to_string() +} + +fn git_config(key: &str) -> Option { + std::process::Command::new("git") + .args(["config", key]) + .output() + .ok() + .and_then(|o| { + if o.status.success() { + String::from_utf8(o.stdout) + .ok() + .map(|s| s.trim().to_string()) + } else { + None + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dir_name() { + assert_eq!(dir_name(Path::new("/home/user/my-project")), "my-project"); + } + + #[test] + fn test_dir_name_root() { + assert_eq!(dir_name(Path::new("/")), "package"); + } + + #[test] + fn test_normalize_url_ssh_shorthand() { + assert_eq!( + normalize_url("git@github.com:user/repo.git"), + "git+ssh://git@github.com/user/repo.git" + ); + } + + #[test] + fn test_normalize_url_https() { + assert_eq!( + normalize_url("https://github.com/user/repo.git"), + "git+https://github.com/user/repo.git" + ); + } + + #[test] + fn test_normalize_url_http() { + assert_eq!( + normalize_url("http://github.com/user/repo.git"), + "git+http://github.com/user/repo.git" + ); + } + + #[test] + fn test_normalize_url_already_prefixed() { + let url = "git+ssh://git@github.com/user/repo.git"; + assert_eq!(normalize_url(url), url); + + let url2 = "git+https://github.com/user/repo.git"; + assert_eq!(normalize_url(url2), url2); + } + + #[test] + fn test_normalize_url_other_format() { + let url = "file:///path/to/repo"; + assert_eq!(normalize_url(url), url); + } +} diff --git a/crates/pm/src/helper/mod.rs b/crates/pm/src/helper/mod.rs index 3ea4452a73..bdfd44d29a 100644 --- a/crates/pm/src/helper/mod.rs +++ b/crates/pm/src/helper/mod.rs @@ -2,6 +2,7 @@ pub mod auto_update; pub mod deps; pub mod fs; pub mod fuzzy_select; +pub mod git; pub mod global_bin; pub mod install_runtime; pub mod lock; diff --git a/crates/pm/src/service/init.rs b/crates/pm/src/service/init.rs new file mode 100644 index 0000000000..15e683e755 --- /dev/null +++ b/crates/pm/src/service/init.rs @@ -0,0 +1,203 @@ +use std::path::Path; + +use anyhow::{Result, bail}; +use dialoguer::Input; +use serde_json::json; + +use crate::helper::git; + +/// Initialize a new package.json file in the given directory (or current directory). +/// +/// If `yes` is true, skip interactive prompts and use defaults. +/// If `cwd` is `None`, uses `std::env::current_dir()`. +pub async fn init(yes: bool, cwd: Option<&Path>) -> Result<()> { + let cwd = match cwd { + Some(p) => p.to_path_buf(), + None => std::env::current_dir()?, + }; + let package_json_path = cwd.join("package.json"); + + if package_json_path.exists() { + bail!("package.json already exists in {}", cwd.display()); + } + + let pkg = if yes { + build_default_package(&cwd) + } else { + build_interactive_package(&cwd)? + }; + + let content = serde_json::to_string_pretty(&pkg)? + "\n"; + + println!("About to write to {}:\n", package_json_path.display()); + println!("{content}"); + + if !yes { + let confirm: String = Input::new() + .with_prompt("Is this OK?") + .default("yes".to_string()) + .interact_text()?; + if confirm.trim().to_lowercase() != "yes" && confirm.trim().to_lowercase() != "y" { + println!("Aborted."); + return Ok(()); + } + } + + tokio::fs::write(&package_json_path, &content).await?; + Ok(()) +} + +const DEFAULT_VERSION: &str = "1.0.0"; +const DEFAULT_ENTRY: &str = "index.js"; +const DEFAULT_LICENSE: &str = "ISC"; +const DEFAULT_TEST: &str = "echo \"Error: no test specified\" && exit 1"; + +struct PackageFields { + name: String, + version: String, + description: String, + main: String, + test_script: String, + repository: String, + keywords: Vec, + author: String, + license: String, +} + +impl PackageFields { + fn defaults(cwd: &Path) -> Self { + Self { + name: git::dir_name(cwd), + version: DEFAULT_VERSION.to_string(), + description: String::new(), + main: DEFAULT_ENTRY.to_string(), + test_script: DEFAULT_TEST.to_string(), + repository: git::detect_repository(cwd), + keywords: vec![], + author: git::detect_author(), + license: DEFAULT_LICENSE.to_string(), + } + } + + fn into_json(self) -> serde_json::Value { + let mut pkg = json!({ + "name": self.name, + "version": self.version, + "description": self.description, + "main": self.main, + "scripts": { + "test": self.test_script + }, + "keywords": self.keywords, + "author": self.author, + "license": self.license + }); + + if !self.repository.is_empty() { + pkg["repository"] = json!({ + "type": "git", + "url": git::normalize_url(&self.repository) + }); + } + + pkg + } +} + +fn build_default_package(cwd: &Path) -> serde_json::Value { + PackageFields::defaults(cwd).into_json() +} + +fn build_interactive_package(cwd: &Path) -> Result { + let defaults = PackageFields::defaults(cwd); + + let fields = PackageFields { + name: prompt("package name", defaults.name, false)?, + version: prompt("version", defaults.version, false)?, + description: prompt("description", String::new(), true)?, + main: prompt("entry point", defaults.main, false)?, + test_script: { + let cmd = prompt("test command", String::new(), true)?; + if cmd.is_empty() { DEFAULT_TEST.to_string() } else { cmd } + }, + repository: prompt("git repository", defaults.repository, true)?, + keywords: { + let input = prompt("keywords", String::new(), true)?; + input.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect() + }, + author: prompt("author", defaults.author, true)?, + license: prompt("license", defaults.license, false)?, + }; + + Ok(fields.into_json()) +} + +fn prompt(label: &str, default: String, allow_empty: bool) -> Result { + Ok(Input::new() + .with_prompt(label) + .default(default) + .allow_empty(allow_empty) + .interact_text()?) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn test_build_default_package_structure() { + let cwd = Path::new("/tmp/test-pkg"); + let pkg = build_default_package(cwd); + + assert_eq!(pkg["name"], "test-pkg"); + assert_eq!(pkg["version"], "1.0.0"); + assert_eq!(pkg["main"], "index.js"); + assert_eq!(pkg["license"], "ISC"); + assert!(pkg["scripts"]["test"].is_string()); + assert!(pkg["keywords"].is_array()); + } + + #[tokio::test] + async fn test_init_refuses_existing_package_json() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("package.json"), "{}").unwrap(); + + let result = init(true, Some(dir.path())).await; + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("package.json already exists") + ); + } + + #[tokio::test] + async fn test_init_yes_creates_valid_package_json() { + let dir = tempfile::tempdir().unwrap(); + init(true, Some(dir.path())).await.unwrap(); + + let content = std::fs::read_to_string(dir.path().join("package.json")).unwrap(); + let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); + + assert_eq!(pkg["name"], dir.path().file_name().unwrap().to_str().unwrap()); + assert_eq!(pkg["version"], "1.0.0"); + assert_eq!(pkg["description"], ""); + assert_eq!(pkg["main"], "index.js"); + assert_eq!(pkg["license"], "ISC"); + assert!(pkg["keywords"].is_array()); + + // Verify field order matches npm convention + let keys: Vec<&String> = pkg.as_object().unwrap().keys().collect(); + let name_idx = keys.iter().position(|k| *k == "name").unwrap(); + let version_idx = keys.iter().position(|k| *k == "version").unwrap(); + let description_idx = keys.iter().position(|k| *k == "description").unwrap(); + let main_idx = keys.iter().position(|k| *k == "main").unwrap(); + let license_idx = keys.iter().position(|k| *k == "license").unwrap(); + assert!(name_idx < version_idx); + assert!(version_idx < description_idx); + assert!(description_idx < main_idx); + assert!(main_idx < license_idx); + } +} diff --git a/crates/pm/src/service/mod.rs b/crates/pm/src/service/mod.rs index 29eae7fc4a..8285ee5833 100644 --- a/crates/pm/src/service/mod.rs +++ b/crates/pm/src/service/mod.rs @@ -2,6 +2,7 @@ pub mod binary; pub mod config; pub mod dependency_graph; pub mod execute; +pub mod init; pub mod install; pub mod package; pub mod package_management; From c8c4c8842812a5fe961d0b9852f7b5b4f927b1b2 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Wed, 11 Feb 2026 15:52:28 +0800 Subject: [PATCH 3/4] fix: ci --- crates/pm/src/helper/git.rs | 8 ++++---- crates/pm/src/service/init.rs | 17 ++++++++++++++--- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/crates/pm/src/helper/git.rs b/crates/pm/src/helper/git.rs index d2b9702e01..d3bca62d53 100644 --- a/crates/pm/src/helper/git.rs +++ b/crates/pm/src/helper/git.rs @@ -53,10 +53,10 @@ pub fn normalize_url(url: &str) -> String { } // SSH shorthand: git@host:user/repo.git - if let Some(rest) = url.strip_prefix("git@") { - if let Some((host, path)) = rest.split_once(':') { - return format!("git+ssh://git@{host}/{path}"); - } + if let Some(rest) = url.strip_prefix("git@") + && let Some((host, path)) = rest.split_once(':') + { + return format!("git+ssh://git@{host}/{path}"); } // https:// or http:// diff --git a/crates/pm/src/service/init.rs b/crates/pm/src/service/init.rs index 15e683e755..800d2f23ed 100644 --- a/crates/pm/src/service/init.rs +++ b/crates/pm/src/service/init.rs @@ -118,12 +118,20 @@ fn build_interactive_package(cwd: &Path) -> Result { main: prompt("entry point", defaults.main, false)?, test_script: { let cmd = prompt("test command", String::new(), true)?; - if cmd.is_empty() { DEFAULT_TEST.to_string() } else { cmd } + if cmd.is_empty() { + DEFAULT_TEST.to_string() + } else { + cmd + } }, repository: prompt("git repository", defaults.repository, true)?, keywords: { let input = prompt("keywords", String::new(), true)?; - input.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect() + input + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() }, author: prompt("author", defaults.author, true)?, license: prompt("license", defaults.license, false)?, @@ -181,7 +189,10 @@ mod tests { let content = std::fs::read_to_string(dir.path().join("package.json")).unwrap(); let pkg: serde_json::Value = serde_json::from_str(&content).unwrap(); - assert_eq!(pkg["name"], dir.path().file_name().unwrap().to_str().unwrap()); + assert_eq!( + pkg["name"], + dir.path().file_name().unwrap().to_str().unwrap() + ); assert_eq!(pkg["version"], "1.0.0"); assert_eq!(pkg["description"], ""); assert_eq!(pkg["main"], "index.js"); From ebd6483de7b7033a43489baa8340c46788bda050 Mon Sep 17 00:00:00 2001 From: elrrrrrrr Date: Wed, 11 Feb 2026 16:19:31 +0800 Subject: [PATCH 4/4] fix: ci --- crates/pm/src/service/init.rs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/crates/pm/src/service/init.rs b/crates/pm/src/service/init.rs index 800d2f23ed..3c6251bb36 100644 --- a/crates/pm/src/service/init.rs +++ b/crates/pm/src/service/init.rs @@ -21,10 +21,11 @@ pub async fn init(yes: bool, cwd: Option<&Path>) -> Result<()> { bail!("package.json already exists in {}", cwd.display()); } + let cwd_clone = cwd.clone(); let pkg = if yes { - build_default_package(&cwd) + tokio::task::spawn_blocking(move || build_default_package(&cwd_clone)).await? } else { - build_interactive_package(&cwd)? + tokio::task::spawn_blocking(move || build_interactive_package(&cwd_clone)).await?? }; let content = serde_json::to_string_pretty(&pkg)? + "\n"; @@ -33,11 +34,18 @@ pub async fn init(yes: bool, cwd: Option<&Path>) -> Result<()> { println!("{content}"); if !yes { - let confirm: String = Input::new() - .with_prompt("Is this OK?") - .default("yes".to_string()) - .interact_text()?; - if confirm.trim().to_lowercase() != "yes" && confirm.trim().to_lowercase() != "y" { + let confirmed = tokio::task::spawn_blocking(|| { + let confirm: String = Input::new() + .with_prompt("Is this OK?") + .default("yes".to_string()) + .interact_text()?; + Ok::(matches!( + confirm.trim().to_lowercase().as_str(), + "yes" | "y" + )) + }) + .await??; + if !confirmed { println!("Aborted."); return Ok(()); }