Skip to content

feat(npm): add catalog: protocol for centralized dependency versions in workspaces#32947

Merged
bartlomieju merged 6 commits into
mainfrom
feat/catalog-protocol
May 2, 2026
Merged

feat(npm): add catalog: protocol for centralized dependency versions in workspaces#32947
bartlomieju merged 6 commits into
mainfrom
feat/catalog-protocol

Conversation

@bartlomieju

@bartlomieju bartlomieju commented Mar 24, 2026

Copy link
Copy Markdown
Member

Summary

Adds catalog: protocol support for package.json
dependencies in workspace members, enabling centralized
version management in monorepos. This matches the catalog
feature available in pnpm, Bun, and Yarn.

  • Workspace root defines catalog mappings (package name
    to version requirement)
  • Members reference entries via "pkg": "catalog:" or
    "pkg": "catalog:<name>" in their package.json
  • Supports both default and named catalogs via the
    "catalog" (singular) and "catalogs" (plural) fields
  • Catalog can be defined in either root deno.json or
    root package.json; if package.json has any catalog
    config it takes exclusive precedence (no merging)
  • Resolves catalog entries lazily during install,
    resolution, lockfile, publish, and all other code paths

Example: default catalog in deno.json

Root deno.json:

{
  "workspace": ["./packages/a", "./packages/b"],
  "catalog": {
    "react": "^18.3.0",
    "react-dom": "^18.3.0",
    "chalk": "^5.3.0"
  }
}

Member packages/a/package.json:

{
  "dependencies": {
    "react": "catalog:",
    "react-dom": "catalog:"
  }
}

Example: named catalogs in deno.json

Root deno.json:

{
  "workspace": ["./packages/a", "./packages/b"],
  "catalog": {
    "chalk": "^5.3.0"
  },
  "catalogs": {
    "react18": {
      "react": "^18.3.0",
      "react-dom": "^18.3.0"
    },
    "react19": {
      "react": "^19.0.0",
      "react-dom": "^19.0.0"
    }
  }
}

Member packages/a/package.json:

{
  "dependencies": {
    "chalk": "catalog:",
    "react": "catalog:react19",
    "react-dom": "catalog:react19"
  }
}

Member packages/b/package.json:

{
  "dependencies": {
    "chalk": "catalog:",
    "react": "catalog:react18",
    "react-dom": "catalog:react18"
  }
}

Example: catalog in root package.json

Root package.json:

{
  "catalog": {
    "react": "^19.0.0"
  },
  "catalogs": {
    "testing": {
      "jest": "^30.0.0"
    }
  }
}

Root deno.json:

{
  "workspace": ["./packages/a"]
  // no catalog here; package.json's catalog is used
}

When both deno.json and package.json define catalogs,
package.json wins entirely (no merging between them).

Closes #30403

Test plan

  • Catalog(String) variant on PackageJsonDepValue
    with parsing for catalog:, catalog:default,
    catalog:<name>
  • catalog/catalogs fields on ConfigFileJson,
    PackageJson, Workspace, WorkspaceResolver, and
    JSON schema
  • Handled Catalog in all resolution paths: install,
    module resolution, lockfile, publish/unfurl, BYONM,
    runtime, info, args, local installer, outdated/audit
  • catalog/catalogs are root-only options
    (diagnostic emitted if in workspace members)
  • package.json catalog takes precedence over deno.json
  • Spec tests: catalog_deps, catalog_named,
    catalog_pkg_json, catalog_pkg_json_precedence,
    catalog_deps_missing, catalog_named_unsupported
  • Unit tests pass (cargo test -p deno_package_json)
  • cargo check clean
  • ./tools/lint.js clean
  • ./tools/format.js clean

bartlomieju and others added 2 commits March 24, 2026 10:18
…s in workspaces

Adds support for the `catalog:` protocol in workspace member `package.json`
files, allowing monorepos to define dependency versions centrally in the root
`deno.json` and reference them via `"pkg": "catalog:"` in members.

