diff --git a/docs/pm/cli/update.mdx b/docs/pm/cli/update.mdx index 169b8b4e8d6d..2be41a60eb1d 100644 --- a/docs/pm/cli/update.mdx +++ b/docs/pm/cli/update.mdx @@ -31,6 +31,7 @@ Updated packages appear in the install summary as `↑ name old → new`, with ` - `^1.1.0` → `^1.2.0`, `~1.1.0` → `~1.1.5`. Bun preserves the operator. With [`install.exact`](/runtime/bunfig#install-exact) or `--exact`, Bun writes an exact version instead. - Exact pins, dist-tags (`"latest"`, `"next"`), and other range forms (`*`, `1.x`, `>=1.0.0`) are left as written; only `bun.lock` moves. `--latest` rewrites them. - Bun never rewrites `catalog:` references; it updates the catalog entry in the root `package.json` instead. +- Bun leaves `workspace:`, git, GitHub, `file:` and tarball entries as written, even when you name them with `--latest` or a version. To replace one with a registry package, use `bun add`. - `--no-save` updates `node_modules` only, leaving `package.json` and `bun.lock` untouched. ### What is held back diff --git a/src/install/PackageManager/PackageJSONEditor.rs b/src/install/PackageManager/PackageJSONEditor.rs index 88773115f6a9..f1d47fdbfb5d 100644 --- a/src/install/PackageManager/PackageJSONEditor.rs +++ b/src/install/PackageManager/PackageJSONEditor.rs @@ -1336,9 +1336,9 @@ pub(crate) fn edit( // derived from a `StoreRef` to the same `E::EString` is live inside this loop body, // so this is the sole mutable borrow. let e_string = unsafe { &mut *e_string }; - // `bun update ` keeps a `catalog:` reference; `bun add` still replaces it. + // `bun update ` keeps an entry the registry does not resolve (`catalog:`, `workspace:`, git, folder, tarball) as written, whatever was requested for it; `bun add` still replaces it. if manager.subcommand == Subcommand::Update - && dependency::Tag::infer(e_string.data.slice()) == dependency::Tag::Catalog + && !dependency::Tag::infer(e_string.data.slice()).is_npm() { continue; } diff --git a/test/cli/install/bun-update-lockfile-sync.test.ts b/test/cli/install/bun-update-lockfile-sync.test.ts index d83f6eb72cf7..89a77e978b65 100644 --- a/test/cli/install/bun-update-lockfile-sync.test.ts +++ b/test/cli/install/bun-update-lockfile-sync.test.ts @@ -1,8 +1,9 @@ import { Archive, file, write } from "bun"; import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { appendFile, exists } from "fs/promises"; +import { appendFile, exists, mkdir } from "fs/promises"; import { VerdaccioRegistry, bunEnv, bunExe, normalizeBunSnapshot, runBunInstall } from "harness"; import { join } from "path"; +import { pathToFileURL } from "url"; // Registry: no-deps 1.0.0/1.0.1/1.1.0/2.0.0, @types/no-deps 1.0.0/2.0.0, a-dep 1.0.1..1.0.10, one-range-dep@1.0.0 -> no-deps ^1.0.0, dep-with-tags 1.0.0..3.0.1 (latest=3.0.0, pre-2=2.0.1). @@ -139,6 +140,63 @@ const MONOREPO = (pkg1: Json = {}, rootFields: Json = {}) => WORKSPACES(rootFiel const PKG1 = "packages/pkg1"; const PKG2 = "packages/pkg2"; +const gitEnv = { + ...bunEnv, + GIT_CONFIG_NOSYSTEM: "1", + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", +}; + +// The git+file:// literal of a repository publishing `name` 1.0.0. +async function gitRepo(dir: string, name: string) { + const repo = join(dir, "repos", name); + await mkdir(repo, { recursive: true }); + await write(join(repo, "package.json"), json({ name, version: "1.0.0" })); + for (const args of [ + ["init", "-q"], + ["add", "package.json"], + ["commit", "-q", "-m", "1.0.0", "--no-gpg-sign"], + ]) { + await using proc = Bun.spawn({ cmd: ["git", ...args], cwd: repo, env: gitEnv, stdout: "ignore", stderr: "pipe" }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(stderr).not.toContain("fatal:"); + expect(exitCode).toBe(0); + } + return `git+${pathToFileURL(repo)}`; +} + +// A root declaring `no-deps` from git although the registry has it (latest 2.0.0), one entry of every other kind the registry does not resolve, and a dist-tag that --latest does rewrite. +async function setupNonRegistryDeps(members: Record = {}) { + const dir = await setup( + { + ...WORKSPACES({}, { pkg1: {}, ...members }), + "folder-target/package.json": { name: "folder-dep", version: "1.0.0" }, + }, + { install: false }, + ); + const dependencies = { + "no-deps": await gitRepo(dir, "no-deps"), + "dep-with-tags": "pre-2", + "folder-dep": "file:./folder-target", + "tgz-dep": "file:./tgz-dep-1.0.0.tgz", + pkg1: "workspace:^", + }; + await Promise.all([ + writePkg(dir, wsRoot({ dependencies })), + Archive.write( + join(dir, "tgz-dep-1.0.0.tgz"), + { "package/package.json": json({ name: "tgz-dep", version: "1.0.0" }) }, + { compress: "gzip" }, + ), + ]); + await runBunInstall(envFor(dir), dir); + const gitResolution = (await resolutions(dir, "no-deps")).find(res => res.startsWith("no-deps@git+"))!; + expect(gitResolution).toBeDefined(); + return { dir, dependencies, gitResolution }; +} + describe.concurrent("bun update rewrites bun.lock together with package.json", () => { test("bun update", async () => { const dir = await setup({ @@ -307,6 +365,46 @@ describe.concurrent("bun update rewrites bun.lock together with package.json", ( await expectInSync(dir, ["", PKG1]); }); + // With --latest or an explicit spec, naming such an entry used to replace it with the registry package of the same name + // (`no-deps` here), or fail with a 404 when there is none; a plain `bun update pkg1` rewrote `workspace:^` to `workspace:*`. + test.each([ + [["no-deps", "dep-with-tags", "folder-dep", "tgz-dep", "pkg1", "--latest"], "^3.0.0"], + [["*", "--latest"], "^3.0.0"], + [["no-deps", "folder-dep", "tgz-dep", "pkg1"], "pre-2"], + [["no-deps@latest"], "pre-2"], + [["no-deps@^2.0.0"], "pre-2"], + ])( + "bun update %j keeps git, folder, tarball and workspace entries as written; dep-with-tags becomes %p", + async (args, depWithTags) => { + const { dir, dependencies, gitResolution } = await setupNonRegistryDeps(); + await run(dir, "update", ...args); + const expected = { ...dependencies, "dep-with-tags": depWithTags }; + expect((await pkg(dir)).dependencies).toStrictEqual(expected); + expect((await lock(dir)).workspaces[""].dependencies).toStrictEqual(expected); + expect(await resolutions(dir, "no-deps")).toStrictEqual([gitResolution]); + expect(await installed(dir, "no-deps")).toMatchObject({ version: "1.0.0" }); + await expectInSync(dir, ["", PKG1]); + }, + ); + + test("bun update -r --latest keeps the workspace declaring the name from git and moves the one declaring it from the registry", async () => { + const { dir, dependencies, gitResolution } = await setupNonRegistryDeps({ + pkg2: { dependencies: { "no-deps": "~1.0.0" } }, + }); + await run(dir, "update", "no-deps", "-r", "--latest"); + expect((await pkg(dir)).dependencies).toStrictEqual(dependencies); + expect((await pkg(dir, PKG2)).dependencies).toStrictEqual({ "no-deps": "~2.0.0" }); + const lockfile = await lock(dir); + expect(lockfile.workspaces[""].dependencies).toStrictEqual(dependencies); + expect(lockfile.workspaces[PKG2].dependencies).toStrictEqual({ "no-deps": "~2.0.0" }); + expect(await resolutions(dir, "no-deps")).toStrictEqual(["no-deps@2.0.0", gitResolution]); + expect(await installed(dir, "no-deps")).toMatchObject({ version: "1.0.0" }); + expect(await file(join(dir, PKG2, "node_modules", "no-deps", "package.json")).json()).toMatchObject({ + version: "2.0.0", + }); + await expectInSync(dir, ["", PKG1, PKG2]); + }); + test("bun update -r", async () => { const dir = await setup(MONOREPO({ dependencies: { "no-deps": "~1.0.0" } })); await run(dir, "update", "-r");