use std::ffi::OsString;
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Args, Parser, Subcommand};
use crate::registry::PackumentDetail;
mod add;
mod audit;
mod ci;
mod common;
mod download;
mod init;
mod install;
mod progress;
mod remove;
mod resolve;
mod sbom;
mod search;
mod source;
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 · remove · init · upgrade · search · sbom · audit"
)]
struct Cli {
#[arg(
long,
global = true,
value_name = "SECS",
conflicts_with = "no_timeout"
)]
timeout: Option<u64>,
#[arg(long, global = true)]
no_timeout: bool,
#[command(flatten)]
display: progress::DisplayOptions,
#[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 {
sources: Vec<String>,
#[arg(long, 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,
},
Remove {
#[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>,
},
Search {
#[arg(required = true)]
query: Vec<String>,
#[arg(long, default_value_t = 20)]
limit: usize,
},
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,
},
Audit {
#[arg(default_value = ".")]
source: String,
#[arg(long, value_enum, default_value = "low")]
audit_level: audit::AuditLevel,
#[arg(long, value_enum, default_value = "summary")]
format: audit::Format,
#[arg(long, value_enum, value_delimiter = ',')]
sources: Option<Vec<audit::SourceKind>>,
#[arg(long)]
registry: Option<String>,
#[arg(long)]
allow_incomplete: bool,
},
}
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,
));
let progress = progress::Progress::new(&cli.display);
progress.install_warn_sink();
match cli.command {
Command::Install {
sources,
dir,
lockfile_only,
no_lockfile,
license,
} => install::run(
&sources,
&dir,
lockfile_only,
no_lockfile,
license.detail(),
&progress,
),
Command::Ci { dir } => ci::run(&dir, &progress),
Command::Add {
packages,
dir,
license,
} => add::run(&packages, &dir, license.detail(), &progress),
Command::Remove {
packages,
dir,
license,
} => remove::run(&packages, &dir, license.detail(), &progress),
Command::Init { dir, name } => init::run(&dir, name.as_deref()),
Command::Upgrade {
packages,
dir,
license,
} => upgrade::run(&packages, &dir, license.detail(), &progress),
Command::Resolve { name, range } => resolve::run(&name, &range),
Command::Download { name, range, out } => {
download::run(&name, &range, out.as_deref(), &progress)
}
Command::Search { query, limit } => search::run(&query.join(" "), limit),
Command::Sbom {
dir,
format,
name,
license_source,
} => sbom::run(&dir, format, name.as_deref(), license_source),
Command::Audit {
source,
audit_level,
format,
sources,
registry,
allow_incomplete,
} => audit::run(
&source,
audit_level,
format,
sources.as_deref(),
registry.as_deref(),
allow_incomplete,
&progress,
),
}
}
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", "--dir", "web", "--lockfile-only"]),
osv(&["npm-utils", "install", "ms=^2", "lit", "--dir", "/tmp/x"]),
osv(&["npm-utils", "install", "--no-lockfile"]),
osv(&["npm-utils", "ci", "/tmp/x"]),
osv(&["npm-utils", "add", "lit@^3", "--dir", "/tmp/x"]),
osv(&["npm-utils", "remove", "lit", "--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", "search", "lodash"]),
osv(&["npm-utils", "search", "react", "router", "--limit", "5"]),
osv(&["npm-utils", "sbom", "/tmp/x", "--format", "cyclonedx"]),
osv(&["npm-utils", "sbom", "--format", "spdx", "--name", "demo"]),
osv(&["npm-utils", "audit", "/tmp/x"]),
osv(&["npm-utils", "audit", "lit=^3", "--audit-level", "high"]),
osv(&["npm-utils", "audit", "/tmp/x/package.json"]),
osv(&[
"npm-utils",
"audit",
"--audit-level",
"high",
"--format",
"json",
]),
osv(&[
"npm-utils",
"audit",
"--sources",
"npm,osv",
"--registry",
"https://r.example",
]),
] {
assert!(Cli::try_parse_from(argv).is_ok());
}
assert!(
Cli::try_parse_from(osv(&["npm-utils", "audit", "--audit-level", "fatal"])).is_err()
);
assert!(Cli::try_parse_from(osv(&["npm-utils", "audit", "--sources", "snyk"])).is_err());
assert!(Cli::try_parse_from(osv(&["npm-utils", "add"])).is_err());
assert!(Cli::try_parse_from(osv(&["npm-utils", "remove"])).is_err());
assert!(Cli::try_parse_from(osv(&["npm-utils", "search"])).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_global_quiet_flag() {
assert!(Cli::try_parse_from(osv(&["npm-utils", "-q", "audit", "/tmp/x"])).is_ok());
assert!(Cli::try_parse_from(osv(&["npm-utils", "audit", "lit=^3", "--quiet"])).is_ok());
assert!(Cli::try_parse_from(osv(&["npm-utils", "install", "-q"])).is_ok());
let quiet = |args: &[&str]| Cli::try_parse_from(osv(args)).unwrap().display.quiet;
assert!(quiet(&["npm-utils", "-q", "audit"]));
assert!(!quiet(&["npm-utils", "audit"]));
}
#[test]
fn cli_accepts_global_progress_flag() {
use progress::ProgressMode;
let mode = |args: &[&str]| Cli::try_parse_from(osv(args)).unwrap().display.progress;
assert_eq!(mode(&["npm-utils", "audit"]), ProgressMode::Auto);
assert_eq!(
mode(&["npm-utils", "--progress", "verbose", "ci", "/tmp/x"]),
ProgressMode::Verbose
);
assert_eq!(
mode(&["npm-utils", "install", "--progress=none"]),
ProgressMode::Off
);
for on in ["on", "1", "yes", "true"] {
assert_eq!(
mode(&["npm-utils", "audit", "--progress", on]),
ProgressMode::On
);
}
for off in ["none", "0", "off", "false", "no"] {
assert_eq!(
mode(&["npm-utils", "audit", "--progress", off]),
ProgressMode::Off
);
}
assert!(Cli::try_parse_from(osv(&["npm-utils", "audit", "--progress", "loud"])).is_err());
assert!(Cli::try_parse_from(osv(&["npm-utils", "-q", "audit"])).is_ok());
assert!(Cli::try_parse_from(osv(&["npm-utils", "-q", "--progress=on", "audit"])).is_err());
assert!(Cli::try_parse_from(osv(&["npm-utils", "audit", "-q", "--progress=on"])).is_err());
assert!(Cli::try_parse_from(osv(&["npm-utils", "--progress=none", "audit", "-q"])).is_ok());
}
#[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);
}
}