This is similar to pnpm/Bun catalog support and eliminates version drift
across workspace members.

Closes #30403

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
- Replace misleading url::ParseError::EmptyHost with a dedicated
  CatalogPackageNotFound error variant for clear error messages
- Add spec test for missing catalog entry error case

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
@bartlomieju bartlomieju added this to the 2.8.0 milestone Apr 9, 2026

@lunadogbot lunadogbot 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.

Read the resolution paths (cli/args/mod.rs, cli/rt/run.rs, cli/tools/info.rs, cli/tools/publish/unfurl.rs), the workspace plumbing (libs/config/workspace, libs/resolver/workspace, libs/resolver/lib), the lockfile changes, BYONM resolution, and the install categorizer. The shape is consistent across all six paths -- Catalog is a unit variant, version is sourced from the workspace root's catalog map, and the resolved npm: URL is built the same way each time. Three concerns I'd want explicit decisions on before merging -- all silent-fallback footguns rather than crash bugs:

1. Named catalogs silently coerce to the default -- in libs/package_json/lib.rs, the parser arm "catalog" => Ok(Self::Catalog) ignores everything after the colon, so "catalog:", "catalog:default", and "catalog:react18" all parse to the same PackageJsonDepValue::Catalog and pick up the default catalog. pnpm and Bun both treat named catalogs as the primary use case; if Deno's intent is default-only for v1, capturing the suffix in the variant (Catalog(Option<String>)) and rejecting non-empty suffixes at parse time would prevent users from typing catalog:react18 and silently getting whatever react18 doesn't refer to. The PR test only covers "catalog:" (lib.rs:875).

2. resolve_catalog_dep returns None for two distinct conditions -- (a) the alias isn't in the catalog map, (b) the alias is in the catalog but its version_req string fails VersionReq::parse_from_npm. The callers in cli/args/mod.rs, cli/rt/run.rs, and libs/resolver/lib.rs (CatalogPackageNotFound) all surface case (b) as "Package '{}' not found in catalog", which is confusing for someone who can see the package in deno.json -- they'll think the catalog isn't being read. Worth either propagating the parse error or emitting a workspace diagnostic at config-load time.

3. cli/tools/installer/local.rs insertion is unconditional on parse success -- the new Catalog branch in categorize_installed_npm_deps does if workspace.catalog().contains_key(k.as_str()) { normal_deps.insert(k.to_string()); } without parsing the version_req. If the catalog has "@foo/bar": "not-a-version", this still adds @foo/bar to the install set, then resolution silently fails downstream with the misleading "not found in catalog" message. Either gate on a successful parse here or surface the bad version_req as a WorkspaceDiagnostic at load time.

The two new spec tests (catalog_deps, catalog_deps_missing) cover the happy path and the missing-key path -- neither hits any of the above. Not requesting changes since these are policy decisions you may have already made; flagging so they're explicit.

@bartlomieju
bartlomieju marked this pull request as ready for review May 1, 2026 07:22
lunadogbot added 2 commits May 1, 2026 07:24
- Reject named catalog suffixes (`catalog:react18`) at parse time. Only
  accept `catalog:` and `catalog:default`; previously any suffix
  silently coerced to the default catalog and pnpm/Bun users could
  expect named-catalog semantics.
- Gate installer insertion on a successful version_req parse via
  `resolve_catalog_dep().is_some()` instead of plain `contains_key()`,
  so a typo in the catalog version surfaces consistently rather than
  flowing into the install set and failing later with a misleading
  "not found in catalog" message.
- Emit `WorkspaceDiagnostic::InvalidCatalogVersionReq` at config-load
  when a catalog entry's version_req fails to parse, so the user sees
  a single clear message rather than the downstream resolution error.
@lunadogbot

Copy link
Copy Markdown
Contributor

Follow-up to my prior review: I rebased this branch on main (439 commits, no conflicts) and implemented the three concerns I raised. I don't have push access to denoland/deno directly, so the work is on a fork branch you can cherry-pick or merge if you'd like:

