Skip to content

install: make bun update <name> update catalog entries instead of adding a root dependency - #32810

Closed
robobun wants to merge 2 commits into
mainfrom
farm/074dae34/catalog-update-by-name
Closed

robobun wants to merge 2 commits into
mainfrom
farm/074dae34/catalog-update-by-name

Conversation

@robobun

@robobun robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #32808

Problem

In a workspace monorepo, a package defined only in a catalog/catalogs map (and consumed by workspaces via catalog:<name>) could not be updated by name. bun update <name> ignored the catalog and appended a brand-new root dependencies.<name> at the latest version, bypassing both the catalog definition and its semver constraint.

// root package.json
{ "workspaces": { "packages": ["packages/*"], "catalogs": { "ai": { "baz": "~1.0.0" } } } }
// packages/server/package.json
{ "dependencies": { "baz": "catalog:ai" } }

bun update baz left catalogs.ai.baz untouched and added a spurious "dependencies": { "baz": "^<latest>" } to the root, so the workspace catalog:ai reference diverged from the new root dependency.

Cause

PackageJSONEditor::edit (the named-update path) only scans the four root dependency groups. A catalog-only package matches none of them, so the append-new-dependency tail synthesizes a root entry that resolves to latest. The non-interactive catalog machinery (#36304, #36379, #36360) only runs for bare bun update (update_requests.is_empty()).

Fix

Route named catalog-only targets through that existing machinery instead of synthesizing a root dependency:

  • edit classifies a named target found in no root dependency group but defined in a catalog/catalogs map (UpdateRequest.is_catalog), so the append block leaves it alone. Classification happens only on the before-install pass; a name matched in a root dependency group keeps its root-dependency behavior and the catalog entry is left alone.
  • edit_catalogs_before_update gains a names_filter: bare bun update still records every entry (None); a named update records only the targeted entries, and with --latest temporarily rewrites just those for resolution. A name defined in several catalog groups updates every group, matching bun update -i.
  • The post-install edit_catalogs_after_update call now runs on the named path too (hoisted out of the no-args branch), writing resolved versions back into the catalog definition with the original pin style preserved, npm: aliases kept, and unresolved (unconsumed) entries restored.

Resolution-side re-targeting needs no changes: #36360's UpdateRequest::contains_name already re-resolves every named-update target, including catalog: dependencies.

After the fix, bun update baz updates catalogs.ai.baz in place (in range without --latest, past it with --latest), adds no root dependency, and leaves catalog:ai references intact.

Background

A catalog is a version map in the workspace root package.json (catalog for the default group, catalogs.<group> for named ones, optionally nested under workspaces). Member workspaces reference entries with the catalog:/catalog:<group> protocol so one constraint is shared across the monorepo. The catalog definition is the single source of truth, which is why an update must edit it in place rather than materialize a root dependency.

Verification

Six tests added to the update section of test/cli/install/catalogs.test.ts, alongside the existing non-interactive catalog-update coverage:

  • named target in the default catalog: entry moves within range, no root dependency appears, untargeted entries untouched
  • named target in a named catalog group
  • --latest past the range, pin style preserved (^1.0.0 to ^2.0.0)
  • --latest with the name in several groups: every consumed group updates per its own pin style; an unconsumed entry is restored
  • name in both a root dependency group and a catalog: the root dependency is rewritten, the catalog entry untouched
  • npm: aliased catalog entry: alias preserved (npm:no-deps@^1.0.0 to npm:no-deps@^1.1.0)

All fail on main (the spurious root dependency appears) and pass with this change. Full catalogs.test.ts (24), bun-update.test.ts (38), and bun-add.test.ts (54) pass locally.

@coderabbitai

coderabbitai Bot commented Jun 27, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: dc70430e-681b-4317-b496-475d85205210

📥 Commits

Reviewing files that changed from the base of the PR and between 46222cf and 81092a9.

📒 Files selected for processing (3)
  • src/install/PackageManager/PackageJSONEditor.rs
  • src/install/lockfile.rs
  • test/cli/install/catalogs.test.ts

Walkthrough

Changes

The update flow identifies catalog-only packages, filters catalog edits to requested names, preserves catalog: references, and avoids adding spurious root dependencies. CLI tests cover catalog groups, aliases, precedence, version styles, and update modes.

Catalog-aware update flow

Layer / File(s) Summary
Update contract and catalog targeting
src/install/PackageManager/UpdateRequest.rs, src/install/PackageManager/PackageJSONEditor.rs
UpdateRequest tracks catalog-only targets. Catalog-defined requests are excluded from regular dependency synthesis.
Catalog edit and install flow
src/install/PackageManager/PackageJSONEditor.rs, src/install/PackageManager/updatePackageJSONAndInstall.rs, src/install/lockfile.rs
Catalog preprocessing filters entries by requested package names. Catalog references remain unchanged during lockfile preprocessing. Post-update catalog editing runs for catalog-only and dependency updates.
Catalog update validation
test/cli/install/catalogs.test.ts
Tests cover default and named catalogs, multiple groups, direct dependency precedence, pinning styles, unused entries, update modes, and npm: aliases.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main fix for named updates of catalog entries.
Description check ✅ Passed The description explains the problem, cause, fix, scope, and verification results with relevant test coverage.
Linked Issues check ✅ Passed The changes address issue #32808 by updating catalog-only targets, avoiding root dependencies, and preserving constraints and catalog references.
Out of Scope Changes check ✅ Passed The implementation and tests remain focused on named catalog updates and related dependency-handling behavior.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jun 27, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:38 PM PT - Aug 12th, 2026

❌ @autofix-ci[bot], your commit 81092a9 has 3 failures in Build #93549 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32810

That installs a local version of the PR into your bun-32810 executable, so you can run:

bun-32810 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Bun v1.2.20 overwrites "catalog:" version references in subpackages #21852 - PR fixes bun update <name> replacing catalog: references in sub-packages with literal version strings by detecting catalog entries and updating them in-place instead

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #21852

🤖 Generated with Claude Code

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there no existing catalog map? I feel like there is?

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. install: update catalog versions on bun update instead of overwriting catalog: references #32064 - Both fix bun update overwriting catalog: references instead of updating the catalog entries in package.json; PR install: update catalog versions on bun update instead of overwriting catalog: references #32064 is a broader take covering --latest, --dry-run, and no-arg bun update in addition to the named update path

🤖 Generated with Claude Code

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/install/PackageManager/PackageManagerEnqueue.rs`:
- Around line 2020-2022: The re-resolution predicate in PackageManagerEnqueue is
too broad because it only checks `updating_packages` by package name, so catalog
dependencies with the same name can be re-resolved across unrelated catalog
groups. Update the logic around `is_named_catalog_update` and the `is_root_dep`
bypass to also compare `dependency.version.catalog()` against the
`UpdateRequest.catalog_name` for the matching request, so only the intended
catalog group is treated as eligible for re-resolution.

In `@test/cli/install/bun-update.test.ts`:
- Around line 449-468: The subprocess test is reading piped output sequentially
instead of draining it concurrently, which can block the child process when
stderr/stdout fills up. Update the relevant spawn-based checks in
bun-update.test.ts to consume stdout, stderr, and exited together using
Promise.all, including the initial install step and the update step. Use the
existing spawn calls and the variables returned from bunExe(), package_dir, and
env to locate the affected test blocks and keep pipe draining concurrent as
required by the test guidelines.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 32b5da52-aca4-4363-afa5-d5ce1cb7980f

📥 Commits

Reviewing files that changed from the base of the PR and between df92f8f and 1807b5b.

📒 Files selected for processing (4)
  • src/install/PackageManager/PackageJSONEditor.rs
  • src/install/PackageManager/PackageManagerEnqueue.rs
  • src/install/PackageManager/UpdateRequest.rs
  • test/cli/install/bun-update.test.ts

Comment thread src/install/PackageManager/PackageManagerEnqueue.rs Outdated
Comment thread test/cli/install/bun-update.test.ts Outdated
@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Yeah, there are two existing pieces and I went through both:

  1. lockfile.catalogs (CatalogMap, src/install/lockfile/CatalogMap.rs) holds the parsed catalog constraints. The snag is timing: it's filled during install_with_manager (load_from_cwd for an existing bun.lock, or Package::parse_with_json for the root), which runs after the before_install edit where I need to decide whether a requested package is catalog-backed. At that first edit(before_install: true) it's still CatalogMap::default() (empty) in both the fresh and existing-lockfile cases, so detection reads the package.json AST instead, which is the authoritative, editable source there anyway. (For the resolved version I scan the dependency list, since the concrete version lives on the consuming dep's resolution, not in the constraint map.)

  2. The interactive updater (update_interactive_command.rs) already has AST catalog editors (update_named_catalog / update_default_catalog / find_catalog_object / preserve_version_prefix). Those live in bun_runtime, and bun_install (where PackageJSONEditor lives) can't depend on it without a crate cycle, so I couldn't call them directly, hence the small amount of duplication. The clean version is to hoist those helpers down into bun_install and have the interactive command reuse them. Happy to do that here or as a follow-up if you'd like one shared implementation.

Separately, heads up that #32064 overlaps this area (it covers the catalog:-overwrite / --latest / no-arg paths and also edits PackageJSONEditor, so they'll conflict). This PR is narrower: bun update <name> at the root for a package defined only in a catalog was synthesizing a root dependencies.<name> at latest (#32808). Glad to defer to #32064, rebase onto it, or fold this in, whichever you prefer.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/install/PackageManager/PackageJSONEditor.rs:900-920 — When a catalog-only package is updated with an explicit version (bun update baz@^1.0.0) or with --latest, the request is marked is_catalog and skipped by both the append-new-dep block and the e_string rewrite loop — so request.version and Do::UPDATE_TO_LATEST are never applied. The catalog literal stays at its old constraint during install, resolution happens within the original range, and commit_catalog_update writes back with the original prefix, silently dropping the user's explicit constraint. Consider either rewriting the catalog literal to the requested version / latest in the before_install pass (so resolution honors it), or at minimum emitting a warning that @<version> / --latest is not yet supported for catalog entries.

    Extended reasoning...

    What the bug is

    The new catalog handling in edit() correctly intercepts bun update <name> for catalog-only packages and re-resolves them within the existing constraint. But it does so unconditionally — it never inspects request.version or Do::UPDATE_TO_LATEST. So when the user supplies an explicit constraint (bun update baz@^1.0.0) or passes --latest, that information is silently discarded: the package is re-resolved only within the catalog's existing semver range, and the result is written back with the original prefix.

    Code path

    In UpdateRequest::parse_with_error, input baz@^1.0.0 hits the @-split branch: alias = Some("baz"), value = "^1.0.0", so request.is_aliased = true, request.name = "baz", and request.version carries ^1.0.0 with tag Npm. get_name() therefore returns "baz".

    In edit() (before_install pass), the dependency-group scan finds nothing (baz isn't in any root dep group), so request.e_string stays None. The new catalog block then runs:

    if let Some(catalog_name) = find_package_catalog(current_package_json, name) {
        if options.before_install {
            manager.updating_packages.get_or_put(name)?;
        }
        request.is_catalog = true;
        request.catalog_name = catalog_name;
    }
    if request.is_catalog {
        remaining -= 1;
    }

    request.version (^1.0.0) is never read. With is_catalog = true and e_string = None, the request is excluded from the remaining != 0 append block (if request.e_string.is_some() || request.is_catalog { continue; }) and from the post-loop for request in updates.iter_mut() { if let Some(e_string) = request.e_string { ... } } rewrite — which is the only place Do::UPDATE_TO_LATEST (→ b"latest") and request.version.literal are propagated for non-catalog deps.

    During install, PackageManagerEnqueue.rs resolves the workspace's catalog:ai dependency via lockfile.catalogs.get(...), which returns the catalog's existing ~0.0.3 constraint parsed from the unmodified package.json. is_named_catalog_update forces re-resolution, but find_best_version is called with ~0.0.3, not the user's ^1.0.0 or latest.

    After install, commit_catalog_update reads the original literal (~0.0.3), calls which_version_is_pinned on it (→ Minor), and writes back ~<resolved> — preserving the original ~ and discarding the user's ^.

    Why nothing prevents it

    For non-catalog deps, an explicit @<version> works because the request either matches an existing dep-group entry (capturing its e_string) or falls into the append block (allocating a fresh e_string); either way the final loop rewrites that slot. --latest works the same way via the 'uninitialized branch. Catalog requests deliberately bypass both paths to avoid synthesizing a root dep, but no equivalent rewrite of the catalog literal was added.

    Step-by-step example

    Setup: root package.json has catalogs: { ai: { baz: "~0.0.3" } }; packages/server depends on baz: "catalog:ai". Registry has 0.0.3, 0.0.5, 1.0.0 with latest = 1.0.0.

    1. User runs bun update baz --latest (or bun update baz@^1.0.0).
    2. parse_with_error → name="baz", is_aliased=true; for --latest, Do::UPDATE_TO_LATEST is set on options.
    3. edit() before_install: dep-group scan misses; find_package_catalog matches → is_catalog=true, remaining -= 1. Catalog literal in the AST is not touched.
    4. Install: should_update is true via is_named_catalog_update; resolver picks the best match for ~0.0.3 → 0.0.5.
    5. edit() after_install: commit_catalog_update reads original ~0.0.3, formats resolved 0.0.5, applies PinnedVersion::Minor → writes ~0.0.5.
    6. Result: catalogs.ai.baz = "~0.0.5". Expected: ^1.0.0 (or 1.0.0). No warning, no error.

    Contrast with a non-catalog dependencies: { baz: "~0.0.3" }: bun update baz --latest rewrites the slot to "latest" before install, resolves to 1.0.0, and writes back ^1.0.0.

    Impact

    Users who try to bump a catalog dependency past its current major/minor with --latest or @<ver> will see the command succeed but the catalog stay pinned within the old range. This is inconsistent with how bun update <name>@<ver> / --latest behaves for ordinary dependencies, and there's no feedback that the explicit constraint was ignored. It's strictly an improvement over the pre-PR behavior (which wrongly added a root dep), but it leaves a user-visible gap.

    Suggested fix

    In the before_install catalog branch, when request.version.tag is Npm/DistTag or Do::UPDATE_TO_LATEST is set, rewrite the catalog object's literal in place (to request.version.literal or b"latest") before install — mirroring what the e_string path does for root deps. Then commit_catalog_update can use which_version_is_pinned on the new literal (or just respect exact_versions / the user's prefix). If that's out of scope for this PR, a TODO plus a warning when an explicit version/--latest is supplied for a catalog package would at least avoid the silent surprise.

Comment thread src/install/PackageManager/PackageJSONEditor.rs Outdated
Comment thread test/cli/install/bun-update.test.ts Outdated
Comment thread test/cli/install/bun-update.test.ts Outdated
@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch on the --latest / explicit @version gap. Fixed in a90d557: for a catalog-backed request, the before_install pass now rewrites the catalog constraint (to latest for --latest, or to the requested literal for an explicit npm version) so resolution targets the request, and commit_catalog_update reformats it from the resolved version with prefix semantics preserved. A bare bun update <name> still leaves the constraint untouched and stays within range.

Added two tests: bun update baz --latest moving an exact pin (0.0.3 → ^0.0.5), and bun update baz@0.0.5 overriding the catalog's ~ constraint (~0.0.3 → 0.0.5). Both fail on the released binary (they hit the old spurious-root-dependency path) and pass with this change.

The multi-catalog-group note is answered in the inline thread (kept to first-match, scoped re-resolution, bun update -i for the fan-out case). The stale-lockfile coverage is the third test, which installs at 0.0.3 then re-resolves to 0.0.5 after the newer version is published.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/install/PackageManager/PackageJSONEditor.rs:904-924 — When a package exists only in a catalog and the user runs bun update <name>@<version>, the new catalog block matches it (by name only), sets is_catalog=true, and decrements remaining — but request.version is never consulted, so the explicit @<version> is silently dropped and the command behaves identically to bun update <name>. Pre-PR the explicit version at least landed (in the wrong place — a spurious root dep); now it's silently ignored. Consider guarding the catalog branch with request.version.tag == dependency::Tag::Uninitialized, and either writing request.version.literal into the catalog constraint before install or printing a note that explicit versions on catalog packages aren't yet supported (the broader #32064 may already cover this).

    Extended reasoning...

    What the gap is. This PR teaches bun update <name> to recognise catalog-only packages and update the catalog entry in place. But the new catalog block at PackageJSONEditor.rs:904-924 matches the request purely by package name via find_package_catalog and never inspects request.version. So when the user supplies an explicit version spec — bun update baz@2.0.0 — that spec is silently discarded: the request is marked is_catalog, the append-new-dependency block skips it (request.e_string.is_some() || request.is_catalog at line 1002), the e_string rewrite loop never runs (e_string is None), and commit_catalog_update only reads the lockfile resolution that was constrained by the unchanged catalog literal. The net effect is that bun update baz@2.0.0 on a catalog-only package behaves identically to bun update baz.

    The specific code path. For input baz@2.0.0, UpdateRequest::parse produces is_aliased=true, name=b"baz", version.tag=Npm with version.literal="2.0.0". get_name() returns b"baz" (because is_aliased). The dependency-group scan at the top of edit() finds baz in none of the four root groups (it's catalog-only), so request.e_string stays None and the inner request.version.tag == Npm rewrite arm — which would have written 2.0.0 into a root-dep slot — never fires. Then the new Subcommand::Update block runs: e_string.is_some() is false, is_catalog is false, find_package_catalog(pkg_json, b"baz") matches the catalog entry, so is_catalog=true, catalog_name is recorded, and remaining -= 1. request.version is never read.

    Why nothing downstream catches it. Install proceeds with the catalog literal still at its old value (e.g. ~0.0.3); is_named_catalog_update in PackageManagerEnqueue.rs allows re-resolution, but only within ~0.0.3, not to 2.0.0. After install, commit_catalog_update calls resolved_catalog_version (reads the lockfile resolution) and which_version_is_pinned(original_literal) (reads the old catalog literal) — request.version is not a parameter. So the user's 2.0.0 never reaches any sink. Compare with a root-dep package, where the same bun update foo@2.0.0 does honour the explicit version via the 'uninitialized arm of the e_string rewrite loop.

    Step-by-step proof. Take the PR's own test fixture (catalogs: { ai: { baz: "~0.0.3" } }, registry has 0.0.3 and 0.0.5) and run bun update baz@0.1.0 instead of bun update baz:

    1. UpdateRequest::parse("baz@0.1.0") → is_aliased=true, name="baz", version.tag=Npm, version.literal="0.1.0".
    2. Dep-group scan: baz not in dependencies/devDependencies/optionalDependencies/peerDependencies → e_string=None.
    3. Catalog block: find_package_catalog(pkg_json, "baz") → Some("ai"). is_catalog=true, remaining=0. request.version untouched.
    4. Append block: is_catalog → skip. e_string rewrite loop: e_string=None → skip.
    5. Install resolves catalog:ai (= ~0.0.3) → baz@0.0.5.
    6. commit_catalog_update(name="baz", catalog_name="ai") reads lockfile → 0.0.5, reads old literal ~0.0.3 → pinned Minor → writes ~0.0.5.
    7. Result: catalogs.ai.baz = "~0.0.5". The user's 0.1.0 appears nowhere; no warning is printed.

    Impact. Silently dropping explicit user input is surprising and diverges from how bun update <name>@<ver> behaves for root deps. That said, this is adjacent to the PR's stated scope (#32808 is about bare bun update <name>), pre-PR behaviour was also wrong (it appended a spurious root dependencies.baz="2.0.0"), and the author has already noted that #32064 covers more catalog-update cases. So this is a nit / follow-up, not a blocker.

    How to fix. Guard the catalog block so it only claims requests with no explicit spec — e.g. add && request.version.tag == dependency::Tag::Uninitialized to the find_package_catalog condition. Then either (a) when an explicit version is given, write request.version.literal into the catalog literal before install so resolution honours it (mirroring what the interactive updater's update_named_catalog/update_default_catalog do), or (b) at minimum print a note: that explicit versions on catalog packages aren't yet supported, so the drop isn't silent. Falling through to the old append-root behaviour would just reintroduce #32808 for this input, so that's not the right mitigation.

Comment thread src/install/PackageManager/PackageJSONEditor.rs Outdated
Comment thread src/install/PackageManager/PackageManagerEnqueue.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/install/PackageManager/PackageJSONEditor.rs:936-940 — Follow-up: bun update <name> on a catalog-only package now correctly rewrites the catalog entry but prints no per-package summary line — the get_or_put(name)? here inserts a default PackageUpdateInfo (empty original_version_literal, original_version: None), and neither record_updating_package_versions nor print_installed_workspace_section reaches a catalog dep that isn't a root dependency. So the user sees only the generic "N packages installed" line, whereas the equivalent root-dep update prints installed baz@0.0.5. Not blocking (strictly better than the pre-PR spurious line, and the first new test only asserts not.toContain("installed baz@")), but worth populating original_version_literal from the catalog literal so the summary pipeline can report the bump.

    Extended reasoning...

    What the gap is

    For a catalog-only package, edit() at line 938 calls manager.updating_packages.get_or_put(name)? and discards the entry handle, so the inserted PackageUpdateInfo stays at its default: original_version_literal is empty, is_alias is false, and original_version is None. Compare the root-dep scan just above (the 'add_packages_to_update block), which writes *entry.value_ptr = PackageUpdateInfo { original_version_literal: version_literal_owned, ... } with the dependency's existing literal. This default-valued entry then never gets enriched by the post-install summary pipeline, so no per-package output is produced.

    Why the summary pipeline can't recover it

    There are two paths that would normally emit a per-package line for a named bun update <pkg>, and a catalog-only dep falls through both:

    1. record_updating_package_versions (install_with_manager.rs:1449-1454) iterates only workspace_deps — the install workspace's root package's direct dependencies — and skips any whose version.tag != Npm && != DistTag. A catalog package consumed only by member workspaces is not a root dep at all (that is the whole reason is_named_catalog_update had to be added in this PR), and where it does appear its tag is Catalog. Both filters exclude it, so entry.original_version stays None and tree_printer.rs:221's if let Some(original_version) falls through — no ^ baz X -> Y line.

    2. print_installed_workspace_section (tree_printer.rs:422-443, non-verbose path) is called once for workspace_package_id = 0 (root) with Some(&mut id_map). It iterates resolutions_list[0], which for the test fixture contains only the workspace member server, not baz. should_print_package_install checks each root dep against update.matches(...); server's name-hash ≠ hash(baz), so nothing matches and id_map[0] stays INVALID_PACKAGE_ID. The later loop at tree_printer.rs:495-498 then hits if dependency_id == INVALID_PACKAGE_ID { continue; } — no installed baz@0.0.5 line.

    Net: the catalog entry is correctly rewritten from ~0.0.3 to ~0.0.5, but stdout shows only the generic N packages installed [Xms] summary, with nothing naming baz or its version.

    Step-by-step proof

    Using the first new test fixture:

    // root: { workspaces: ["packages/*"], catalogs: { ai: { baz: "~0.0.3" } } }
    // packages/server: { dependencies: { baz: "catalog:ai" } }

    Run bun update baz from the root:

    1. edit(before_install=true): root has no dependencies/devDependencies/etc., so the dep-group scan finds nothing and request.e_string stays None. The new catalog block calls find_package_catalog → Some(b"ai"), sets request.is_catalog = true, and calls manager.updating_packages.get_or_put(b"baz")?. The entry is inserted with PackageUpdateInfo::default() — original_version_literal = b"", original_version = None.
    2. Install runs. record_updating_package_versions iterates root's deps (just the server workspace dep, tag Workspace) → filtered at the != Npm && != DistTag check. updating_packages["baz"].original_version remains None.
    3. Tree printer (non-verbose): print_installed_workspace_section(0, Some(&mut id_map)) iterates root's resolutions → only server, which doesn't match the baz request → id_map[0] = INVALID_PACKAGE_ID.
    4. The installed <name>@<ver> loop skips index 0 because id_map[0] == INVALID_PACKAGE_ID. The ^ X -> Y upgrade line is gated on Some(original_version), which is None.
    5. Result: no line mentioning baz is printed. The catalog in package.json is correctly bumped to ~0.0.5.

    By contrast, the existing test in this same file (should update to latest version of dependency) shows that a root-dep bun update baz prints installed baz@0.0.5 with binaries: — so the catalog path is the only named-update flow with no per-package feedback.

    Test evidence

    The first new test captures stdout but only asserts the negative — expect(out).not.toContain("installed baz@") — confirming the old spurious line is gone without asserting any replacement. The other four catalog tests use stdout: "ignore" entirely. So the silent-success behavior is exercised but not pinned down either way.

    Impact and severity

    This is a UX/output gap, not a correctness bug — the catalog is updated correctly, the lockfile is re-resolved, and the workspace references stay intact. It is also strictly better than pre-PR, which printed a misleading installed baz@<latest> for a spurious root dependency it had just synthesized. So: nit, fine as a follow-up.

    Suggested fix

    When the catalog branch inserts into updating_packages, populate the entry from the catalog literal so the existing summary machinery has something to work with, mirroring the root-dep path:

    if let Some(catalog_name) = find_package_catalog(current_package_json, name) {
        if options.before_install {
            let entry = manager.updating_packages.get_or_put(name)?;
            if !entry.found_existing {
                if let Some(obj) = catalog_object_with_key(current_package_json, &catalog_name, name) {
                    if let Some(s) = obj.get(name).and_then(|e| e.data.e_string()) {
                        *entry.value_ptr = PackageUpdateInfo {
                            original_version_literal: Box::from(s.data.slice()),
                            ..Default::default()
                        };
                    }
                }
            }
        }
        ...
    }

    That alone won't make the line print (the tree printer still only walks root deps), but it brings the catalog path to parity with the root-dep path's bookkeeping and is the prerequisite for whichever summary fix lands — likely as part of the planned helper-hoisting that consolidates this with the interactive updater.

Comment thread src/install/PackageManager/PackageJSONEditor.rs Outdated
@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Re the two latest notes:

  • --latest / @<version> prefix divergence (🔴): reverted the pre-install constraint rewrite in 8d71abc rather than carry the stash-original-literal machinery needed to match the root-dep path exactly. The PR is now scoped to the bare bun update <name> case from bun update <package> ignores catalog and creates a spurious root dependency at latest #32808: the catalog entry is updated in place, within range, prefix preserved, with no spurious root dependency, for every form of the command. Full --latest / @<version> support for catalog entries is deferred to install: update catalog versions on bun update instead of overwriting catalog: references #32064.

  • No per-package summary line for catalog updates: acknowledged as a follow-up. As you noted, even with the bookkeeping populated, the tree printer only walks the install workspace's root deps, so a catalog dep consumed by a member workspace wouldn't print a line without further changes. That fits naturally with the helper-hoisting/consolidation follow-up and is strictly better than the pre-PR misleading installed <name>@<latest> line.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🔴 src/install/PackageManager/PackageJSONEditor.rs:945-962 — When a package appears in both a root dependency group and a catalog/catalogs map, this block hijacks the request on the second edit() pass and stops the root dep from being rewritten. The dep-group scan takes the replacing += 1 branch (which deliberately leaves e_string unset and remaining undecremented, relying on the if remaining != 0 block below to do the in-place rewrite), but the catalog block then sees e_string.is_none() and !is_catalog, claims the request, and decrements remaining to 0 — so dependencies.baz is left at its old literal while catalogs.*.baz is bumped (a regression vs. pre-PR, which rewrote the root dep). Also skip this block when request.package_id != INVALID_PACKAGE_ID (the same discriminant the replacing branch keys on).

    Extended reasoning...

    What the bug is

    edit() is called twice with the same updates slice — once with before_install=true (updatePackageJSONAndInstall.rs:310) and once with before_install=false (:569). Between the two passes, clean_with_logger resets update.e_string = None (lockfile.rs:918) and, because the package is a root workspace dependency, sets update.package_id to the resolved id (lockfile.rs:1254-1264). The new catalog block guards only on request.e_string.is_some(), which is insufficient: on the second pass, the dep-group scan above takes the replacing += 1 branch (because package_id != INVALID && list == dependency_list), and that branch intentionally leaves e_string unset and remaining undecremented — it defers the actual rewrite to the if remaining != 0 block. The catalog block then claims the request, sets is_catalog = true, and decrements remaining to 0, starving the in-place-replacement block.

    Step-by-step proof

    Given:

    // root package.json
    {
      "workspaces": ["packages/*"],
      "dependencies": { "baz": "^1.0.0" },        // root uses baz directly
      "catalogs": { "ai": { "baz": "^1.0.0" } } // workspaces use it via catalog:ai
    }

    Run bun update baz after baz@1.5.0 is published.

    Pass 1 (before_install=true, package_id == INVALID): the dep-group scan finds baz in dependencies and takes the else branch → sets e_string, remaining -= 1. The catalog block's e_string.is_some() guard skips it, so is_catalog stays false.

    Between passes: lockfile.rs:918 sets e_string = None; lockfile.rs:1264 sets package_id to the resolved id (baz matches a workspace-root dep). is_catalog is untouched (still false).

    Pass 2 (before_install=false): the dep-group scan finds baz in dependencies; package_id != INVALID && list == dependency_list → replacing += 1, does not set e_string, does not decrement remaining. Now the catalog block: e_string.is_some() → false, !is_catalog → true, find_package_catalog(..., b"baz") finds it in catalogs.ai → is_catalog = true, catalog_name = b"ai", remaining -= 1 → remaining == 0. The if remaining != 0 block — which pre-PR re-found the entry in new_dependencies, set e_string, and let the final write-back loop write the resolved version — is skipped. The final for request ... if let Some(e_string) loop does nothing for this request. commit_catalog_update then rewrites only catalogs.ai.baz.

    Why the existing guards don't catch it

    The catalog block uses e_string.is_some() as its "already handled by the dep-group scan" signal. That works for the else branch of the dep-group scan (which sets e_string), but not for the replacing branch, which by design leaves e_string = None until the if remaining != 0 block runs. The !request.is_catalog guard doesn't help either: is_catalog was never set in pass 1 (because pass 1 set e_string and skipped the catalog block), and nothing between the passes sets it.

    Impact

    Pre-PR, bun update baz on this configuration rewrote dependencies.baz to the resolved version (and didn't touch the catalog). Post-PR, dependencies.baz is silently left at its old literal while catalogs.ai.baz is bumped and the lockfile/node_modules are re-resolved (since baz is a root dep, should_update was true). The root package.json literal now diverges from both the lockfile and the catalog entry the same command just updated. This is an edge-case configuration (same package in both a root dep group and a catalog) but a legitimate one, and it's a behavioral regression introduced by this PR.

    Suggested fix

    Also skip catalog detection when request.package_id != INVALID_PACKAGE_ID — that's exactly the discriminant the replacing branch keys on, so it cleanly identifies "the dep-group scan already matched this request":

    for request in updates.iter_mut() {
        if request.e_string.is_some() || request.package_id != INVALID_PACKAGE_ID {
            continue;
        }
        ...
    }

    Alternatively, gate the whole find_package_catalog call on options.before_install and rely on the persisted is_catalog flag in pass 2 (the block already does if !request.is_catalog { find... }, so detection only ever needs to happen once).

Comment thread src/install/PackageManager/PackageJSONEditor.rs Outdated
@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Both addressed:

  • Package in both a root dep group and a catalog (🔴): real bug, fixed in 9cdaa09. edit() runs twice, and on the post-install pass the dependency-group scan takes the replacing branch (which leaves e_string unset), so the catalog block was reclassifying the request as a catalog update and skipping the root-dependency rewrite. Catalog detection is now gated on before_install and skips requests already matched to a resolved package (package_id), so the first-pass classification persists. A package in both places now updates the root dependency and leaves the catalog entry untouched (matching prior behavior). Added a regression test.

  • Unconsumed catalog entry + --latest writing "latest" to disk (🟡): moot now. That path went through rewrite_catalog_constraint, which was reverted in 8d71abc. No catalog constraint is rewritten before install anymore, so there's nothing left to strand.

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff is green on the relevant lanes. The one hard failure is unrelated to this change:

  • test/cli/install/bun-add.test.ts > "should add dependency without duplication (GitHub)" failed with GET https://codeload.github.com/mishoo/UglifyJS/legacy.tar.gz/refs/tags/v3.14.1 - 400 (GitHub tarball fetch, network/infra). That test exercises bun add of a GitHub dependency, which doesn't touch the catalog/update code here (catalog handling is gated on the Update subcommand), and it fails the same way on an unmodified build. 53/54 in that file passed.
  • The remaining reds are in the "flaky" annotation (retried): update_interactive_install, bun-install, bake/dev-and-prod, http/serve — real-registry/timing flakes, none in this diff.

test/cli/install/bun-update.test.ts (the tests for this PR) passes. I already used the one ci: retrigger re-roll earlier (315bd25), so I'm not pushing another. A re-run should clear the GitHub-network failure.

Comment thread src/install/PackageManager/PackageJSONEditor.rs
Jarred-Sumner pushed a commit that referenced this pull request Jul 29, 2026
…36304)

Fixes #23739
Fixes #21852

## Problem

In a workspace using a catalog, non-interactive `bun update` never
touched the `catalog`/`catalogs` entries in the root package.json:

```jsonc
// root package.json
{ "workspaces": { "packages": ["packages/*"], "catalog": { "lodash": "4.17.0" } } }
// packages/app/package.json
{ "dependencies": { "lodash": "catalog:" } }
```

```console
$ bun update -r --latest
# "no changes" — catalog.lodash still 4.17.0
```

Worse, running `bun update --latest` from inside a workspace package
rewrote the `catalog:` reference to `^<version>`, silently detaching it
from the catalog. Interactive mode (`bun update -r -i`) already handled
both correctly.

**Root cause:** `edit_update_no_args` only iterates the four dependency
groups of the current package.json; it never looks at
`catalog`/`catalogs`. When it does encounter a `catalog:` value (running
inside a workspace), it treats it like an npm dependency, registers it
in `updating_packages`, and writes the resolved range back over the
`catalog:` literal. Nothing ever edits the root catalog objects.

## Fix

In `PackageJSONEditor.rs`:

* `catalog:`-tagged dependencies are excluded from the value-rewrite
paths (`edit_update_no_args` before/after install, and the `bun update
<pkg>` path in `edit`).
* New `edit_catalogs_before_update` / `edit_catalogs_after_update` walk
the root `catalog`/`catalogs` objects (under `workspaces` or at the top
level, matching `CatalogMap::parse_append`). With `--latest` the entries
are set to a temporary `latest` in the cached root package.json so the
resolver fetches the newest version through the existing `catalog:`
resolution path; after install the resolved version is written back
preserving the `^`/`~`/exact pin. Without `--latest`, ranges move within
range and exact pins stay, matching direct-dependency behavior.

`updatePackageJSONAndInstall.rs` calls these around the install and
handles both run-from-root and run-from-inside-a-workspace (the root
package.json is a different file in the second case and is written
separately), honoring `--dry-run`/`--no-save`. Identity is `(catalog
name, dependency name)`, so the same package in multiple catalogs
updates independently; entries not referenced by any workspace stay
untouched.

`bun add <pkg>` still replaces a `catalog:` reference (adding is an
explicit request to pin a version in that package). `bun update <pkg>`
on a `catalog:` reference keeps the reference and re-resolves within the
catalog range; bumping the catalog entry from a named update is #32808 /
#32810 territory.

This PR adopts #32064 (thank you @CarlosZiegler) on current main with a
small test addition for the `-r` flag from #23739.

Also includes a harness fix: bind the verdaccio test registry to
`127.0.0.1` explicitly. A bare port makes verdaccio listen on whatever
`localhost` resolves to (`::1` on hosts that list it first) while the
install client connects to `127.0.0.1`, refusing every request.

## Tests

`test/cli/install/catalogs.test.ts` (new `describe("update")`, verdaccio
registry):

- `--latest` updates default + named catalogs, top-level and
`workspaces.*` locations, with and without `-r`
- run from inside a workspace package updates the root catalog and keeps
`catalog:` refs
- same package in default + named catalogs updates independently;
unreferenced entries unchanged
- plain `bun update` stays in range, does not move exact pins, keeps
refs
- `bun update <pkg> --latest` keeps the catalog reference
- `--dry-run` writes nothing

Fail-before with src/ reverted: 7/8 new tests fail (catalog unchanged,
`catalog:` overwritten with `^2.0.0`). With the fix: 14/14 pass
(including the four pre-existing catalog tests). `bun-update.test.ts`
6/6, `bun-add.test.ts` 54/54, `bun-install-registry.test.ts -t update`
16 pass + 1 todo.

<!-- robobun:evidence:begin -->

---

**[review]** gate passed · iteration 2 · 5 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 8 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/install/catalogs.test.ts
bun test v1.4.0 (75bcb99)

test/cli/install/catalogs.test.ts:
(pass) basic > both catalog and catalogs in top-level [587.35ms]
(pass) basic > both catalog and catalogs in workspaces [457.07ms]
(pass) basic > detect changes (bun.lockb) [803.98ms]
(pass) basic > detect changes (bun.lock) [881.35ms]
241 |       expect(err).not.toContain("error:");
242 | 
243 |       // catalog entries are updated, preserving the pinning style
244 |       const root = await file(join(packageDir, "package.json")).json();
245 |       const { catalog, catalogs } = isTopLevel ? root : root.workspaces;
246 |       expect(catalog).toEqual({ "no-deps": "^2.0.0" });
                            ^
error: expect(received).toEqual(expected)

  {
-   "no-deps": "^2.0.0",
+   "no-deps": "^1.0.0",
  }

- Expected  - 1
+ Received  + 1

      at <anonymous> (/workspace/bun/test/cli/install/catalogs.test.ts:246:23)
(fail) update > --latest updates catalog versions in top-level [584.67ms]
241 |       expect(err).not.toContain("error:");
24
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (3bfb037)

test/cli/install/catalogs.test.ts:
(pass) basic > both catalog and catalogs in top-level [70.22ms]
(pass) basic > both catalog and catalogs in workspaces [27.96ms]
(pass) basic > detect changes (bun.lockb) [45.77ms]
(pass) basic > detect changes (bun.lock) [43.93ms]
(pass) update > --latest updates catalog versions in top-level [50.49ms]
(pass) update > --latest updates catalog versions in workspaces [41.35ms]
(pass) update > --latest updates catalog versions with -r [43.70ms]
(pass) update > --frozen-lockfile passes after --latest updates catalogs [44.07ms]
(pass) update > --latest run from inside a workspace package updates the root catalog [38.69ms]
(pass) update > --latest updates the same package independently per catalog [34.68ms]
(pass) update > --latest --dry-run does not modify any package.json (from root) [34.78ms]
(pass) update > --latest --dry-run does not modify any package.json (from workspace) [58.05ms]
(pass) update > update without --latest stays in range and keeps catalog references [39.07ms]
(pass) update > update <pkg> --latest keeps the catalog reference [56.67ms]
(pass) errors > fails gracefully when no cat
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/cli/install/catalogs.test.ts
bun test v1.4.0 (75bcb99)

test/cli/install/catalogs.test.ts:
(pass) basic > both catalog and catalogs in top-level [433.37ms]
(pass) basic > both catalog and catalogs in workspaces [331.82ms]
(pass) basic > detect changes (bun.lockb) [537.51ms]
(pass) basic > detect changes (bun.lock) [552.37ms]
(pass) update > --latest updates catalog versions in top-level [445.45ms]
(pass) update > --latest updates catalog versions in workspaces [399.59ms]
(pass) update > --latest updates catalog versions with -r [404.73ms]
(pass) update > --frozen-lockfile passes after --latest updates catalogs [558.91ms]
(pass) update > --latest run from inside a workspace package updates the root catalog [386.68ms]
(pass) update > --latest updates the same package independently per catalog [411.74ms]
(pass) update > --latest --dry-run does not modify any package.json (from root) [379.27ms]
(pass) update > --latest --dry-run does not modify any package.json (from workspace) [396.85ms]
(pass) update > update without --latest stay
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 695ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/7] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[2/7] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[2/7] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/install/PackageManager.rs                      |  13 +
 src/install/PackageManager/PackageJSONEditor.rs    | 336 ++++++++++++++++++++-
 .../PackageManager/updatePackageJSONAndInstall.rs  | 128 ++++++++
 test/cli/install/catalogs.test.ts                  | 278 +++++++++++++++++
 test/harness.ts                                    |   6 +-
 5 files changed, 756 insertions(+), 5 deletions(-)
```

</details>

**gate history** · 6 passed · 0 rejected · iteration 2

<details><summary>evidence per changed file</summary>

```
file                                                      reads  edits  tests
src/install/PackageManager.rs                                 2      1      0
src/install/PackageManager/PackageJSONEditor.rs               9      8      0
…c/install/PackageManager/updatePackageJSONAndInstall.rs      8      7      0
test/cli/install/catalogs.test.ts                             3      5      0
test/harness.ts                                               2      1      0
```

</details>

<!-- robobun:evidence:end -->

---------

Co-authored-by: Carlos Ricardo Ziegler <38855507+CarlosZiegler@users.noreply.github.com>
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun rebase m. Update. Make it good

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

On it. Main has since landed the non-interactive catalog-update machinery (#36304, #36379, #36360), so I'm rebasing onto that: the named catalog-only path will reuse edit_catalogs_before_update/edit_catalogs_after_update with a name filter instead of the parallel helpers in this PR. That also makes --latest work with the original prefix preserved, and npm: aliased catalog entries update correctly. Squashing to one clean commit; will push shortly.

@robobun
robobun force-pushed the farm/074dae34/catalog-update-by-name branch from 9cdaa09 to b45c0b7 Compare August 12, 2026 19:55
Comment thread src/install/PackageManager/PackageJSONEditor.rs Outdated
Comment thread src/install/PackageManager/PackageJSONEditor.rs Outdated
Comment thread src/install/PackageManager/PackageJSONEditor.rs Outdated
Comment thread src/install/PackageManager/UpdateRequest.rs Outdated
Comment thread src/install/PackageManager/updatePackageJSONAndInstall.rs Outdated
@robobun
robobun force-pushed the farm/074dae34/catalog-update-by-name branch from b45c0b7 to 46222cf Compare August 12, 2026 20:01
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

Comment thread src/install/PackageManager/PackageJSONEditor.rs
Comment thread src/install/PackageManager/PackageJSONEditor.rs Outdated
Comment thread src/install/PackageManager/UpdateRequest.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/install/PackageManager/PackageJSONEditor.rs`:
- Around line 1110-1122: Update the Update handling around the mutable requests
loop so matching direct `catalog:` references are classified as catalog targets
even when request.e_string is already set. Preserve precedence for direct
non-catalog dependencies, and apply the same classification to root and
workspace packages using the existing catalog lookup symbols. Add regression
coverage for both package scopes verifying bun update changes the catalog
version.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7ddbdd7a-8d4c-4664-9a67-7e51746aef58

📥 Commits

Reviewing files that changed from the base of the PR and between 315136d and 46222cf.

📒 Files selected for processing (4)
  • src/install/PackageManager/PackageJSONEditor.rs
  • src/install/PackageManager/UpdateRequest.rs
  • src/install/PackageManager/updatePackageJSONAndInstall.rs
  • test/cli/install/catalogs.test.ts

Comment thread src/install/PackageManager/PackageJSONEditor.rs
…dding a root dependency

`bun update <name>` for a package defined in a `catalog`/`catalogs` map
did not update the catalog definition. A catalog-only target (consumed by
workspaces via `catalog:`) got a brand-new root `dependencies.<name>`
appended at the latest version, bypassing the catalog and its constraint;
a target referenced as `catalog:` in a dependency group kept the reference
but the definition never moved.

`PackageJSONEditor::edit` only scanned the four root dependency groups, so
a catalog-only package matched none of them and the append-new-dependency
tail synthesized a root entry that resolved to `latest`. The non-interactive
catalog machinery only ran for bare `bun update`.

Route named catalog targets through that machinery instead: `edit`
classifies both catalog-only targets (no root dependency appended) and kept
`catalog:` references (`UpdateRequest.is_catalog`), and
`edit_catalogs_before_update` gains a name filter so only the targeted
entries are recorded (and, with `--latest`, temporarily rewritten for
resolution). The post-install `edit_catalogs_after_update` call now also
runs on the named path, writing resolved versions back into the catalog
definition with the original pin style preserved and `npm:` aliases kept.
`preprocess_update_requests` no longer rewrites a `catalog:` dependency's
lockfile literal to a resolved range, which both corrupted the lockfile
reference and hid the dependency from the catalog write-back. A name
defined in several catalog groups updates every group, matching the
interactive updater; a name also present as a plain root dependency keeps
its root-dependency behavior and the catalog entry is left alone.

Fixes #32808
@robobun
robobun force-pushed the farm/074dae34/catalog-update-by-name branch from 46222cf to 2cc7499 Compare August 12, 2026 20:18
Comment thread src/install/PackageManager/PackageJSONEditor.rs
Comment thread src/install/PackageManager/PackageJSONEditor.rs
Comment thread src/install/PackageManager/PackageJSONEditor.rs
Comment thread src/install/lockfile.rs
Comment thread src/install/lockfile.rs
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

CI note: the last four builds (93540, 93541, 93548, 93549) each failed only in build lanes downloading vendored dependency tarballs from github.com (mimalloc, cares, lolhtml, WebKit; HTTP 503 / fetch failed after 5 retries), across FreeBSD, linux-musl, linux-android, and darwin agents. Nothing in the diff is implicated; no test lane has failed. Locally green on the rebased commit: catalogs.test.ts 26/26 (including the 9 named-update catalog tests), bun-update.test.ts 38/38, bun-add.test.ts 54/54. A rebuild once GitHub recovers should go green.

Comment thread src/install/PackageManager/PackageJSONEditor.rs
Comment thread src/install/PackageManager/PackageJSONEditor.rs Outdated
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Build 93549 finished: 174 passed, 3 failed, none related to this diff. The darwin aarch64 build lane hit the same GitHub tarball outage (mimalloc download). The two red test lanes are grpc-js/test-tonic.test.ts (debian x64) and install/migration/complex-workspace.test.ts (debian x64-asan); both are untouched by this change (third-party grpc; lockfile migration, which never runs the update path), appear in the flaky annotation on other lanes, and have been reported to main-break triage. catalogs.test.ts and bun-update.test.ts passed on every lane that ran them.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #38333 has landed on main since this was opened and removes the bug this PR targeted (#32808). Verified with the issue's repro on 7d276b9: bun update <name> no longer adds anything to package.json. From the root, a name that only workspaces depend on is an error that suggests bun update -r <name> or --filter, and those forms update the locked version in place while leaving the catalog entry and the catalog: references untouched.

Main also pins a different behavior from this PR for named updates of catalog packages: only a bare bun update rewrites catalog entries, a named update never does (see "a catalog reference keeps the member's literal and the root catalog entry" in test/cli/install/bun-update.test.ts). The tests here assert the opposite, so there is nothing to rebase. The remaining catalog follow-ups on top of main's model are #38763 (output row for moved catalog entries) and #38826 (docs).

@robobun robobun closed this Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bun update <package> ignores catalog and creates a spurious root dependency at latest

2 participants