use std::ffi::OsString;
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Args, Parser, Subcommand};
use crate::registry::PackumentDetail;
mod add;
mod ci;
mod common;
mod download;
mod init;
mod install;
mod resolve;
mod sbom;
mod upgrade;
pub(crate) type Res<T = ()> = crate::Result<T>;
#[derive(Parser)]
#[command(
name = "npm-utils",
version,
about = "Pure-Rust npm registry tools: install · ci · add · init · upgrade · sbom"
)]
struct Cli {
#[arg(
long,
global = true,
value_name = "SECS",
conflicts_with = "no_timeout"
)]
timeout: Option<u64>,
#[arg(long, global = true)]
no_timeout: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Args)]
struct LicenseOpts {
#[arg(long, conflicts_with = "skip_license")]
no_skip_license: bool,
#[arg(long)]
skip_license: bool,
}
impl LicenseOpts {
fn detail(&self) -> PackumentDetail {
if self.no_skip_license && !self.skip_license {
PackumentDetail::Full
} else {
PackumentDetail::Abbreviated
}
}
}
#[derive(Subcommand)]
enum Command {
Install {
#[arg(default_value = ".")]
dir: PathBuf,
#[arg(
long,
visible_alias = "package-lock-only",
conflicts_with = "no_lockfile"
)]
lockfile_only: bool,
#[arg(long, visible_alias = "no-package-lock")]
no_lockfile: bool,
#[command(flatten)]
license: LicenseOpts,
},
Ci {
#[arg(default_value = ".")]
dir: PathBuf,
},
Add {
#[arg(required = true)]
packages: Vec<String>,
#[arg(long, default_value = ".")]
dir: PathBuf,
#[command(flatten)]
license: LicenseOpts,
},
Init {
#[arg(long, default_value = ".")]
dir: PathBuf,
#[arg(long)]
name: Option<String>,
},
Upgrade {
packages: Vec<String>,
#[arg(long, default_value = ".")]
dir: PathBuf,
#[command(flatten)]
license: LicenseOpts,
},
Resolve {
name: String,
#[arg(default_value = "*")]
range: String,
},
Download {
name: String,
#[arg(default_value = "*")]
range: String,
#[arg(long)]
out: Option<PathBuf>,
},
Sbom {
#[arg(default_value = ".")]
dir: PathBuf,
#[arg(long, default_value = "summary")]
format: sbom::Format,
#[arg(long)]
name: Option<String>,
#[arg(long, default_value = "auto")]
license_source: sbom::LicenseSource,
},
}
pub fn run(argv: impl IntoIterator<Item = OsString>) -> Res {
let cli = Cli::parse_from(argv);
crate::download::set_timeouts(crate::download::Timeouts::from_cli(
cli.timeout,
cli.no_timeout,
));
match cli.command {
Command::Install {
dir,
lockfile_only,
no_lockfile,
license,
} => install::run(&dir, lockfile_only, no_lockfile, license.detail()),
Command::Ci { dir } => ci::run(&dir),
Command::Add {
packages,
dir,
license,
} => add::run(&packages, &dir, license.detail()),
Command::Init { dir, name } => init::run(&dir, name.as_deref()),
Command::Upgrade {
packages,
dir,
license,
} => upgrade::run(&packages, &dir, license.detail()),
Command::Resolve { name, range } => resolve::run(&name, &range),
Command::Download { name, range, out } => download::run(&name, &range, out.as_deref()),
Command::Sbom {
dir,
format,
name,
license_source,
} => sbom::run(&dir, format, name.as_deref(), license_source),
}
}
pub fn main_with(argv: impl IntoIterator<Item = OsString>) -> ExitCode {
match run(argv) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("npm-utils: {e}");
ExitCode::FAILURE
}
}
}
pub fn run_as_cargo_subcommand(argv: impl IntoIterator<Item = OsString>) -> ExitCode {
main_with(strip_cargo_prefix(argv.into_iter().collect()))
}
fn strip_cargo_prefix(mut args: Vec<OsString>) -> Vec<OsString> {
if args.get(1).is_some_and(|a| a == "npm-utils") {
args.remove(1);
}
args
}
#[cfg(test)]
mod tests {
use super::*;
fn osv(args: &[&str]) -> Vec<OsString> {
args.iter().map(OsString::from).collect()
}
#[test]
fn strip_cargo_prefix_drops_the_repassed_subcommand_name() {
assert_eq!(
strip_cargo_prefix(osv(&["cargo-npm-utils", "npm-utils", "add", "lit"])),
osv(&["cargo-npm-utils", "add", "lit"])
);
assert_eq!(
strip_cargo_prefix(osv(&["cargo-npm-utils", "install"])),
osv(&["cargo-npm-utils", "install"])
);
assert_eq!(
strip_cargo_prefix(osv(&["cargo-npm-utils"])),
osv(&["cargo-npm-utils"])
);
}
#[test]
fn cli_parses_the_verb_set() {
for argv in [
osv(&["npm-utils", "install"]),
osv(&["npm-utils", "install", "web", "--lockfile-only"]),
osv(&["npm-utils", "install", "--no-lockfile"]),
osv(&["npm-utils", "ci", "/tmp/x"]),
osv(&["npm-utils", "add", "lit@^3", "--dir", "/tmp/x"]),
osv(&["npm-utils", "init", "--name", "demo"]),
osv(&["npm-utils", "upgrade"]),
osv(&["npm-utils", "resolve", "lit", "^3"]),
osv(&["npm-utils", "download", "ms", "--out", "/tmp/ms.tgz"]),
osv(&["npm-utils", "sbom", "/tmp/x", "--format", "cyclonedx"]),
osv(&["npm-utils", "sbom", "--format", "spdx", "--name", "demo"]),
] {
assert!(Cli::try_parse_from(argv).is_ok());
}
assert!(Cli::try_parse_from(osv(&["npm-utils", "add"])).is_err());
assert!(Cli::try_parse_from(osv(&[
"npm-utils",
"install",
"--lockfile-only",
"--no-lockfile"
]))
.is_err());
}
#[test]
fn cli_accepts_global_timeout_flags() {
assert!(Cli::try_parse_from(osv(&["npm-utils", "--timeout", "5", "install"])).is_ok());
assert!(Cli::try_parse_from(osv(&["npm-utils", "install", "--no-timeout"])).is_ok());
assert!(Cli::try_parse_from(osv(&["npm-utils", "--no-timeout", "ci", "/tmp/x"])).is_ok());
assert!(Cli::try_parse_from(osv(&[
"npm-utils",
"--timeout",
"5",
"--no-timeout",
"install"
]))
.is_err());
assert!(Cli::try_parse_from(osv(&["npm-utils", "--timeout", "soon", "install"])).is_err());
}
#[test]
fn cli_accepts_license_flags() {
assert!(Cli::try_parse_from(osv(&["npm-utils", "install", "--skip-license"])).is_ok());
assert!(
Cli::try_parse_from(osv(&["npm-utils", "add", "lit", "--no-skip-license"])).is_ok()
);
assert!(Cli::try_parse_from(osv(&["npm-utils", "upgrade", "--skip-license"])).is_ok());
assert!(Cli::try_parse_from(osv(&[
"npm-utils",
"install",
"--skip-license",
"--no-skip-license"
]))
.is_err());
}
#[test]
fn license_opts_map_to_packument_detail() {
let detail = |skip: bool, no_skip: bool| {
LicenseOpts {
skip_license: skip,
no_skip_license: no_skip,
}
.detail()
};
assert_eq!(detail(false, false), PackumentDetail::Abbreviated);
assert_eq!(detail(false, true), PackumentDetail::Full);
assert_eq!(detail(true, false), PackumentDetail::Abbreviated);
}
}