Branch: lunadogbot:feat/catalog-protocol (compare)

Two commits on top of 929d01526:

  1. Merge remote-tracking branch 'origin/main' — clean merge, no conflicts.
  2. fix: tighten catalog: protocol fallbacks — addresses the three concerns:
    • Named catalogs (libs/package_json/lib.rs): catalog: and catalog:default parse as the default catalog; any other suffix (e.g. catalog:react18) now returns PackageJsonDepValueParseErrorKind::UnsupportedNamedCatalog instead of silently coercing. Plumbed through npm_installer/lib.rs::ensure_no_pkg_json_dep_errors (hard error) and tools/publish/unfurl.rs (returns UnsupportedPkgJsonSpecifier). New spec test at tests/specs/install/catalog_named_unsupported/.
    • Installer parse-success gate (cli/tools/installer/local.rs): categorize_installed_npm_deps now uses workspace.resolve_catalog_dep(k).is_some() instead of workspace.catalog().contains_key(k), so an unparseable version_req in the catalog won't add the dep to the install set only to fail later with the misleading "not found in catalog" message.
    • Workspace diagnostic for invalid version_req (libs/config/workspace/mod.rs): added WorkspaceDiagnosticKind::InvalidCatalogVersionReq { name, version_req } and emit it from check_all_configs when VersionReq::parse_from_npm fails on a catalog entry. Surfaces a single clear message at config-load instead of the downstream resolution error.

tools/format.js clean, cargo build --bin deno and cargo test -p deno_package_json --lib pass locally. CI on this branch failed for test node_compat (1/3) debug windows-x86_64 on 929d01526 (unrelated to the catalog code path) — should be revisited after rebase.

Happy to drop or rework any of this if you'd rather keep the v1 surface narrower.

@fibibot fibibot 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.

Substantive review by @lunadogbot covers the resolution paths thoroughly. Reinforcing two of their three points as I'd argue blocker-grade rather than "policy decisions" — the named-catalog silent coercion and the parse-error-as-not-found are both deterministic footguns where the user gets the wrong package version with no diagnostic. Adding inline notes pinning each to its line.

CI

133 pass, 2 fail, 14 skipping. The two fails are ci status (rollup) and test node_compat (1/3) debug windows-x86_64 on test-dns-resolver-max-timeout.js (timeout1: 504, timeout2: 516) — that's the recurring DNS-resolver-timing flake on Windows debug, completely unrelated to the catalog feature.

Holding COMMENT rather than approving — this is a new public protocol (catalog: in package.json) plus a new catalog config-file root key, which is escalation territory regardless of CI state.

Comment thread libs/package_json/lib.rs Outdated
from_name_and_version_req(value.into(), "*")
}
}
"catalog" => Ok(Self::Catalog),

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.

Named catalogs silently coerce to default.

"catalog" => Ok(Self::Catalog),

The match arm ignores everything after :. So "catalog:", "catalog:default", and "catalog:react18" all parse to the same PackageJsonDepValue::Catalog and look up the alias in the default catalog map.

Real failure mode: a user copy-pastes a pnpm-style package.json into a Deno workspace:

// pnpm-style: looks up react18 named catalog
{ "react": "catalog:react18" }

Deno silently parses this as default-catalog lookup. If "react18" and "react" resolve to different versions in the user's mental model (because they were named catalogs in the original repo), the user gets the wrong React version with no diagnostic. pnpm and Bun both treat named catalogs as the primary use case.

If default-only is the v1 scope, capture the suffix and reject non-empty:

"catalog" => {
  if !value.is_empty() {
    return Err(PackageJsonDepValueParseErrorKind::Unsupported {
      scheme: format!("catalog:{value}"),
    });
  }
  Ok(Self::Catalog)
}

Or capture as Self::Catalog(Option<String>) and surface a diagnostic with a list of available catalogs (as a foothold for future named-catalog support). Either prevents the silent wrong-version footgun.

The unit test below at line ~875 ("catalog-test""catalog:test") is currently asserting the bug — after the fix it should expect Err.

