1
0
Fork 0
mirror of https://github.com/denoland/deno.git synced 2024-12-28 01:59:06 -05:00
denoland-deno/cli/tools/registry/pm/outdated.rs
Nathan Whitaker 56f31628f7
feat: subcommand to view and update outdated dependencies (#26942)
Closes #20487

Currently spelled

```
deno outdated
```
and
```
deno outdated --update
```

Works across package.json and deno.json, and in workspaces.

There's a bit of duplicated code, I'll refactor to reduce this in follow
ups

## Currently supported:
### Printing outdated deps (current output below which basically mimics
pnpm, but requesting feedback / suggestions)

```
deno outdated
```
![Screenshot 2024-11-19 at 2 01
56 PM](https://github.com/user-attachments/assets/51fea83a-181a-4082-b388-163313ce15e7)

### Updating deps

semver compatible:
```
deno outdated --update
```
latest:
```
deno outdated --latest
```
current output is basic, again would love suggestions
![Screenshot 2024-11-19 at 2 13
46 PM](https://github.com/user-attachments/assets/e4c4db87-cd67-4b74-9ea7-4bd80106d5e9)

#### Filters
```
deno outdated --update "@std/*"
deno outdated --update --latest "@std/* "!@std/fmt"
```
#### Update to specific versions
```
deno outdated --update @std/fmt@1.0.2 @std/cli@^1.0.3
```

### Include all workspace members
```
deno outdated --recursive
deno outdated --update --recursive
```

## Future work
- interactive update
- update deps in js/ts files
- better support for transitive deps

Known issues (to be fixed in follow ups):
- If no top level dependencies have changed, we won't update transitive
deps (even if they could be updated)
- Can't filter transitive deps, or update them to specific versions

## TODO (in this PR):
- ~~spec tests for filters~~
- ~~spec test for mixed workspace (have tested manually)~~
- tweak output
- suggestion when you try `deno update`

---------

Co-authored-by: Bartek Iwańczuk <biwanczuk@gmail.com>
2024-11-20 15:22:15 -08:00

661 lines
18 KiB
Rust

// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
use std::collections::HashSet;
use std::sync::Arc;
use deno_core::error::AnyError;
use deno_semver::package::PackageNv;
use deno_semver::package::PackageReq;
use deno_semver::VersionReq;
use deno_terminal::colors;
use crate::args::CacheSetting;
use crate::args::CliOptions;
use crate::args::Flags;
use crate::args::OutdatedFlags;
use crate::factory::CliFactory;
use crate::file_fetcher::FileFetcher;
use crate::jsr::JsrFetchResolver;
use crate::npm::NpmFetchResolver;
use crate::tools::registry::pm::deps::DepKind;
use super::deps::Dep;
use super::deps::DepManager;
use super::deps::DepManagerArgs;
use super::deps::PackageLatestVersion;
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
struct OutdatedPackage {
kind: DepKind,
latest: String,
semver_compatible: String,
current: String,
name: String,
}
#[allow(clippy::print_stdout)]
fn print_outdated_table(packages: &[OutdatedPackage]) {
const HEADINGS: &[&str] = &["Package", "Current", "Update", "Latest"];
let mut longest_package = 0;
let mut longest_current = 0;
let mut longest_update = 0;
let mut longest_latest = 0;
for package in packages {
let name_len = package.kind.scheme().len() + 1 + package.name.len();
longest_package = longest_package.max(name_len);
longest_current = longest_current.max(package.current.len());
longest_update = longest_update.max(package.semver_compatible.len());
longest_latest = longest_latest.max(package.latest.len());
}
let package_column_width = longest_package.max(HEADINGS[0].len()) + 2;
let current_column_width = longest_current.max(HEADINGS[1].len()) + 2;
let update_column_width = longest_update.max(HEADINGS[2].len()) + 2;
let latest_column_width = longest_latest.max(HEADINGS[3].len()) + 2;
let package_fill = "".repeat(package_column_width);
let current_fill = "".repeat(current_column_width);
let update_fill = "".repeat(update_column_width);
let latest_fill = "".repeat(latest_column_width);
println!("{package_fill}{current_fill}{update_fill}{latest_fill}");
println!(
"{}{}{}{}{}{}{}{}",
colors::intense_blue(HEADINGS[0]),
" ".repeat(package_column_width - 2 - HEADINGS[0].len()),
colors::intense_blue(HEADINGS[1]),
" ".repeat(current_column_width - 2 - HEADINGS[1].len()),
colors::intense_blue(HEADINGS[2]),
" ".repeat(update_column_width - 2 - HEADINGS[2].len()),
colors::intense_blue(HEADINGS[3]),
" ".repeat(latest_column_width - 2 - HEADINGS[3].len())
);
for package in packages {
println!("{package_fill}{current_fill}{update_fill}{latest_fill}",);
print!(
"{:<package_column_width$} ",
format!("{}:{}", package.kind.scheme(), package.name),
package_column_width = package_column_width - 2
);
print!(
"{:<current_column_width$} ",
package.current,
current_column_width = current_column_width - 2
);
print!(
"{:<update_column_width$} ",
package.semver_compatible,
update_column_width = update_column_width - 2
);
println!(
"{:<latest_column_width$}",
package.latest,
latest_column_width = latest_column_width - 2
);
}
println!("{package_fill}{current_fill}{update_fill}{latest_fill}",);
}
fn print_outdated(
deps: &mut DepManager,
compatible: bool,
) -> Result<(), AnyError> {
let mut outdated = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for (dep_id, resolved, latest_versions) in
deps.deps_with_resolved_latest_versions()
{
let dep = deps.get_dep(dep_id);
let Some(resolved) = resolved else { continue };
let latest = {
let preferred = if compatible {
&latest_versions.semver_compatible
} else {
&latest_versions.latest
};
if let Some(v) = preferred {
v
} else {
continue;
}
};
if latest > &resolved
&& seen.insert((dep.kind, dep.req.name.clone(), resolved.version.clone()))
{
outdated.push(OutdatedPackage {
kind: dep.kind,
name: dep.req.name.clone(),
current: resolved.version.to_string(),
latest: latest_versions
.latest
.map(|l| l.version.to_string())
.unwrap_or_default(),
semver_compatible: latest_versions
.semver_compatible
.map(|l| l.version.to_string())
.unwrap_or_default(),
})
}
}
if !outdated.is_empty() {
outdated.sort();
print_outdated_table(&outdated);
}
Ok(())
}
pub async fn outdated(
flags: Arc<Flags>,
update_flags: OutdatedFlags,
) -> Result<(), AnyError> {
let factory = CliFactory::from_flags(flags.clone());
let cli_options = factory.cli_options()?;
let workspace = cli_options.workspace();
let http_client = factory.http_client_provider();
let deps_http_cache = factory.global_http_cache()?;
let mut file_fetcher = FileFetcher::new(
deps_http_cache.clone(),
CacheSetting::RespectHeaders,
true,
http_client.clone(),
Default::default(),
None,
);
file_fetcher.set_download_log_level(log::Level::Trace);
let file_fetcher = Arc::new(file_fetcher);
let npm_fetch_resolver = Arc::new(NpmFetchResolver::new(
file_fetcher.clone(),
cli_options.npmrc().clone(),
));
let jsr_fetch_resolver =
Arc::new(JsrFetchResolver::new(file_fetcher.clone()));
let args = dep_manager_args(
&factory,
cli_options,
npm_fetch_resolver.clone(),
jsr_fetch_resolver.clone(),
)
.await?;
let filter_set = filter::FilterSet::from_filter_strings(
update_flags.filters.iter().map(|s| s.as_str()),
)?;
let filter_fn = |alias: Option<&str>, req: &PackageReq, _: DepKind| {
if filter_set.is_empty() {
return true;
}
let name = alias.unwrap_or(&req.name);
filter_set.matches(name)
};
let mut deps = if update_flags.recursive {
super::deps::DepManager::from_workspace(workspace, filter_fn, args)?
} else {
super::deps::DepManager::from_workspace_dir(
&cli_options.start_dir,
filter_fn,
args,
)?
};
deps.resolve_versions().await?;
match update_flags.kind {
crate::args::OutdatedKind::Update { latest } => {
update(deps, latest, &filter_set, flags).await?;
}
crate::args::OutdatedKind::PrintOutdated { compatible } => {
print_outdated(&mut deps, compatible)?;
}
}
Ok(())
}
fn choose_new_version_req(
dep: &Dep,
resolved: Option<&PackageNv>,
latest_versions: &PackageLatestVersion,
update_to_latest: bool,
filter_set: &filter::FilterSet,
) -> Option<VersionReq> {
let explicit_version_req = filter_set
.matching_filter(dep.alias.as_deref().unwrap_or(&dep.req.name))
.version_spec()
.cloned();
if let Some(version_req) = explicit_version_req {
if let Some(resolved) = resolved {
// todo(nathanwhit): handle tag
if version_req.tag().is_none() && version_req.matches(&resolved.version) {
return None;
}
}
Some(version_req)
} else {
let preferred = if update_to_latest {
latest_versions.latest.as_ref()?
} else {
latest_versions.semver_compatible.as_ref()?
};
if preferred.version <= resolved?.version {
return None;
}
Some(
VersionReq::parse_from_specifier(
format!("^{}", preferred.version).as_str(),
)
.unwrap(),
)
}
}
async fn update(
mut deps: DepManager,
update_to_latest: bool,
filter_set: &filter::FilterSet,
flags: Arc<Flags>,
) -> Result<(), AnyError> {
let mut updated = Vec::new();
for (dep_id, resolved, latest_versions) in deps
.deps_with_resolved_latest_versions()
.into_iter()
.collect::<Vec<_>>()
{
let dep = deps.get_dep(dep_id);
let new_version_req = choose_new_version_req(
dep,
resolved.as_ref(),
&latest_versions,
update_to_latest,
filter_set,
);
let Some(new_version_req) = new_version_req else {
continue;
};
updated.push((
dep_id,
format!("{}:{}", dep.kind.scheme(), dep.req.name),
deps.resolved_version(dep.id).cloned(),
new_version_req.clone(),
));
deps.update_dep(dep_id, new_version_req);
}
deps.commit_changes()?;
if !updated.is_empty() {
let factory = super::npm_install_after_modification(
flags.clone(),
Some(deps.jsr_fetch_resolver.clone()),
)
.await?;
let mut updated_to_versions = HashSet::new();
let cli_options = factory.cli_options()?;
let args = dep_manager_args(
&factory,
cli_options,
deps.npm_fetch_resolver.clone(),
deps.jsr_fetch_resolver.clone(),
)
.await?;
let mut deps = deps.reloaded_after_modification(args);
deps.resolve_current_versions().await?;
for (dep_id, package_name, maybe_current_version, new_version_req) in
updated
{
if let Some(nv) = deps.resolved_version(dep_id) {
updated_to_versions.insert((
package_name,
maybe_current_version,
nv.version.clone(),
));
} else {
log::warn!(
"Failed to resolve version for new version requirement: {} -> {}",
package_name,
new_version_req
);
}
}
log::info!(
"Updated {} dependenc{}:",
updated_to_versions.len(),
if updated_to_versions.len() == 1 {
"y"
} else {
"ies"
}
);
let mut updated_to_versions =
updated_to_versions.into_iter().collect::<Vec<_>>();
updated_to_versions.sort_by(|(k, _, _), (k2, _, _)| k.cmp(k2));
let max_name = updated_to_versions
.iter()
.map(|(name, _, _)| name.len())
.max()
.unwrap_or(0);
let max_old = updated_to_versions
.iter()
.map(|(_, maybe_current, _)| {
maybe_current
.as_ref()
.map(|v| v.version.to_string().len())
.unwrap_or(0)
})
.max()
.unwrap_or(0);
let max_new = updated_to_versions
.iter()
.map(|(_, _, new_version)| new_version.to_string().len())
.max()
.unwrap_or(0);
for (package_name, maybe_current_version, new_version) in
updated_to_versions
{
let current_version = if let Some(current_version) = maybe_current_version
{
current_version.version.to_string()
} else {
"".to_string()
};
log::info!(
" - {}{} {}{} -> {}{}",
format!(
"{}{}",
colors::gray(package_name[0..4].to_string()),
package_name[4..].to_string()
),
" ".repeat(max_name - package_name.len()),
" ".repeat(max_old - current_version.len()),
colors::gray(&current_version),
" ".repeat(max_new - new_version.to_string().len()),
colors::green(&new_version),
);
}
} else {
log::info!(
"All {}dependencies are up to date.",
if filter_set.is_empty() {
""
} else {
"matching "
}
);
}
Ok(())
}
async fn dep_manager_args(
factory: &CliFactory,
cli_options: &CliOptions,
npm_fetch_resolver: Arc<NpmFetchResolver>,
jsr_fetch_resolver: Arc<JsrFetchResolver>,
) -> Result<DepManagerArgs, AnyError> {
Ok(DepManagerArgs {
module_load_preparer: factory.module_load_preparer().await?.clone(),
jsr_fetch_resolver,
npm_fetch_resolver,
npm_resolver: factory.npm_resolver().await?.clone(),
permissions_container: factory.root_permissions_container()?.clone(),
main_module_graph_container: factory
.main_module_graph_container()
.await?
.clone(),
lockfile: cli_options.maybe_lockfile().cloned(),
})
}
mod filter {
use deno_core::anyhow::anyhow;
use deno_core::anyhow::Context;
use deno_core::error::AnyError;
use deno_semver::VersionReq;
enum FilterKind {
Exclude,
Include,
}
pub struct Filter {
kind: FilterKind,
regex: regex::Regex,
version_spec: Option<VersionReq>,
}
fn pattern_to_regex(pattern: &str) -> Result<regex::Regex, AnyError> {
let escaped = regex::escape(pattern);
let unescaped_star = escaped.replace(r"\*", ".*");
Ok(regex::Regex::new(&format!("^{}$", unescaped_star))?)
}
impl Filter {
pub fn version_spec(&self) -> Option<&VersionReq> {
self.version_spec.as_ref()
}
pub fn from_str(input: &str) -> Result<Self, AnyError> {
let (kind, first_idx) = if input.starts_with('!') {
(FilterKind::Exclude, 1)
} else {
(FilterKind::Include, 0)
};
let s = &input[first_idx..];
let (pattern, version_spec) =
if let Some(scope_name) = s.strip_prefix('@') {
if let Some(idx) = scope_name.find('@') {
let (pattern, version_spec) = s.split_at(idx + 1);
(
pattern,
Some(
VersionReq::parse_from_specifier(
version_spec.trim_start_matches('@'),
)
.with_context(|| format!("Invalid filter \"{input}\""))?,
),
)
} else {
(s, None)
}
} else {
let mut parts = s.split('@');
let Some(pattern) = parts.next() else {
return Err(anyhow!("Invalid filter \"{input}\""));
};
(
pattern,
parts
.next()
.map(VersionReq::parse_from_specifier)
.transpose()
.with_context(|| format!("Invalid filter \"{input}\""))?,
)
};
Ok(Filter {
kind,
regex: pattern_to_regex(pattern)
.with_context(|| format!("Invalid filter \"{input}\""))?,
version_spec,
})
}
pub fn matches(&self, name: &str) -> bool {
self.regex.is_match(name)
}
}
pub struct FilterSet {
filters: Vec<Filter>,
has_exclude: bool,
has_include: bool,
}
impl FilterSet {
pub fn from_filter_strings<'a>(
filter_strings: impl IntoIterator<Item = &'a str>,
) -> Result<Self, AnyError> {
let filters = filter_strings
.into_iter()
.map(Filter::from_str)
.collect::<Result<Vec<_>, _>>()?;
let has_exclude = filters
.iter()
.any(|f| matches!(f.kind, FilterKind::Exclude));
let has_include = filters
.iter()
.any(|f| matches!(f.kind, FilterKind::Include));
Ok(FilterSet {
filters,
has_exclude,
has_include,
})
}
pub fn is_empty(&self) -> bool {
self.filters.is_empty()
}
pub fn matches(&self, name: &str) -> bool {
self.matching_filter(name).is_included()
}
pub fn matching_filter(&self, name: &str) -> MatchResult<'_> {
if self.filters.is_empty() {
return MatchResult::Included;
}
let mut matched = None;
for filter in &self.filters {
match filter.kind {
FilterKind::Include => {
if matched.is_none() && filter.matches(name) {
matched = Some(filter);
}
}
FilterKind::Exclude => {
if filter.matches(name) {
return MatchResult::Excluded;
}
}
}
}
if let Some(filter) = matched {
MatchResult::Matches(filter)
} else if self.has_exclude && !self.has_include {
MatchResult::Included
} else {
MatchResult::Excluded
}
}
}
pub enum MatchResult<'a> {
Matches(&'a Filter),
Included,
Excluded,
}
impl MatchResult<'_> {
pub fn version_spec(&self) -> Option<&VersionReq> {
match self {
MatchResult::Matches(filter) => filter.version_spec(),
_ => None,
}
}
pub fn is_included(&self) -> bool {
matches!(self, MatchResult::Included | MatchResult::Matches(_))
}
}
#[cfg(test)]
mod test {
fn matches_filters<'a, 'b>(
filters: impl IntoIterator<Item = &'a str>,
name: &str,
) -> bool {
let filters = super::FilterSet::from_filter_strings(filters).unwrap();
filters.matches(name)
}
fn version_spec(s: &str) -> deno_semver::VersionReq {
deno_semver::VersionReq::parse_from_specifier(s).unwrap()
}
#[test]
fn basic_glob() {
assert!(matches_filters(["foo*"], "foo"));
assert!(matches_filters(["foo*"], "foobar"));
assert!(!matches_filters(["foo*"], "barfoo"));
assert!(matches_filters(["*foo"], "foo"));
assert!(matches_filters(["*foo"], "barfoo"));
assert!(!matches_filters(["*foo"], "foobar"));
assert!(matches_filters(["@scope/foo*"], "@scope/foobar"));
}
#[test]
fn basic_glob_with_version() {
assert!(matches_filters(["foo*@1"], "foo",));
assert!(matches_filters(["foo*@1"], "foobar",));
assert!(matches_filters(["foo*@1"], "foo-bar",));
assert!(!matches_filters(["foo*@1"], "barfoo",));
assert!(matches_filters(["@scope/*@1"], "@scope/foo"));
}
#[test]
fn glob_exclude() {
assert!(!matches_filters(["!foo*"], "foo"));
assert!(!matches_filters(["!foo*"], "foobar"));
assert!(matches_filters(["!foo*"], "barfoo"));
assert!(!matches_filters(["!*foo"], "foo"));
assert!(!matches_filters(["!*foo"], "barfoo"));
assert!(matches_filters(["!*foo"], "foobar"));
assert!(!matches_filters(["!@scope/foo*"], "@scope/foobar"));
}
#[test]
fn multiple_globs() {
assert!(matches_filters(["foo*", "bar*"], "foo"));
assert!(matches_filters(["foo*", "bar*"], "bar"));
assert!(!matches_filters(["foo*", "bar*"], "baz"));
assert!(matches_filters(["foo*", "!bar*"], "foo"));
assert!(!matches_filters(["foo*", "!bar*"], "bar"));
assert!(matches_filters(["foo*", "!bar*"], "foobar"));
assert!(!matches_filters(["foo*", "!*bar"], "foobar"));
assert!(!matches_filters(["foo*", "!*bar"], "baz"));
let filters =
super::FilterSet::from_filter_strings(["foo*@1", "bar*@2"]).unwrap();
assert_eq!(
filters.matching_filter("foo").version_spec().cloned(),
Some(version_spec("1"))
);
assert_eq!(
filters.matching_filter("bar").version_spec().cloned(),
Some(version_spec("2"))
);
}
}
}