From a8c816fe33e078da474fc8f6f7002f2818f3eeaf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:56:42 +0000 Subject: [PATCH 1/4] install: ignore leading whitespace wherever a dependency literal is classified or re-parsed Dependency::parse strips leading whitespace from a package.json version literal before inferring its tag, but the package.json editor, the string buffer pre-count in Package::parse, the workspace: alias check and Dependency::clone all looked at the raw literal. Route them through one helper so a literal like " npm:no-deps@^1.0.0" behaves the same as the trimmed value everywhere: bun update keeps the alias and updates the entry, " catalog:" references survive bun update , a " file:" folder dependency no longer panics on install or gets saved to the lockfile with an empty specifier, and " workspace:name@range" resolves. --- .../PackageManager/PackageJSONEditor.rs | 33 ++++--- src/install/dependency.rs | 14 ++- src/install/lockfile/Package.rs | 11 ++- test/cli/install/bun-install-registry.test.ts | 83 +++++++++++++++++ test/cli/install/bun-install.test.ts | 44 ++++++++++ test/cli/install/bun-workspaces.test.ts | 10 ++- test/cli/install/catalogs.test.ts | 88 +++++++++++++++++++ 7 files changed, 263 insertions(+), 20 deletions(-) diff --git a/src/install/PackageManager/PackageJSONEditor.rs b/src/install/PackageManager/PackageJSONEditor.rs index 23322d4adf0c..bc35fb20a3fc 100644 --- a/src/install/PackageManager/PackageJSONEditor.rs +++ b/src/install/PackageManager/PackageJSONEditor.rs @@ -298,7 +298,8 @@ pub(crate) fn edit_update_no_args_in( let version_literal = value .as_utf8_string_literal() .unwrap_or_else(|| bun_core::out_of_memory()); - let mut tag = dependency::Tag::infer(version_literal); + let mut tag = + dependency::Tag::infer(dependency::trim_literal(version_literal)); // npm versions only (and dist-tags with --latest); `catalog:` is handled by edit_catalogs_*. if tag != dependency::Tag::Npm @@ -403,7 +404,9 @@ pub(crate) fn edit_update_no_args_in( let value_literal = value .as_utf8_string_literal() .unwrap_or_else(|| bun_core::out_of_memory()); - if dependency::Tag::infer(value_literal) == dependency::Tag::Catalog { + if dependency::Tag::infer(dependency::trim_literal(value_literal)) + == dependency::Tag::Catalog + { continue; } @@ -499,8 +502,9 @@ pub(crate) fn edit_update_no_args_in( }; if is_alias { - let dep_literal = - workspace_dep.version.literal.slice(string_buf); + let dep_literal = dependency::trim_literal( + workspace_dep.version.literal.slice(string_buf), + ); // negative because the real package might have a scope // e.g. "dep": "npm:@foo/bar@1.2.3" @@ -621,7 +625,7 @@ pub(crate) fn edit_catalogs_before_update( let version_literal = value .as_utf8_string_literal() .unwrap_or_else(|| bun_core::out_of_memory()); - let mut tag = dependency::Tag::infer(version_literal); + let mut tag = dependency::Tag::infer(dependency::trim_literal(version_literal)); let mut alias_at_index: Option = None; if strings::trim(version_literal, &strings::WHITESPACE_CHARS).starts_with(b"npm:") { @@ -781,7 +785,7 @@ pub(crate) fn edit_catalogs_after_update( }; new_literals[index] = Some(if info.is_alias { - let dep_literal = &info.original_version_literal; + let dep_literal = dependency::trim_literal(&info.original_version_literal); if let Some(at_index) = strings::last_index_of_char(dep_literal, b'@') { let mut v = Vec::new(); write!( @@ -917,8 +921,9 @@ pub(crate) fn edit( == Subcommand::Update && value.expr.as_utf8_string_literal().is_some_and( |version_literal| { - dependency::Tag::infer(version_literal) - == dependency::Tag::Catalog + dependency::Tag::infer(dependency::trim_literal( + version_literal, + )) == dependency::Tag::Catalog }, ); @@ -937,8 +942,9 @@ pub(crate) fn edit( else { break 'add_packages_to_update; }; - let mut tag = - dependency::Tag::infer(version_literal); + let mut tag = dependency::Tag::infer( + dependency::trim_literal(version_literal), + ); if tag != dependency::Tag::Npm && tag != dependency::Tag::DistTag @@ -1423,7 +1429,8 @@ pub(crate) fn edit( let e_string = unsafe { &mut *e_string }; // `bun update ` keeps a `catalog:` reference; `bun add` still replaces it. if manager.subcommand == Subcommand::Update - && dependency::Tag::infer(e_string.data.slice()) == dependency::Tag::Catalog + && dependency::Tag::infer(dependency::trim_literal(e_string.data.slice())) + == dependency::Tag::Catalog { continue; } @@ -1518,7 +1525,9 @@ pub(crate) fn edit( }; if entry.value.is_alias { - let dep_literal = &entry.value.original_version_literal; + let dep_literal = dependency::trim_literal( + &entry.value.original_version_literal, + ); if let Some(at_index) = strings::last_index_of_char(dep_literal, b'@') diff --git a/src/install/dependency.rs b/src/install/dependency.rs index 3a2661f63ab6..173825553bb1 100644 --- a/src/install/dependency.rs +++ b/src/install/dependency.rs @@ -233,7 +233,7 @@ impl DependencyExt for Dependency { Some(Semver::string::Builder::string_hash( new_name.slice(out_slice), )), - new_literal.slice(out_slice), + trim_literal(new_literal.slice(out_slice)), self.version.tag, &sliced, None, @@ -1164,6 +1164,14 @@ pub(crate) fn is_windows_abs_path_with_leading_slashes(dep: &[u8]) -> Option<&[u None } +/// The specifier inside a version literal: a package.json value may carry leading whitespace +/// (`" npm:foo@^1"`), which is not part of the specifier. Every path that classifies +/// (`Tag::infer`) or parses a literal must look at these bytes, never at the raw literal. +#[inline] +pub fn trim_literal(literal: &[u8]) -> &[u8] { + strings::trim_left(literal, b" \t\n\r") +} + #[inline] pub fn parse<'a, 'b>( alias: String, @@ -1173,7 +1181,7 @@ pub fn parse<'a, 'b>( log: impl Into>, manager: impl Into>, ) -> Option { - let dep = strings::trim_left(dependency, b" \t\n\r"); + let dep = trim_literal(dependency); parse_with_tag( alias, alias_hash.into(), @@ -1194,7 +1202,7 @@ pub(crate) fn parse_with_optional_tag<'a, 'b>( log: impl Into>, package_manager: impl Into>, ) -> Option { - let dep = strings::trim_left(dependency, b" \t\n\r"); + let dep = trim_literal(dependency); parse_with_tag( alias, alias_hash.into(), diff --git a/src/install/lockfile/Package.rs b/src/install/lockfile/Package.rs index 43b9edf56896..787795bc7048 100644 --- a/src/install/lockfile/Package.rs +++ b/src/install/lockfile/Package.rs @@ -1631,7 +1631,9 @@ impl Package { ) -> crate::Result> { #[cfg(windows)] let external_version = 'brk: { - match tag.unwrap_or_else(|| dependency::version::Tag::infer(version)) { + match tag.unwrap_or_else(|| { + dependency::version::Tag::infer(dependency::trim_literal(version)) + }) { dependency::version::Tag::Workspace | dependency::version::Tag::Folder | dependency::version::Tag::Symlink @@ -1683,9 +1685,10 @@ impl Package { semver::string::Builder::string_hash(npm_name.slice(buf)) } dependency::version::Tag::Workspace => { - if strings::has_prefix(sliced.slice, b"workspace:") { + let literal = dependency::trim_literal(sliced.slice); + if strings::has_prefix(literal, b"workspace:") { 'brk: { - let input = &sliced.slice[b"workspace:".len()..]; + let input = &literal[b"workspace:".len()..]; let trimmed = strings::trim(input, &strings::WHITESPACE_CHARS); if trimmed.len() != 1 || (trimmed[0] != b'*' && trimmed[0] != b'^' && trimmed[0] != b'~') @@ -2298,7 +2301,7 @@ impl Package { string_builder.count(value); // If it's a folder or workspace, pessimistically assume we will need a maximum path - match dependency::version::Tag::infer(value) { + match dependency::version::Tag::infer(dependency::trim_literal(value)) { dependency::version::Tag::Folder | dependency::version::Tag::Workspace => { string_builder.cap += MAX_PATH_BYTES; diff --git a/test/cli/install/bun-install-registry.test.ts b/test/cli/install/bun-install-registry.test.ts index b9eb36ab9011..e83bd7a75c12 100644 --- a/test/cli/install/bun-install-registry.test.ts +++ b/test/cli/install/bun-install-registry.test.ts @@ -5405,6 +5405,89 @@ describe("update", () => { }); }); }); + describe("leading whitespace in the version literal", () => { + // The installer ignores leading whitespace in a package.json version, so each of these must + // end up exactly where the same command takes the value without the whitespace. + const cases: [dependency: string, version: string, args: string[], updated: string, installed: string][] = [ + ["aliased-dep", " npm:no-deps@^1.0.0", ["aliased-dep"], "npm:no-deps@^1.1.0", "1.1.0"], + ["aliased-dep", " npm:no-deps@^1.0.0", [], "npm:no-deps@^1.1.0", "1.1.0"], + ["aliased-dep", " npm:no-deps@^1.0.0", ["--latest"], "npm:no-deps@^2.0.0", "2.0.0"], + ["aliased-dep", "\tnpm:no-deps@~1.0.0", [], "npm:no-deps@~1.0.1", "1.0.1"], + ["aliased-dep", "\tnpm:no-deps@~1.0.0", ["--latest"], "npm:no-deps@~2.0.0", "2.0.0"], + ["no-deps", " ^1.0.0", [], "^1.1.0", "1.1.0"], + ["no-deps", " ^1.0.0", ["no-deps"], "^1.1.0", "1.1.0"], + ["no-deps", " ^1.0.0", ["--latest"], "^2.0.0", "2.0.0"], + ["no-deps", "\n~1.0.0", [], "~1.0.1", "1.0.1"], + ]; + + for (const [dependency, version, args, updated, installed] of cases) { + test(`${JSON.stringify(version)} with \`bun update${args.map(arg => ` ${arg}`).join("")}\``, async () => { + await write( + packageJson, + JSON.stringify({ + name: "foo", + dependencies: { + [dependency]: version, + }, + }), + ); + + await runBunUpdate(env, packageDir, args); + assertManifestsPopulated(join(packageDir, ".bun-cache"), registryUrl()); + + expect(await file(packageJson).json()).toEqual({ + name: "foo", + dependencies: { + [dependency]: updated, + }, + }); + expect(await file(join(packageDir, "node_modules", dependency, "package.json")).json()).toMatchObject({ + name: "no-deps", + version: installed, + }); + }); + } + + test("a catalog reference in an earlier dependency group is left alone", async () => { + // devDependencies are visited before dependencies. The entry recorded for `dependencies` + // must not be spent rewriting the `catalog:` reference. + await write( + packageJson, + JSON.stringify({ + name: "foo", + workspaces: { + catalog: { + "no-deps": "^1.0.0", + }, + }, + devDependencies: { + "no-deps": " catalog:", + }, + dependencies: { + "no-deps": " ^1.0.0", + }, + }), + ); + + await runBunUpdate(env, packageDir); + assertManifestsPopulated(join(packageDir, ".bun-cache"), registryUrl()); + + expect(await file(packageJson).json()).toEqual({ + name: "foo", + workspaces: { + catalog: { + "no-deps": "^1.1.0", + }, + }, + devDependencies: { + "no-deps": " catalog:", + }, + dependencies: { + "no-deps": "^1.1.0", + }, + }); + }); + }); test("--no-save will update packages in node_modules and not save to package.json", async () => { await write( packageJson, diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index e35753d1be20..312f53681516 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -9883,6 +9883,50 @@ it("installs the transitive file: dependency of a file: dependency", async () => } }); +it("installs a file: dependency whose version literal has leading whitespace", async () => { + // The folder path is longer than an inline string so that it has to be appended to the + // lockfile's string buffer, which only has room for it if the literal was classified as a folder. + const literal = " file:./vendor/some-long-directory-name/lib"; + using dir = tempDir("whitespace-file-dep", { + "package.json": JSON.stringify({ + name: "my-app", + version: "1.0.0", + dependencies: { + lib: literal, + }, + }), + "vendor/some-long-directory-name/lib/package.json": JSON.stringify({ + name: "lib", + version: "1.0.0", + }), + }); + + // The first pass resolves from package.json; the second installs from the lockfile the first + // pass wrote, which must still carry the folder specifier. + for (const args of [["install"], ["install", "--frozen-lockfile"]]) { + await rm(join(String(dir), "node_modules"), { recursive: true, force: true }); + + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), ...args], + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + env, + }); + const [err, out, exitCode] = await Promise.all([stderr.text(), stdout.text(), exited]); + + expect(err).not.toContain("error:"); + expect(out).toContain("1 package installed"); + expect(exitCode).toBe(0); + + expect(await file(join(String(dir), "node_modules", "lib", "package.json")).json()).toEqual({ + name: "lib", + version: "1.0.0", + }); + expect(await file(join(String(dir), "bun.lock")).text()).toContain(`"lib": ${JSON.stringify(literal)}`); + } +}); + it("fails when a transitive file: dependency's folder does not exist", async () => { using dir = tempDir("transitive-file-dep-missing", { "package.json": JSON.stringify({ diff --git a/test/cli/install/bun-workspaces.test.ts b/test/cli/install/bun-workspaces.test.ts index f6d72798485a..a25cac787a99 100644 --- a/test/cli/install/bun-workspaces.test.ts +++ b/test/cli/install/bun-workspaces.test.ts @@ -510,6 +510,8 @@ describe("workspace aliases", async () => { "workspace:@org/b@*", // missing version after `@` "workspace:@org/b@", + // leading whitespace is not part of the specifier + " workspace:@org/b@*", ]; for (const version of shouldPass) { test.concurrent(`version range ${version} and workspace with no version`, async () => { @@ -550,7 +552,13 @@ describe("workspace aliases", async () => { expect(files).toMatchObject([{ name: "@org/a" }, { name: "@org/b" }, { name: "@org/b" }]); }); } - let shouldFail: string[] = ["workspace:@org/b@1.0.0", "workspace:@org/b@1", "workspace:@org/b"]; + let shouldFail: string[] = [ + "workspace:@org/b@1.0.0", + "workspace:@org/b@1", + "workspace:@org/b", + // leading whitespace is not part of the specifier + " workspace:@org/b@1.0.0", + ]; for (const version of shouldFail) { test.concurrent(`version range ${version} and workspace with no version (should fail)`, async () => { using ctx = await setupTest(); diff --git a/test/cli/install/catalogs.test.ts b/test/cli/install/catalogs.test.ts index 08cc2b4044db..b7e576938d9b 100644 --- a/test/cli/install/catalogs.test.ts +++ b/test/cli/install/catalogs.test.ts @@ -511,6 +511,94 @@ describe("update", () => { }); expect(exitCode).toBe(0); }); + + // Leading whitespace in a version literal is ignored by the installer, so `bun update` has to + // treat these entries exactly like their trimmed counterparts above. + for (const [flags, expected] of [ + [[], { "no-deps": "^1.1.0", "aliased-dep": "npm:no-deps@~1.0.1" }], + [["--latest"], { "no-deps": "^2.0.0", "aliased-dep": "npm:no-deps@~2.0.0" }], + ] as const) { + test(`update ${flags.join(" ") || "(no args)"} updates catalog entries with leading whitespace`, async () => { + const { packageDir } = await registry.createTestDir(); + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "catalog-update-whitespace", + workspaces: { + packages: ["packages/*"], + catalog: { + "no-deps": " ^1.0.0", + "aliased-dep": "\tnpm:no-deps@~1.0.0", + }, + }, + }), + ), + write( + join(packageDir, "packages", "pkg1", "package.json"), + JSON.stringify({ + name: "pkg1", + dependencies: { + "no-deps": "catalog:", + "aliased-dep": "catalog:", + }, + }), + ), + ]); + await runBunInstall(bunEnv, packageDir); + + const { err, exitCode } = await runUpdate(packageDir, ...flags); + expect(err).not.toContain("error:"); + + expect((await file(join(packageDir, "package.json")).json()).workspaces.catalog).toEqual(expected); + expect((await file(join(packageDir, "packages", "pkg1", "package.json")).json()).dependencies).toEqual({ + "no-deps": "catalog:", + "aliased-dep": "catalog:", + }); + expect(exitCode).toBe(0); + }); + } + + for (const flags of [[], ["--latest"]] as const) { + test(`update ${flags.length ? ` ${flags.join(" ")}` : ""} keeps a catalog reference with leading whitespace`, async () => { + const { packageDir } = await registry.createTestDir(); + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "catalog-reference-whitespace", + workspaces: { + packages: ["packages/*"], + catalog: { + "no-deps": "^1.0.0", + }, + }, + }), + ), + write( + join(packageDir, "packages", "pkg1", "package.json"), + JSON.stringify({ + name: "pkg1", + dependencies: { + "no-deps": " catalog:", + }, + }), + ), + ]); + await runBunInstall(bunEnv, packageDir); + + const { err, exitCode } = await runUpdate(join(packageDir, "packages", "pkg1"), "no-deps", ...flags); + expect(err).not.toContain("error:"); + + expect((await file(join(packageDir, "packages", "pkg1", "package.json")).json()).dependencies).toEqual({ + "no-deps": " catalog:", + }); + expect((await file(join(packageDir, "package.json")).json()).workspaces.catalog).toEqual({ + "no-deps": "^1.0.0", + }); + expect(exitCode).toBe(0); + }); + } }); describe("errors", () => { From 0222289965749f86c0727a38e06a6ab2914ade7e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:33:03 +0000 Subject: [PATCH 2/4] install: trim the literal when loading a dependency from bun.lockb too --- src/install/dependency.rs | 2 +- test/cli/install/bun-install.test.ts | 85 +++++++++++++++------------- 2 files changed, 47 insertions(+), 40 deletions(-) diff --git a/src/install/dependency.rs b/src/install/dependency.rs index 173825553bb1..a20305f8028b 100644 --- a/src/install/dependency.rs +++ b/src/install/dependency.rs @@ -693,7 +693,7 @@ impl VersionExt for Version { parse_with_tag( alias, Some(alias_hash), - sliced.slice, + trim_literal(sliced.slice), tag, &sliced, Some(ctx.log), diff --git a/test/cli/install/bun-install.test.ts b/test/cli/install/bun-install.test.ts index 312f53681516..189bb6a5d37b 100644 --- a/test/cli/install/bun-install.test.ts +++ b/test/cli/install/bun-install.test.ts @@ -9883,49 +9883,56 @@ it("installs the transitive file: dependency of a file: dependency", async () => } }); -it("installs a file: dependency whose version literal has leading whitespace", async () => { - // The folder path is longer than an inline string so that it has to be appended to the - // lockfile's string buffer, which only has room for it if the literal was classified as a folder. - const literal = " file:./vendor/some-long-directory-name/lib"; - using dir = tempDir("whitespace-file-dep", { - "package.json": JSON.stringify({ - name: "my-app", - version: "1.0.0", - dependencies: { - lib: literal, - }, - }), - "vendor/some-long-directory-name/lib/package.json": JSON.stringify({ - name: "lib", - version: "1.0.0", - }), - }); +for (const lockfile of ["bun.lock", "bun.lockb"]) { + it(`installs a file: dependency whose version literal has leading whitespace (${lockfile})`, async () => { + // The folder path is longer than an inline string so that it has to be appended to the + // lockfile's string buffer, which only has room for it if the literal was classified as a folder. + const literal = " file:./vendor/some-long-directory-name/lib"; + using dir = tempDir("whitespace-file-dep", { + "package.json": JSON.stringify({ + name: "my-app", + version: "1.0.0", + dependencies: { + lib: literal, + }, + }), + "vendor/some-long-directory-name/lib/package.json": JSON.stringify({ + name: "lib", + version: "1.0.0", + }), + "bunfig.toml": `install.saveTextLockfile = ${lockfile === "bun.lock"}`, + }); - // The first pass resolves from package.json; the second installs from the lockfile the first - // pass wrote, which must still carry the folder specifier. - for (const args of [["install"], ["install", "--frozen-lockfile"]]) { - await rm(join(String(dir), "node_modules"), { recursive: true, force: true }); + // The first pass resolves from package.json; the second installs from the lockfile the first + // pass wrote, which stores the literal as written and has to parse it as a folder again. + for (const args of [["install"], ["install", "--frozen-lockfile"]]) { + await rm(join(String(dir), "node_modules"), { recursive: true, force: true }); - const { stdout, stderr, exited } = spawn({ - cmd: [bunExe(), ...args], - cwd: String(dir), - stdout: "pipe", - stderr: "pipe", - env, - }); - const [err, out, exitCode] = await Promise.all([stderr.text(), stdout.text(), exited]); + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), ...args], + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + env, + }); + const [err, out, exitCode] = await Promise.all([stderr.text(), stdout.text(), exited]); - expect(err).not.toContain("error:"); - expect(out).toContain("1 package installed"); - expect(exitCode).toBe(0); + expect(err).not.toContain("error:"); + expect(out).toContain("1 package installed"); + expect(exitCode).toBe(0); - expect(await file(join(String(dir), "node_modules", "lib", "package.json")).json()).toEqual({ - name: "lib", - version: "1.0.0", - }); - expect(await file(join(String(dir), "bun.lock")).text()).toContain(`"lib": ${JSON.stringify(literal)}`); - } -}); + expect(await file(join(String(dir), "node_modules", "lib", "package.json")).json()).toEqual({ + name: "lib", + version: "1.0.0", + }); + if (lockfile === "bun.lock") { + expect(await file(join(String(dir), "bun.lock")).text()).toContain(`"lib": ${JSON.stringify(literal)}`); + } else { + expect(await exists(join(String(dir), "bun.lockb"))).toBe(true); + } + } + }); +} it("fails when a transitive file: dependency's folder does not exist", async () => { using dir = tempDir("transitive-file-dep-missing", { From 2869c292f7bc3b6552e30cdacc17b8a9ca94519e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:34:58 +0000 Subject: [PATCH 3/4] install: shorten the trim_literal doc comment --- src/install/dependency.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/install/dependency.rs b/src/install/dependency.rs index a20305f8028b..e2eabc840981 100644 --- a/src/install/dependency.rs +++ b/src/install/dependency.rs @@ -1164,9 +1164,7 @@ pub(crate) fn is_windows_abs_path_with_leading_slashes(dep: &[u8]) -> Option<&[u None } -/// The specifier inside a version literal: a package.json value may carry leading whitespace -/// (`" npm:foo@^1"`), which is not part of the specifier. Every path that classifies -/// (`Tag::infer`) or parses a literal must look at these bytes, never at the raw literal. +/// Literals may carry leading whitespace; classify and parse these bytes, not the raw literal. #[inline] pub fn trim_literal(literal: &[u8]) -> &[u8] { strings::trim_left(literal, b" \t\n\r") From a7d2c3803e0b17c12eeb66f846173010df301b35 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:17:16 +0000 Subject: [PATCH 4/4] install: trim the literal in bun update -i and bun pm pack too preserve_version_prefix (bun update --interactive) and edit_root_package_json (bun pm pack / publish) also read the raw package.json value, so a leading space dropped the npm: alias and the range prefix on write-back, and left a workspace:/catalog: protocol in the packed package.json. --- src/runtime/cli/pack_command.rs | 1 + src/runtime/cli/update_interactive_command.rs | 1 + test/cli/install/bun-install-registry.test.ts | 38 +++++++++++++++ test/cli/install/bun-pack.test.ts | 2 + test/cli/install/catalogs.test.ts | 47 ++++++++++++++++++- 5 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index 749980616d6b..fdb7b2401ddb 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -3315,6 +3315,7 @@ fn edit_root_package_json( else { continue; }; + let package_spec = bun_install::dependency::trim_literal(package_spec); if let Some(without_workspace_protocol) = strings::without_prefix_if_possible_comptime(package_spec, b"workspace:") { diff --git a/src/runtime/cli/update_interactive_command.rs b/src/runtime/cli/update_interactive_command.rs index 1421ac1c7923..14f6580fac2e 100644 --- a/src/runtime/cli/update_interactive_command.rs +++ b/src/runtime/cli/update_interactive_command.rs @@ -2395,6 +2395,7 @@ fn preserve_version_prefix( original_version: &[u8], new_version: &[u8], ) -> crate::Result> { + let original_version = dependency::trim_literal(original_version); if original_version.len() > 1 { let mut orig_version: &[u8] = original_version; let mut alias: Option<&[u8]> = None; diff --git a/test/cli/install/bun-install-registry.test.ts b/test/cli/install/bun-install-registry.test.ts index e83bd7a75c12..db74420a15cc 100644 --- a/test/cli/install/bun-install-registry.test.ts +++ b/test/cli/install/bun-install-registry.test.ts @@ -5448,6 +5448,44 @@ describe("update", () => { }); } + test("bun update --interactive keeps the alias and the range prefix", async () => { + await write( + packageJson, + JSON.stringify({ + name: "foo", + dependencies: { + "aliased-dep": " npm:no-deps@^1.0.0", + "no-deps": "\t~1.0.0", + }, + }), + ); + await runBunInstall(env, packageDir); + + // `a` selects every package, enter confirms. bunEnv lets the prompt read keys from a pipe. + await using proc = spawn({ + cmd: [bunExe(), "update", "--interactive", "--latest"], + cwd: packageDir, + env, + stdin: new Blob(["a\r"]), + stdout: "pipe", + stderr: "pipe", + }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ out, err, exitCode }).toMatchObject({ exitCode: 0 }); + + expect(await file(packageJson).json()).toEqual({ + name: "foo", + dependencies: { + "aliased-dep": "npm:no-deps@^2.0.0", + "no-deps": "~2.0.0", + }, + }); + expect(await file(join(packageDir, "node_modules", "aliased-dep", "package.json")).json()).toMatchObject({ + name: "no-deps", + version: "2.0.0", + }); + }); + test("a catalog reference in an earlier dependency group is left alone", async () => { // devDependencies are visited before dependencies. The entry recorded for `dependencies` // must not be spent rewriting the `catalog:` reference. diff --git a/test/cli/install/bun-pack.test.ts b/test/cli/install/bun-pack.test.ts index 8e349f865f35..b01dc50570fa 100644 --- a/test/cli/install/bun-pack.test.ts +++ b/test/cli/install/bun-pack.test.ts @@ -651,6 +651,8 @@ describe("workspaces", () => { { input: "workspace:1.1.x", expected: "1.1.x" }, { input: "workspace:*", expected: "1.1.1" }, { input: "workspace:-", expected: "-" }, + // leading whitespace is not part of the specifier + { input: " workspace:^", expected: "^1.1.1" }, ]; for (const { input, expected } of withLockfileWorkspaceProtocolTests) { diff --git a/test/cli/install/catalogs.test.ts b/test/cli/install/catalogs.test.ts index b7e576938d9b..16a1d28df5e3 100644 --- a/test/cli/install/catalogs.test.ts +++ b/test/cli/install/catalogs.test.ts @@ -1,7 +1,8 @@ import { file, spawn, write } from "bun"; +import { readTarball } from "bun:internal-for-testing"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { exists } from "fs/promises"; -import { VerdaccioRegistry, bunEnv, bunExe, runBunInstall } from "harness"; +import { VerdaccioRegistry, bunEnv, bunExe, pack, runBunInstall } from "harness"; import { join } from "path"; var registry = new VerdaccioRegistry(); @@ -601,6 +602,50 @@ describe("update", () => { } }); +describe("pack", () => { + test("replaces a catalog: reference with leading whitespace", async () => { + const { packageDir } = await registry.createTestDir(); + const pkg1Dir = join(packageDir, "packages", "pkg1"); + await Promise.all([ + write( + join(packageDir, "package.json"), + JSON.stringify({ + name: "catalog-pack-whitespace", + workspaces: { + packages: ["packages/*"], + catalog: { + "no-deps": "^1.0.0", + }, + }, + }), + ), + write( + join(pkg1Dir, "package.json"), + JSON.stringify({ + name: "pkg1", + version: "1.0.0", + dependencies: { + "no-deps": " catalog:", + }, + }), + ), + ]); + await runBunInstall(bunEnv, packageDir); + + await pack(pkg1Dir, bunEnv); + + const tarball = readTarball(join(pkg1Dir, "pkg1-1.0.0.tgz")); + expect(tarball.entries).toMatchObject([{ pathname: "package/package.json" }]); + expect(JSON.parse(tarball.entries[0].contents)).toEqual({ + name: "pkg1", + version: "1.0.0", + dependencies: { + "no-deps": "^1.0.0", + }, + }); + }); +}); + describe("errors", () => { test("fails gracefully when no catalog is found for a package", async () => { const { packageDir, packageJson } = await registry.createTestDir();