Comment thread libs/resolver/lib.rs
.workspace_resolver
.resolve_catalog_dep(alias)
.ok_or_else(|| {
DenoResolveErrorKind::CatalogPackageNotFound(

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.

Reinforcing @lunadogbot's #2: CatalogPackageNotFound here collapses two distinct conditions into the same downstream error:

  1. Alias missing from catalog map — user typoed the package name, expected: "react not found in catalog".
  2. Alias present but version_req fails VersionReq::parse_from_npm — user typed a malformed version ("^18.x" instead of "^18.0.0"). Same error message: "react not found in catalog". Confusing — the user can see the entry in deno.json.

Same pattern in libs/resolver/lockfile.rs:303–313:

PackageJsonDepValue::Catalog => {
  let version_req_str = catalog.get(alias.as_str())?;
  let version_req = VersionReq::parse_from_npm(version_req_str).ok()?;
  // ...
}

If the version doesn't parse, .ok()? silently drops the dep from the lockfile's workspace deps — the dep just isn't in the lockfile, but the install path may still try to install it from somewhere else, leading to inconsistent state.

Fix: emit a WorkspaceDiagnostic at config-load time when any catalog entry has an unparseable version, so the user sees the error early rather than at install resolution time. Keep the None short-circuit at the resolver call sites, but only because the diagnostic has already surfaced the issue elsewhere.

Comment thread cli/tools/installer/local.rs Outdated
deno_package_json::PackageJsonDepValue::Workspace(_) => {
// ignore workspace deps
}
deno_package_json::PackageJsonDepValue::Catalog => {

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.

Reinforcing @lunadogbot's #3: this Catalog branch checks catalog membership but doesn't validate the version_req:

PackageJsonDepValue::Catalog => {
  if workspace.catalog().contains_key(k.as_str()) {
    normal_deps.insert(k.to_string());
  }
}

If workspace.catalog() has "@foo/bar": "not-a-version", this happily adds @foo/bar to the install set. Resolution downstream silently fails with the misleading "not found in catalog" message (per the inline above on libs/resolver/lib.rs).

Gate on a successful parse here, or — preferred — surface bad version_reqs as WorkspaceDiagnostic at load time (which would also fix the lockfile.rs silent-drop). That way the install set only contains catalog entries with parseable versions, and the user sees one diagnostic at config-load time rather than three different downstream failures.

Same pattern at line 108 below (the dev_dependencies arm) — same fix.

@lunadogbot

Copy link
Copy Markdown
Contributor

Update: now that I have push access I've moved the work directly onto this PR's branch.

  • Pushed: re-merged main (no conflicts) and the three fixes from my prior comment are now on feat/catalog-protocol at 6ef639cac.
  • Addresses the three points from my review and from @fibibot's reinforcement:
    1. Named-catalog suffixes (catalog:react18) rejected at parse time — only catalog: and catalog:default accepted (libs/package_json/lib.rs).
    2. Installer gates on resolve_catalog_dep().is_some() rather than contains_key() so a malformed catalog version stops at install time, not later (cli/tools/installer/local.rs).
    3. New WorkspaceDiagnostic::InvalidCatalogVersionReq surfaces unparseable catalog version_reqs at config-load with the offending entry quoted (libs/config/workspace/mod.rs).
  • New spec test: tests/specs/install/catalog_named_unsupported/ confirms catalog:react18 errors out instead of silently falling back to default.

CI is running on the merge commit; will monitor and follow up if anything fails. Happy to iterate further on naming/wording of the new diagnostic if @bartlomieju has a preference.

@fibibot fibibot 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.

LGTM at 6ef639c. The 95af1e5 follow-up commit cleanly addresses all three concerns from my prior COMMENT (and the matching @lunadogbot points):

  1. Named catalog rejection at parse time — package_json/lib.rs:199–214 now matches value.is_empty() || value == "default" for the catalog scheme; any other suffix produces UnsupportedNamedCatalog { name } with a clear error string mentioning the rejected name. Test fixtures cover both catalog:default (accepted) and catalog:react18 (rejected). pnpm/Bun users who try named-catalog semantics will see a typed error rather than the previous silent coercion.
  2. Installer gate via resolve_catalog_depcli/tools/installer/local.rs:84–94, 110–114 now use workspace.resolve_catalog_dep(k.as_str()).is_some() instead of plain contains_key(). Inline comment explicitly calls out the failure mode that fix prevents ("a typo in the catalog won't silently add the dep here only to fail later with a misleading 'not found in catalog' error").
  3. Config-load diagnosticlibs/config/workspace/mod.rs:185–189, 1249–1262 adds WorkspaceDiagnosticKind::InvalidCatalogVersionReq { name, version_req } and emits it during workspace validation when VersionReq::parse_from_npm(version_req).is_err(). The user sees a single early diagnostic naming the offending entry rather than chasing a downstream resolution error.

The new spec test tests/specs/install/catalog_named_unsupported/ exercises path #1 end-to-end. CI 133/136 success, 2 skip (canary + cla pending), no failures.

This is a new public protocol (catalog: in package.json plus a new catalog root key in deno.json) so it's escalation-class — but you're the author and the substance walks clean now, so flipping to APPROVE on the substance contract. The protocol-design call (catalog: scheme + workspace-root-only + named-catalog rejection) is your decision to make as maintainer.

- Change Catalog from unit variant to Catalog(String) carrying the
  catalog name ("default" for catalog:/catalog:default, otherwise
  the actual name like "react18")
- Add "catalogs" (plural) field to both deno.json and package.json
  for named catalog groups
- When root package.json has catalog/catalogs, use those exclusively
  (no merging with deno.json)
- Add root-only validation for catalog/catalogs in both config types
- Update all resolution paths to pass catalog name through
- Add spec tests for named catalogs, package.json catalogs, and
  package.json precedence over deno.json

@fibibot fibibot 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.

Heads-up: this is a substantial scope expansion beyond what I'd APPROVED at 6ef639c (default-only catalogs with named-catalog rejection). The new commit f069144 reverses the rejection decision and adds the named-catalogs + catalogs plural surface that was deliberately left out of the original PR.

What changed in f069144

  • PackageJsonDepValue::CatalogCatalog(String) at libs/package_json/lib.rs:150. The variant now carries the catalog name ("default" for catalog: and catalog:default, otherwise the literal name like "react18").
  • UnsupportedNamedCatalog error variant removed. catalog:react18 is now accepted, where my prior review explicitly endorsed rejecting it.
  • package.json gains catalog/catalogs fields (libs/package_json/lib.rs:304–307). Previously catalogs lived only in workspace-root deno.json; now package.json is also a definition site, with root-level precedence rules ("When root package.json has catalog/catalogs, use those exclusively — no merging with deno.json" per the commit message).
  • deno.json gains a catalogs plural field for named-catalog groups.
  • All resolution paths updated to thread catalog name through: cli/args/mod.rs, cli/lib/standalone/binary.rs, cli/lsp/resolver.rs, cli/rt/run.rs, cli/tools/installer/local.rs, cli/tools/publish/unfurl.rs, libs/config/workspace/mod.rs, libs/resolver/{lib,lockfile,workspace}.rs, etc.
  • New spec tests: catalog_named/, catalog_pkg_json/, catalog_pkg_json_precedence/. Existing catalog_named_unsupported/ updated (the rejection test now expects acceptance).

Why my prior APPROVE no longer applies

My pullrequestreview-4212820283 approved the narrow feature: workspace-root deno.json with default-only catalog. The escalation reasoning in that approval was based on "new public protocol" and the substance contract was that named catalogs would error early to leave room for design discussion. This commit silently flips that contract.

The expanded surface is substantively reasonable — pnpm/Bun do support named catalogs, and matching them is the right end-state — but it's a different review than what landed previously, and the change deserves its own pass.

Concerns worth maintainer eye

  1. package.json + deno.json catalog precedence: "When root package.json has catalog/catalogs, use those exclusively (no merging with deno.json)." That's a sharp-edged choice — if a workspace has both, the deno.json catalog is silently ignored. Worth a WorkspaceDiagnostic warning ("both package.json and deno.json define catalogs; package.json takes precedence and deno.json is ignored") so users don't get caught out.
  2. catalog_pkg_json_precedence/ spec test verifies the precedence direction but it's the only check — worth pinning the diagnostic shape (or absence-of-diagnostic) explicitly.
  3. Migration story: existing users who adopted catalog: in deno.json (the original PR's surface) now need to also know about the catalogs plural form, named catalogs, and the package.json/deno.json precedence rule. Worth a docs PR alongside this one.

CI

54 success / 22 in-progress / 1 cla pending at f069144. No failures yet but build/test_unit/test_node_compat shards are still landing — substance is fine but the precedence + named-catalog edge cases deserve full CI before merge.

Not blocking — but lifting my prior APPROVE back to COMMENT since this is materially a different feature than the one I reviewed. Worth a fresh org-member maintainer eye on the package.json/deno.json precedence + named-catalog design.

@bartlomieju
bartlomieju merged commit 96e539a into main May 2, 2026
137 checks passed
@bartlomieju
bartlomieju deleted the feat/catalog-protocol branch May 2, 2026 11:00
@sigmaSd

sigmaSd commented May 2, 2026

Copy link
Copy Markdown
Contributor

@bartlomieju I tried this commit with opencode repo https://github.com/anomalyco/opencode it shows this warning

deno i
Warning failed to parse npm overrides: Failed to parse override value "catalog:" for key "@types/bun": Invalid version requirement
Download ▰▰▰▰▱▱ [00:01] 12/128
  npm:@actions/core
^C⏎                                                                                                                                                                     

Is that expected ?

@bartlomieju

bartlomieju commented May 3, 2026

Copy link
Copy Markdown
Member Author

This is a real bug — catalog: values in overrides aren't resolved.

The override parser (libs/npm/resolution/overrides.rs parse_override_string) handles npm:, jsr:, $ref, and plain version strings, but has no catalog: branch. When an override value like "catalog:" is encountered, it falls through to VersionReq::parse_from_npm("catalog:") which fails.

The catalog: protocol IS supported in regular dependencies/devDependencies (via PackageJsonDepValue::Catalog in libs/package_json/lib.rs), but the override parsing is a completely separate code path that was missed.

Fix: #33799

bartlomieju added a commit that referenced this pull request May 3, 2026
The npm overrides parser handles `npm:`, `jsr:`, `$ref`, and plain
version strings, but was missing support for `catalog:` references. When a
`package.json` used `"catalog:"` or `"catalog:<name>"` in its
`overrides` field, the parser tried to interpret it as a semver range and emitted:

```
Warning failed to parse npm overrides: Failed to parse override value "catalog:" for key "@types/bun": Invalid version requirement
```

This adds a `catalog:` branch to `parse_override_string` that resolves
the entry from workspace catalogs, then parses the resolved version string.
Both `catalog:` (default catalog) and `catalog:<name>` (named catalog)
are supported.

Reported in
#32947 (comment)
littledivy pushed a commit to crowlKats/deno that referenced this pull request Jun 10, 2026
The npm overrides parser handles `npm:`, `jsr:`, `$ref`, and plain
version strings, but was missing support for `catalog:` references. When a
`package.json` used `"catalog:"` or `"catalog:<name>"` in its
`overrides` field, the parser tried to interpret it as a semver range and emitted:

```
Warning failed to parse npm overrides: Failed to parse override value "catalog:" for key "@types/bun": Invalid version requirement
```

This adds a `catalog:` branch to `parse_override_string` that resolves
the entry from workspace catalogs, then parses the resolved version string.
Both `catalog:` (default catalog) and `catalog:<name>` (named catalog)
are supported.

Reported in
denoland#32947 (comment)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Introduce catalogs for dependency management like pnpm

4 participants