diff --git a/Cargo.lock b/Cargo.lock index faf4c78b..8609bac9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2234,6 +2234,8 @@ dependencies = [ "oro-client", "oro-common", "oro-config", + "oro-package-spec", + "oro-pretty-json", "poloto", "rand 0.8.5", "resvg", diff --git a/Cargo.toml b/Cargo.toml index a5a7ae0e..59585477 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ node-maintainer = { version = "=0.3.19", path = "./crates/node-maintainer" } oro-client = { version = "=0.3.19", path = "./crates/oro-client" } oro-common = { version = "=0.3.19", path = "./crates/oro-common" } oro-config = { version = "=0.3.19", path = "./crates/oro-config" } +oro-package-spec = { version = "=0.3.19", path = "./crates/oro-package-spec" } +oro-pretty-json = { version = "=0.3.19", path = "./crates/oro-pretty-json" } # Regular deps async-std = { workspace = true, features = ["attributes", "tokio1", "unstable"] } diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 54179a9e..fae4df03 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -13,6 +13,7 @@ # Commands +- [add](./commands/add.md) - [apply](./commands/apply.md) - [ping](./commands/ping.md) - [reapply](./commands/reapply.md) diff --git a/book/src/commands/add.md b/book/src/commands/add.md new file mode 100644 index 00000000..33a28066 --- /dev/null +++ b/book/src/commands/add.md @@ -0,0 +1 @@ +{{#include ../../../tests/snapshots/help__add.snap:8:}} diff --git a/crates/oro-package-spec/src/lib.rs b/crates/oro-package-spec/src/lib.rs index 6b19d955..48c4e06b 100644 --- a/crates/oro-package-spec/src/lib.rs +++ b/crates/oro-package-spec/src/lib.rs @@ -69,6 +69,20 @@ impl PackageSpec { } } + pub fn target_mut(&mut self) -> &mut PackageSpec { + use PackageSpec::*; + match self { + Alias { spec, .. } => { + if spec.is_alias() { + spec.target_mut() + } else { + spec + } + } + _ => self, + } + } + pub fn requested(&self) -> String { use PackageSpec::*; match self { diff --git a/crates/oro-pretty-json/src/lib.rs b/crates/oro-pretty-json/src/lib.rs index 785afd97..898d0e23 100644 --- a/crates/oro-pretty-json/src/lib.rs +++ b/crates/oro-pretty-json/src/lib.rs @@ -73,7 +73,9 @@ fn detect_indentation(json: &str) -> Option<(char, usize)> { fn detect_line_end(json: &str) -> Option<(String, bool)> { json.find(['\r', '\n']) .map(|idx| { - let c = json.get(idx..idx + 1).expect("we already know there's a char there"); + let c = json + .get(idx..idx + 1) + .expect("we already know there's a char there"); if c == "\r" && json.get(idx..idx + 2) == Some("\r\n") { return "\r\n".into(); } diff --git a/src/apply.rs b/src/apply_args.rs similarity index 99% rename from src/apply.rs rename to src/apply_args.rs index e6221333..5ab9f1fc 100644 --- a/src/apply.rs +++ b/src/apply_args.rs @@ -15,7 +15,7 @@ use url::Url; /// the right state to execute, based on your declared dependencies. #[derive(Debug, Args)] #[command(next_help_heading = "Apply Options")] -pub struct Apply { +pub struct ApplyArgs { /// Prevent all apply operations from executing. #[arg( long = "no-apply", @@ -109,7 +109,7 @@ pub struct Apply { pub emoji: bool, } -impl Apply { +impl ApplyArgs { pub async fn execute(&self) -> Result<()> { let total_time = std::time::Instant::now(); diff --git a/src/commands/add.rs b/src/commands/add.rs new file mode 100644 index 00000000..a6c24a74 --- /dev/null +++ b/src/commands/add.rs @@ -0,0 +1,171 @@ +use async_trait::async_trait; +use clap::Args; +use miette::{IntoDiagnostic, Result}; +use nassun::PackageResolution; +use oro_package_spec::{PackageSpec, VersionSpec}; +use oro_pretty_json::Formatted; + +use crate::apply_args::ApplyArgs; +use crate::commands::OroCommand; +use crate::nassun_args::NassunArgs; + +/// Adds one or more dependencies to the target package. +#[derive(Debug, Args)] +pub struct AddCmd { + /// Specifiers for packages to add. + #[arg(required = true)] + specs: Vec, + + /// Prefix to prepend to package versions for resolved NPM dependencies. + /// + /// For example, if you do `oro add foo@1.2.3 --prefix ~`, this will write `"foo": "~1.2.3"` to your `package.json`. + #[arg(long, default_value = "^")] + prefix: String, + + /// Add packages as devDependencies. + #[arg(long, short = 'D')] + dev: bool, + + /// Add packages as optionalDependencies. + #[arg(long, short = 'O', visible_alias = "optional")] + opt: bool, + + #[command(flatten)] + apply: ApplyArgs, +} + +#[async_trait] +impl OroCommand for AddCmd { + async fn execute(self) -> Result<()> { + let mut manifest = oro_pretty_json::from_str( + &async_std::fs::read_to_string(self.apply.root.join("package.json")) + .await + .into_diagnostic()?, + ) + .into_diagnostic()?; + let nassun = NassunArgs::from_apply_args(&self.apply).to_nassun(); + use PackageResolution as Pr; + use PackageSpec as Ps; + let mut count = 0; + for spec in &self.specs { + let pkg = nassun.resolve(spec).await?; + let name = pkg.name(); + let requested: PackageSpec = spec.parse()?; + let resolved_spec = match requested.target() { + Ps::Alias { .. } => { + unreachable!(".target() ensures this alias is fully resolved"); + } + Ps::Git(info) => { + format!("{info}") + } + Ps::Dir { path } => { + { + // TODO: make relative to root? + path.to_string_lossy().to_string() + } + } + Ps::Npm { .. } => { + let mut from = pkg.from().clone(); + let resolved = pkg.resolved(); + let version = if let Pr::Npm { version, .. } = resolved { + version + } else { + unreachable!("No other type of spec should be here."); + }; + match from.target_mut() { + Ps::Npm { requested, .. } => { + // We use Tag in a hacky way here to have some level of "preserved" formatting. + *requested = + Some(VersionSpec::Tag(format!("{}{version}", self.prefix))); + } + _ => { + unreachable!("No other type of spec should be here."); + } + } + from.requested() + } + }; + tracing::info!( + "{}Resolved {spec} to {name}@{resolved_spec}.", + if self.apply.emoji { "🔍 " } else { "" } + ); + self.remove_from_manifest(&mut manifest, name); + self.add_to_manifest(&mut manifest, name, &resolved_spec); + count += 1; + } + + async_std::fs::write( + self.apply.root.join("package.json"), + oro_pretty_json::to_string_pretty(&manifest).into_diagnostic()?, + ) + .await + .into_diagnostic()?; + + tracing::info!( + "{}Updated package.json with {count} new {}.", + if self.apply.emoji { "📝 " } else { "" }, + if count == 1 { + self.dep_kind_str_singular() + } else { + self.dep_kind_str() + } + ); + + // TODO: Force locked = false here, once --locked is supported. + // Using `oro add` with `--locked` doesn't make sense. + // self.apply.locked = false; + + // Then, we apply the change. + self.apply.execute().await + } +} + +impl AddCmd { + fn add_to_manifest(&self, mani: &mut Formatted, name: &str, spec: &str) { + let deps = self.dep_kind_str(); + tracing::debug!("Adding {name}@{spec} to {deps}."); + mani.value[deps][name] = + serde_json::to_value(spec).expect("Value is always a valid string"); + } + + fn remove_from_manifest(&self, mani: &mut Formatted, name: &str) { + for ty in [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", + ] { + if mani.value[ty].is_object() { + if let Some(obj) = mani.value[ty].as_object_mut() { + if obj.contains_key(name) { + tracing::debug!( + "Removing {name}@{} from {ty}.", + obj[name].as_str().unwrap_or("") + ); + obj.remove(name); + } + } + } + } + } + + fn dep_kind_str(&self) -> &'static str { + if self.dev { + "devDependencies" + } else if self.opt { + "optionalDependencies" + } else { + "dependencies" + } + } + + fn dep_kind_str_singular(&self) -> &'static str { + if self.dev { + "devDependency" + } else if self.opt { + "optionalDependency" + } else { + "dependency" + } + } +} diff --git a/src/commands/apply.rs b/src/commands/apply.rs index 40e39da7..64940520 100644 --- a/src/commands/apply.rs +++ b/src/commands/apply.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use clap::Args; use miette::Result; -use crate::apply::Apply; +use crate::apply_args::ApplyArgs; use crate::commands::OroCommand; /// Applies the current project's requested dependencies to `node_modules/`, @@ -17,7 +17,7 @@ use crate::commands::OroCommand; #[clap(visible_aliases(["a", "ap", "app"]))] pub struct ApplyCmd { #[command(flatten)] - apply: Apply, + apply: ApplyArgs, } #[async_trait] diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 10f230ce..0f7b5ef0 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,6 +1,7 @@ use async_trait::async_trait; use miette::Result; +pub mod add; pub mod apply; pub mod ping; pub mod reapply; diff --git a/src/commands/reapply.rs b/src/commands/reapply.rs index 25cfde9f..ac51a27c 100644 --- a/src/commands/reapply.rs +++ b/src/commands/reapply.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; use clap::Args; use miette::{IntoDiagnostic, Result}; -use crate::apply::Apply; +use crate::apply_args::ApplyArgs; use crate::commands::OroCommand; /// Removes the existing `node_modules`, if any, and reapplies it from @@ -10,7 +10,7 @@ use crate::commands::OroCommand; #[derive(Debug, Args)] pub struct ReapplyCmd { #[command(flatten)] - apply: Apply, + apply: ApplyArgs, } #[async_trait] diff --git a/src/commands/view.rs b/src/commands/view.rs index b5cbc0b7..4490918c 100644 --- a/src/commands/view.rs +++ b/src/commands/view.rs @@ -1,16 +1,13 @@ -use std::path::PathBuf; - use async_trait::async_trait; use clap::Args; use colored::*; use humansize::{file_size_opts, FileSize}; use miette::{IntoDiagnostic, Result, WrapErr}; -use nassun::NassunOpts; use oro_common::{Bin, Manifest, NpmUser, Person, PersonField, VersionMetadata}; use term_grid::{Cell, Direction, Filling, Grid, GridOptions}; -use url::Url; use crate::commands::OroCommand; +use crate::nassun_args::NassunArgs; #[derive(Debug, Args)] /// Get information about a package. @@ -20,36 +17,17 @@ pub struct ViewCmd { #[arg()] pkg: String, - #[arg(from_global)] - registry: Url, - - #[arg(from_global)] - scoped_registries: Vec<(String, Url)>, - - #[arg(from_global)] - root: Option, - - #[arg(from_global)] - cache: Option, - #[arg(from_global)] json: bool, + + #[command(flatten)] + nassun_args: NassunArgs, } #[async_trait] impl OroCommand for ViewCmd { async fn execute(self) -> Result<()> { - let mut nassun_opts = NassunOpts::new().registry(self.registry); - for (scope, registry) in self.scoped_registries { - nassun_opts = nassun_opts.scope_registry(scope, registry); - } - if let Some(root) = self.root { - nassun_opts = nassun_opts.base_dir(root); - } - if let Some(cache) = self.cache { - nassun_opts = nassun_opts.cache(cache); - } - let pkg = nassun_opts.build().resolve(&self.pkg).await?; + let pkg = self.nassun_args.to_nassun().resolve(&self.pkg).await?; let packument = pkg.packument().await?; let metadata = pkg.metadata().await?; // TODO: oro view pkg [[....]] diff --git a/src/lib.rs b/src/lib.rs index 197506d1..afae0d51 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -57,8 +57,9 @@ use url::Url; use commands::OroCommand; -mod apply; +mod apply_args; mod commands; +mod nassun_args; const MAX_RETAINED_LOGS: usize = 5; @@ -435,6 +436,8 @@ where #[derive(Debug, Subcommand)] pub enum OroCmd { + Add(commands::add::AddCmd), + Apply(commands::apply::ApplyCmd), Ping(commands::ping::PingCmd), @@ -452,6 +455,7 @@ impl OroCommand for Orogene { async fn execute(self) -> Result<()> { log_command_line(); match self.subcommand { + OroCmd::Add(cmd) => cmd.execute().await, OroCmd::Apply(cmd) => cmd.execute().await, OroCmd::Ping(cmd) => cmd.execute().await, OroCmd::Reapply(cmd) => cmd.execute().await, diff --git a/src/nassun_args.rs b/src/nassun_args.rs new file mode 100644 index 00000000..c9dc5718 --- /dev/null +++ b/src/nassun_args.rs @@ -0,0 +1,52 @@ +use std::path::PathBuf; + +use clap::Args; +use nassun::{Nassun, NassunOpts}; +use url::Url; + +use crate::apply_args::ApplyArgs; + +#[derive(Debug, Args)] +pub struct NassunArgs { + /// Default dist-tag to use when resolving package versions. + #[arg(long, default_value = "latest")] + default_tag: String, + + #[arg(from_global)] + registry: Url, + + #[arg(from_global)] + scoped_registries: Vec<(String, Url)>, + + #[arg(from_global)] + root: PathBuf, + + #[arg(from_global)] + cache: Option, +} + +impl NassunArgs { + pub fn from_apply_args(apply_args: &ApplyArgs) -> Self { + Self { + default_tag: apply_args.default_tag.clone(), + registry: apply_args.registry.clone(), + scoped_registries: apply_args.scoped_registries.clone(), + root: apply_args.root.clone(), + cache: apply_args.cache.clone(), + } + } + + pub fn to_nassun(&self) -> Nassun { + let mut nassun_opts = NassunOpts::new() + .registry(self.registry.clone()) + .base_dir(self.root.clone()) + .default_tag(&self.default_tag); + for (scope, registry) in &self.scoped_registries { + nassun_opts = nassun_opts.scope_registry(scope.clone(), registry.clone()); + } + if let Some(cache) = &self.cache { + nassun_opts = nassun_opts.cache(cache.clone()); + } + nassun_opts.build() + } +} diff --git a/tests/help.rs b/tests/help.rs index a7487231..1ed11e8a 100644 --- a/tests/help.rs +++ b/tests/help.rs @@ -2,6 +2,11 @@ use std::process::{Command, Output, Stdio}; static BIN: &str = env!("CARGO_BIN_EXE_oro"); +#[test] +fn add_markdown() { + insta::assert_snapshot!("add", sub_md("add")); +} + #[test] fn apply_markdown() { insta::assert_snapshot!("apply", sub_md("apply")); diff --git a/tests/snapshots/help__add.snap b/tests/snapshots/help__add.snap new file mode 100644 index 00000000..9f887afa --- /dev/null +++ b/tests/snapshots/help__add.snap @@ -0,0 +1,166 @@ +--- +source: tests/help.rs +expression: "sub_md(\"add\")" +--- +stderr: + +stdout: +# oro add + +Adds one or more dependencies to the target package + +### Usage: + +``` +oro add [OPTIONS] ... +``` + +### Arguments + +#### `...` + +Specifiers for packages to add + +### Options + +#### `-D, --dev` + +Add packages as devDependencies + +#### `-O, --opt` + +Add packages as optionalDependencies + +\[aliases: optional] + +#### `-h, --help` + +Print help (see a summary with '-h') + +#### `-V, --version` + +Print version + +### Apply Options + +#### `--no-apply` + +Prevent all apply operations from executing + +#### `--prefer-copy` + +When extracting packages, prefer to copy files files instead of linking them. + +This option has no effect if hard linking fails (for example, if the cache is on a different drive), or if the project is on a filesystem that supports Copy-on-Write (zfs, btrfs, APFS (macOS), etc). + +#### `--validate` + +Validate the integrity of installed files. + +When this is true, orogene will verify all files extracted from the cache, as well as verify that any files in the existing `node_modules` are unmodified. If verification fails, the packages will be reinstalled. + +#### `--lockfile-only` + +Whether to skip restoring packages into `node_modules` and just resolve the tree and write the lockfile + +#### `--no-scripts` + +Skip running install scripts + +#### `--default-tag ` + +Default dist-tag to use when resolving package versions + +\[default: latest] + +#### `--concurrency ` + +Controls number of concurrent operations during various apply steps (resolution fetches, extractions, etc). + +Tuning this might help reduce memory usage (if lowered), or improve performance (if increased). + +\[default: 50] + +#### `--script-concurrency ` + +Controls number of concurrent script executions while running `run_script`. + +This option is separate from `concurrency` because executing concurrent scripts is a much heavier operation. + +\[default: 6] + +#### `--no-lockfile` + +Disable writing the lockfile after operations complete. + +Note that lockfiles are only written after all operations complete successfully. + +#### `--hoisted` + +Use the hoisted installation mode, where all dependencies and their transitive dependencies are installed as high up in the `node_modules` tree as possible. + +This can potentially mean that packages have access to dependencies they did not specify in their package.json, but it might be useful for compatibility. + +By default, dependencies are installed in "isolated" mode, using a symlink/junction structure to simulate a dependency tree. + +### Global Options + +#### `--root ` + +Path to the project to operate on. + +By default, Orogene will look up from the current working directory until it finds a directory with a `package.json` file or a `node_modules/` directory. + +\[default: .] + +#### `--registry ` + +Registry used for unscoped packages + +\[default: https://registry.npmjs.org] + +#### `--scoped-registry ` + +Registry to use for a specific `@scope`, using `--scoped-registry @scope=https://foo.com` format. + +Can be provided multiple times to specify multiple scoped registries. + +#### `--cache ` + +Location of disk cache. + +Default location varies by platform. + +#### `--config ` + +File to read configuration values from. + +When specified, global configuration loading is disabled and configuration values will only be read from this location. + +#### `--loglevel ` + +Log output level/directive. + +Supports plain loglevels (off, error, warn, info, debug, trace) as well as more advanced directives in the format `target[span{field=value}]=level`. + +\[default: info] + +#### `-q, --quiet` + +Disable all output + +#### `--json` + +Format output as JSON + +#### `--no-progress` + +Disable the progress bars + +#### `--no-emoji` + +Disable printing emoji. + +By default, this will show emoji when outputting to a TTY that supports unicode. + + diff --git a/tests/snapshots/help__view.snap b/tests/snapshots/help__view.snap index 807a36cd..6f3e9573 100644 --- a/tests/snapshots/help__view.snap +++ b/tests/snapshots/help__view.snap @@ -25,6 +25,12 @@ Package spec to look up ### Options +#### `--default-tag ` + +Default dist-tag to use when resolving package versions + +\[default: latest] + #### `-h, --help` Print help (see a summary with '-h')