diff --git a/crates/pm/Cargo.toml b/crates/pm/Cargo.toml index a9e6919ddd..e7982e9e96 100644 --- a/crates/pm/Cargo.toml +++ b/crates/pm/Cargo.toml @@ -40,7 +40,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..64444fd1a5 --- /dev/null +++ b/crates/pm/src/cmd/init.rs @@ -0,0 +1,7 @@ +use std::path::Path; + +use anyhow::Result; + +pub async fn init(yes: bool, cwd: Option<&Path>) -> Result<()> { + crate::service::init::init(yes, cwd).await +} 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/helper/git.rs b/crates/pm/src/helper/git.rs new file mode 100644 index 0000000000..d3bca62d53 --- /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@") + && 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 0166076105..a5d8afdd69 100644 --- a/crates/pm/src/helper/mod.rs +++ b/crates/pm/src/helper/mod.rs @@ -1,6 +1,7 @@ pub mod auto_update; pub mod deps; pub mod fuzzy_select; +pub mod git; pub mod global_bin; pub mod install_runtime; pub mod lock; diff --git a/crates/pm/src/main.rs b/crates/pm/src/main.rs index 8f87c76b23..2b055376de 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() { @@ -478,6 +487,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?; diff --git a/crates/pm/src/service/init.rs b/crates/pm/src/service/init.rs new file mode 100644 index 0000000000..3c6251bb36 --- /dev/null +++ b/crates/pm/src/service/init.rs @@ -0,0 +1,222 @@ +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 cwd_clone = cwd.clone(); + let pkg = if yes { + tokio::task::spawn_blocking(move || build_default_package(&cwd_clone)).await? + } else { + tokio::task::spawn_blocking(move || build_interactive_package(&cwd_clone)).await?? + }; + + let content = serde_json::to_string_pretty(&pkg)? + "\n"; + + println!("About to write to {}:\n", package_json_path.display()); + println!("{content}"); + + if !yes { + 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(()); + } + } + + 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 f390a280d9..634f7f45eb 100644 --- a/crates/pm/src/service/mod.rs +++ b/crates/pm/src/service/mod.rs @@ -3,6 +3,7 @@ pub mod clean; pub mod config; pub mod dependency_graph; pub mod execute; +pub mod init; pub mod install; pub mod package; pub mod package_management;