Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/pm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions crates/pm/src/cmd/init.rs
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions crates/pm/src/cmd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions crates/pm/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
138 changes: 138 additions & 0 deletions crates/pm/src/helper/git.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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);
}
}
1 change: 1 addition & 0 deletions crates/pm/src/helper/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
23 changes: 18 additions & 5 deletions crates/pm/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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?;
Expand Down
Loading