diff --git a/docs/pm/cli/outdated.mdx b/docs/pm/cli/outdated.mdx index 361f0bd65e8f..d7a4cbe72d7d 100644 --- a/docs/pm/cli/outdated.mdx +++ b/docs/pm/cli/outdated.mdx @@ -94,6 +94,18 @@ bun outdated '!@types/*' | typescript (dev) | 5.7.2 | 5.7.3 | 5.7.3 | ``` +An aliased dependency such as `"lodash3": "npm:lodash@^3.0.0"` is listed with both of its names, and a pattern selects it by either name: + +```sh terminal icon="terminal" +bun outdated lodash +``` + +```txt +| Package | Current | Update | Latest | +| -------------------- | ------- | ------ | ------- | +| lodash3@npm:lodash | 3.10.1 | 3.10.1 | 4.17.21 | +``` + ### Workspace Filters Use the `--filter` flag to check for outdated dependencies in a different workspace package: diff --git a/docs/pm/cli/why.mdx b/docs/pm/cli/why.mdx index 2091605c8c99..697139b4df87 100644 --- a/docs/pm/cli/why.mdx +++ b/docs/pm/cli/why.mdx @@ -13,7 +13,7 @@ bun why ## Arguments -- ``: The name of the package to explain. Supports glob patterns like `@org/*` or `*-lodash`. +- ``: The name of the package to explain, or the alias a `package.json` gives it. Supports glob patterns like `@org/*` or `*-lodash`. ## Options @@ -47,6 +47,17 @@ bun why "@types/*" └─ dev my-app@1.0.0 (requires ^18.0.0) ``` +Find the package behind an alias. With `"lodash3": "npm:lodash@^3.0.0"` in `package.json`, both `bun why lodash3` and `bun why lodash` print: + +```bash terminal icon="terminal" +bun why lodash3 +``` + +```txt +lodash@3.10.1 + └─ my-app@1.0.0 (requires lodash3@npm:lodash@^3.0.0) +``` + Show only top-level dependencies: ```bash terminal icon="terminal" diff --git a/src/install/dependency.rs b/src/install/dependency.rs index ff459e1932eb..d8885a97d686 100644 --- a/src/install/dependency.rs +++ b/src/install/dependency.rs @@ -93,6 +93,12 @@ pub trait DependencyExt { builder: &mut SB, ) -> Result; fn realname(&self) -> String; + /// The name package.json gives this dependency, when it is not + /// `package_name`, the name of the package it resolves to: the `my-alias` + /// of `"my-alias": "npm:dep@1.0.0"`. Unlike `realname`, this also holds for + /// a `catalog:` or overridden dependency. A command that takes or prints a + /// package name has to handle both names. + fn alias_for<'a>(&'a self, package_name: &[u8], buf: &'a [u8]) -> Option<&'a [u8]>; fn eql(&self, b: &Dependency, lhs_buf: &[u8], rhs_buf: &[u8]) -> bool; fn is_remote_tarball(dep: &[u8]) -> bool; fn parse<'a, 'b>( @@ -245,6 +251,11 @@ impl DependencyExt for Dependency { } } + fn alias_for<'a>(&'a self, package_name: &[u8], buf: &'a [u8]) -> Option<&'a [u8]> { + let name = self.name.slice(buf); + (name != package_name).then_some(name) + } + fn eql(&self, b: &Dependency, lhs_buf: &[u8], rhs_buf: &[u8]) -> bool { self.name_hash == b.name_hash && self.name.len() == b.name.len() diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index fd409cdd149b..889f99a9ca52 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -2192,7 +2192,7 @@ Full documentation is available at https://bun.com/docs/cli/info Explain why a package is installed Arguments: - \\ The package name to explain (supports glob patterns like '@org/*') + \\ The package name or alias to explain (supports glob patterns like '@org/*') Options: --top Show only the top dependency tree instead of nested ones diff --git a/src/runtime/cli/outdated_command.rs b/src/runtime/cli/outdated_command.rs index 003000547e14..bf5889b1a738 100644 --- a/src/runtime/cli/outdated_command.rs +++ b/src/runtime/cli/outdated_command.rs @@ -12,7 +12,9 @@ use bun_install::lockfile::{LoadResult, LoadStep}; use bun_install::package_manager::{ LogLevel, Subcommand, WorkspaceFilter, populate_manifest_cache, }; -use bun_install::{CommandLineArguments, DependencyID, PackageID, PackageManager, resolution}; +use bun_install::{ + CommandLineArguments, DependencyExt as _, DependencyID, PackageID, PackageManager, resolution, +}; use bun_wyhash::hash; use crate::Command; @@ -34,6 +36,10 @@ struct GroupedOutdatedInfo { grouped_workspace_names: Option>, } +fn visible_width(text: &[u8]) -> usize { + strings::visible::width::exclude_ansi_colors::utf8(text) +} + /// The rows of the `bun outdated` table and the column widths that fit them. struct OutdatedTable { rows: Vec, @@ -64,6 +70,27 @@ impl<'a> FilterType<'a> { // *NOTE*: name and path are not allocated → no Drop impl needed. } +/// Printed between the two names of an aliased dependency: the row for +/// `"my-alias": "npm:dep@1.0.0"` reads `my-alias@npm:dep`, which is what +/// `bun add` takes and prints. +const ALIAS_SEPARATOR: &str = "@npm:"; + +/// A pattern selects a dependency by the name of its package or by its alias, +/// so every name the table prints can be passed back as a filter. A `!pattern` +/// keeps the dependency only when neither name matches. +fn name_pattern_matches(pattern: &[u8], package_name: &[u8], alias: Option<&[u8]>) -> bool { + let by_package_name = glob::r#match(pattern, package_name); + let Some(alias) = alias else { + return by_package_name.matches(); + }; + let by_alias = glob::r#match(pattern, alias); + if by_alias.is_negated() { + by_alias.matches() && by_package_name.matches() + } else { + by_alias.matches() || by_package_name.matches() + } +} + impl OutdatedCommand { pub(crate) fn exec(ctx: Command::Context) -> crate::Result<()> { bun_core::prettyln!( @@ -390,7 +417,10 @@ impl OutdatedCommand { continue; } - // package patterns match against dependency name (name in package.json) + let package_name = + manager.lockfile.packages.items_name()[package_id as usize].slice(string_buf); + let alias = dep.alias_for(package_name, string_buf); + if let Some(patterns) = &package_patterns { let matched = 'match_: { for pattern in patterns { @@ -400,9 +430,7 @@ impl OutdatedCommand { if name_pattern.is_empty() { continue; } - if !glob::r#match(name_pattern, dep.name.slice(string_buf)) - .matches() - { + if !name_pattern_matches(name_pattern, package_name, alias) { break 'match_ false; } } @@ -416,8 +444,6 @@ impl OutdatedCommand { } } - let package_name = - manager.lockfile.packages.items_name()[package_id as usize].slice(string_buf); let scope = manager.options.scope_for_package_name(package_name).clone(); let mut expired = false; let Some(manifest) = manager.manifests.by_name_allow_expired( @@ -465,6 +491,7 @@ impl OutdatedCommand { } let package_name_len = package_name.len() + + alias.map_or(0, |alias| visible_width(alias) + ALIAS_SEPARATOR.len()) + if dep.behavior.is_dev() { " (dev)".len() } else if dep.behavior.is_peer() { @@ -696,10 +723,13 @@ impl OutdatedCommand { for _ in 0..COLUMN_LEFT_PAD { bun_core::pretty!(" "); } + let mut name_len = package_name.len() + behavior_str.len(); + if let Some(alias) = dep.alias_for(package_name, string_buf) { + bun_core::pretty!("{}{}", BStr::new(alias), ALIAS_SEPARATOR); + name_len += visible_width(alias) + ALIAS_SEPARATOR.len(); + } bun_core::pretty!("{}{}", BStr::new(package_name), behavior_str); - for _ in package_name.len() + behavior_str.len() - ..package_column_inside_length + COLUMN_RIGHT_PAD - { + for _ in name_len..package_column_inside_length + COLUMN_RIGHT_PAD { bun_core::pretty!(" "); } } diff --git a/src/runtime/cli/why_command.rs b/src/runtime/cli/why_command.rs index 498202cfcd6f..16fba21e1f82 100644 --- a/src/runtime/cli/why_command.rs +++ b/src/runtime/cli/why_command.rs @@ -11,7 +11,10 @@ use bun_core::{Global, Output}; use bun_install::dependency::Behavior; use bun_install::lockfile::Lockfile; use bun_install::lockfile::package::PackageColumns as _; -use bun_install::{CommandLineArguments, PackageID, PackageManager, Subcommand, package_manager}; +use bun_install::{ + CommandLineArguments, DependencyExt as _, PackageID, PackageManager, Subcommand, + package_manager, +}; use bun_semver as semver; use crate::command; @@ -37,6 +40,8 @@ struct VersionInfo { struct DependentInfo { name: Box<[u8]>, version: Box<[u8]>, + /// What this dependent calls the package, when that is not the package's name. + alias: Option>, spec: Box<[u8]>, dep_type: DependencyType, pkg_id: PackageID, @@ -278,7 +283,7 @@ impl WhyCommand { "Explain why a package is installed\n\ \n\ Arguments:\n\ - \\ The package name to explain (supports glob patterns like '@org/*')\n\ + \\ The package name or alias to explain (supports glob patterns like '@org/*')\n\ \n\ Options:\n\ --top Show only the top dependency tree instead of nested ones\n\ @@ -385,6 +390,17 @@ impl WhyCommand { let _pkg_resolutions = packages.items_resolutions(); let pkg_resolution = packages.items_resolution(); + // The pattern also selects a package through the name of a dependency + // that resolves to it, so an alias finds the package behind it. + let mut matched_by_dependency_name = vec![false; packages.len()]; + for (dependency, &target_id) in dependencies_items.iter().zip(resolutions_items) { + if (target_id as usize) < packages.len() + && glob.matches_name(dependency.name.slice(string_bytes), package_pattern) + { + matched_by_dependency_name[target_id as usize] = true; + } + } + for pkg_idx in 0..packages.len() { let pkg_name = pkg_names[pkg_idx].slice(string_bytes); @@ -415,6 +431,13 @@ impl WhyCommand { let spec: Box<[u8]> = Box::<[u8]>::from(dependency.version.literal.slice(string_bytes)); + let alias = dependency + .alias_for( + pkg_names[target_id as usize].slice(string_bytes), + string_bytes, + ) + .map(Box::<[u8]>::from); + let dep_type = if dependency.behavior.contains(Behavior::DEV) { DependencyType::Dev } else if dependency.behavior.contains(Behavior::OPTIONAL) @@ -435,6 +458,7 @@ impl WhyCommand { dependents_entry.push(DependentInfo { name: Box::<[u8]>::from(pkg_name), version: dep_pkg_version, + alias, spec, dep_type, pkg_id: PackageID::try_from(pkg_idx).expect("int cast"), @@ -442,7 +466,8 @@ impl WhyCommand { }); } - if !glob.matches_name(pkg_name, package_pattern) { + if !glob.matches_name(pkg_name, package_pattern) && !matched_by_dependency_name[pkg_idx] + { continue; } @@ -557,10 +582,16 @@ fn print_package_with_type(prefix: &[u8], package: &DependentInfo) { } } - if !package.spec.is_empty() { - bun_core::prettyln!(" (requires {})", BStr::new(&package.spec)); - } else { + if package.spec.is_empty() { bun_core::prettyln!(""); + } else if let Some(alias) = &package.alias { + bun_core::prettyln!( + " (requires {}@{})", + BStr::new(alias), + BStr::new(&package.spec) + ); + } else { + bun_core::prettyln!(" (requires {})", BStr::new(&package.spec)); } } diff --git a/test/cli/install/bun-install-registry.test.ts b/test/cli/install/bun-install-registry.test.ts index 01cc01240eaf..db20c00c1b5f 100644 --- a/test/cli/install/bun-install-registry.test.ts +++ b/test/cli/install/bun-install-registry.test.ts @@ -9569,6 +9569,72 @@ describe("outdated", () => { expect(out).toContain("prereleases-1"); }); + test("an aliased dependency prints both of its names and a pattern matches either", async () => { + await write( + packageJson, + JSON.stringify({ + name: "foo", + dependencies: { + "a-dep": "1.0.1", + "my-alias": "npm:no-deps@1.0.0", + // Two columns wide per character. + "別名": "npm:no-deps@1.0.0", + }, + devDependencies: { + "dev-alias": "npm:no-deps@1.0.0", + }, + }), + ); + await runBunInstall(env, packageDir); + + const table = async (...args: string[]) => { + const out = await runBunOutdated({ ...env, NO_COLOR: "1" }, packageDir, ...args); + return out + .slice(out.indexOf("\n") + 1) + .trimEnd() + .split("\n"); + }; + // The Package column of every row. + const rows = async (...args: string[]) => + (await table(...args)) + .filter(line => line.startsWith("| ") && !line.startsWith("| Package ")) + .map(line => line.split("|")[1].trim()); + + const myAlias = "my-alias@npm:no-deps"; + const wideAlias = "別名@npm:no-deps"; + const devAlias = "dev-alias@npm:no-deps (dev)"; + const all = await table(); + expect({ + all, + byPackageName: await rows("no-deps"), + byAlias: await rows("my-alias"), + byAliasGlob: await rows("*-alias"), + notPackageName: await rows("!no-deps"), + notAlias: await rows("!my-alias"), + }).toEqual({ + all: [ + "|---------------------------------------------------------|", + "| Package | Current | Update | Latest |", + "|-----------------------------|---------|--------|--------|", + "| a-dep | 1.0.1 | 1.0.1 | 1.0.10 |", + "|-----------------------------|---------|--------|--------|", + "| my-alias@npm:no-deps | 1.0.0 | 1.0.0 | 2.0.0 |", + "|-----------------------------|---------|--------|--------|", + "| 別名@npm:no-deps | 1.0.0 | 1.0.0 | 2.0.0 |", + "|-----------------------------|---------|--------|--------|", + "| dev-alias@npm:no-deps (dev) | 1.0.0 | 1.0.0 | 2.0.0 |", + "|---------------------------------------------------------|", + ], + byPackageName: [myAlias, wideAlias, devAlias], + byAlias: [myAlias], + byAliasGlob: [myAlias, devAlias], + notPackageName: ["a-dep"], + notAlias: ["a-dep", wideAlias, devAlias], + }); + // The borders line up: every line takes the same number of terminal columns. + expect(all.map(line => Bun.stringWidth(line))).toEqual(all.map(() => 59)); + }); + test("scoped workspace names", async () => { await Promise.all([ write( diff --git a/test/cli/install/bun-pm-why.test.ts b/test/cli/install/bun-pm-why.test.ts index aa147f047daf..456b00f31a69 100644 --- a/test/cli/install/bun-pm-why.test.ts +++ b/test/cli/install/bun-pm-why.test.ts @@ -1,11 +1,12 @@ import { spawn } from "bun"; import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, tempDir, tempDirWithFiles } from "harness"; +import { VerdaccioRegistry, bunEnv, bunExe, tempDir, tempDirWithFiles } from "harness"; import { existsSync, mkdtempSync, realpathSync } from "node:fs"; import { mkdir, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +const registry = new VerdaccioRegistry(); let package_dir: string; let i = 0; beforeAll(async () => { @@ -13,9 +14,11 @@ beforeAll(async () => { package_dir = join(base, `why-test-${Math.random().toString(36).slice(2)}`); await mkdir(package_dir, { recursive: true }); + await registry.start(); }); afterAll(async () => { + registry.stop(); if (existsSync(package_dir)) { await rm(package_dir, { recursive: true, force: true }); } @@ -219,40 +222,70 @@ describe.concurrent.each(["why", "pm why"])("bun %s", cmd => { expect(output).toContain("pkg-b@"); }); - it("should handle npm aliases", async () => { - await using tmpDir = tempDir(`why-alias-${i++}`, { - "package.json": JSON.stringify({ - name: "foo", - version: "0.0.1", - dependencies: { - "alias-pkg": "npm:lodash@^4.17.21", - }, - }), + it("should find a package by its own name and by the alias a dependent gives it", async () => { + // `my-alias` and `no-deps` are two versions of one package. The registry's + // alias-loop-1 depends on alias-loop-2 as `alias1`. + const { packageDir } = await registry.createTestDir({ + bunfigOpts: { saveTextLockfile: true, linker: "hoisted" }, + files: { + "package.json": JSON.stringify({ + name: "foo", + version: "0.0.1", + dependencies: { + "no-deps": "2.0.0", + "my-alias": "npm:no-deps@1.0.0", + "alias-loop-1": "1.0.0", + }, + }), + }, }); - const install = spawn({ + await using install = spawn({ cmd: [bunExe(), "install", "--lockfile-only"], - cwd: tmpDir, - env: bunEnv, - stdout: "inherit", - stderr: "inherit", - }); - expect(await install.exited).toBe(0); - - const { stdout, stderr, exited } = spawn({ - cmd: [bunExe(), ...cmd.split(" "), "alias-pkg"], - cwd: tmpDir, + cwd: packageDir, env: bunEnv, stdout: "pipe", - stderr: "inherit", + stderr: "pipe", + }); + const [, installStderr, installExitCode] = await Promise.all([ + install.stdout.text(), + install.stderr.text(), + install.exited, + ]); + expect(installStderr).toContain("Saved lockfile"); + expect(installExitCode).toBe(0); + + // One block per matched package, in no fixed order. + const why = async (...args: string[]) => { + await using proc = spawn({ + cmd: [bunExe(), ...cmd.split(" "), ...args], + cwd: packageDir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { blocks: stdout.trim().split("\n\n").sort(), stderr, exitCode }; + }; + + const viaAlias = "no-deps@1.0.0\n └─ foo (requires my-alias@npm:no-deps@1.0.0)"; + const viaName = "no-deps@2.0.0\n └─ foo (requires 2.0.0)"; + const [byAlias, byAliasGlob, byPackageName, byTransitiveAlias] = await Promise.all([ + why("my-alias"), + why("my-*"), + why("no-deps"), + why("alias1", "--top"), + ]); + expect({ byAlias, byAliasGlob, byPackageName, byTransitiveAlias }).toEqual({ + byAlias: { blocks: [viaAlias], stderr: "", exitCode: 0 }, + byAliasGlob: { blocks: [viaAlias], stderr: "", exitCode: 0 }, + byPackageName: { blocks: [viaAlias, viaName], stderr: "", exitCode: 0 }, + byTransitiveAlias: { + blocks: ["alias-loop-2@1.0.0\n └─ alias-loop-1@1.0.0 (requires alias1@npm:alias-loop-2@*)"], + stderr: "", + exitCode: 0, + }, }); - - if ((await exited) === 0) { - const output = await stdout.text(); - expect(output).toContain("alias-pkg@"); - } else { - expect(true).toBe(true); - } }); it("should show error for non-existent package", async () => {