Repository: lerna-lite/lerna-lite (ts)

Issue:
Support for `bun`
### Clear and concise description of the problem

Hi! I was just thinking of checking out bun in my monorepo since it recently added catalogs. I'm using lerna-lite to publish packages, but seems like both lerna and lerna-lite doesn't support bun according to issue https://github.com/lerna/lerna/issues/3836, just wanted to check if this is at all planned just to set my expectations correct, thanks so much for the effort!

### Suggested solution

Add support for npmClient for `bun`

### Alternative

_No response_

### Additional context

_No response_

### Validations

- [x] Follow our [Code of Conduct](https://github.com/lerna-lite/lerna-lite/blob/main/CODE_OF_CONDUCT.md)
- [x] Read the [docs](https://github.com/lerna-lite/lerna-lite#readme).
- [x] Check that there isn't already an issue that request the same feature to avoid creating a duplicate.

Pull request description:
feat: support Bun Catalogs, fixes #1063
<!--- Provide a general summary of your changes in the Title above -->

## Description

Add support for [Bun Catalogs](https://bun.sh/docs/install/catalogs) which is roughly the same as [pnpm Catalogs](https://pnpm.io/catalogs) except that in Bun it's located in the project root `package.json` file under the `workspaces` package field.

Side note, I have never used Bun and not planning to use in the short term, so it's possible that some small issues may show up. If that happens then please help and contribute back to the project. 

## Motivation and Context

This PR does 3 things:
1. add Bun as a new valid `npmClient` option in the JSON Schema
2. add Bun Catalog support which is located in `package.json` in `workspaces` package field
   - the catalog location is different compare to pnpm but the usage is exactly the same 
3. reimplement and simplify dependencies change detection, I was previously looking at git diff changes (which isn't reliable, especially manually) but we can simplify this by reading the files from git (before) vs current file (after), then parse both files and then compare props which is much more reliable and less error prone than git diff analysis. 

fixes #1063

## How Has This Been Tested?

added bunch of new unit tests

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

- [ ] Chore (change that has absolutely no effect on users)
- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Checklist:

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My code follows the code style of this project.
- [x] My change requires a change to the documentation.
- [x] I have updated the documentation accordingly.
- [x] I have added tests to cover my changes.
- [x] All new and existing tests passed.


Patch:
diff --git a/.github/renovate.json5 b/.github/renovate.json5
index 65e73a36..76bc47df 100644
--- a/.github/renovate.json5
+++ b/.github/renovate.json5
@@ -12,7 +12,7 @@
     },
     {
       // group all Conventional-Changelog dependencies
-      matchPackagePatterns: ['^conventional-'],
+      matchPackagePatterns: ['^conventional-', '^@conventional-changelog/'],
       groupName: 'Conventional-Changelog packages',
     },
     {
diff --git a/packages/cli/schemas/lerna-schema.json b/packages/cli/schemas/lerna-schema.json
index 2df66cec..023de1c1 100644
--- a/packages/cli/schemas/lerna-schema.json
+++ b/packages/cli/schemas/lerna-schema.json
@@ -1090,9 +1090,9 @@
       },
       "npmClient": {
         "type": "string",
-        "description": "The npm client to use when running commands (either npm, pnpm or yarn). Defaults to npm if unspecified.",
+        "description": "The npm client to use when running commands (either 'npm', 'pnpm', 'yarn' or 'bun'). Defaults to 'npm' if unspecified.",
         "default": "npm",
-        "enum": ["npm", "pnpm", "yarn"]
+        "enum": ["npm", "pnpm", "yarn", "bun"]
       },
       "npmClientArgs": {
         "type": "array",
diff --git a/packages/core/src/__fixtures__/catalog-protocol/lerna.json b/packages/core/src/__fixtures__/package-workspaces-catalog/lerna.json
similarity index 100%
rename from packages/core/src/__fixtures__/catalog-protocol/lerna.json
rename to packages/core/src/__fixtures__/package-workspaces-catalog/lerna.json
diff --git a/packages/core/src/__fixtures__/package-workspaces-catalog/package.json b/packages/core/src/__fixtures__/package-workspaces-catalog/package.json
new file mode 100644
index 00000000..f80552fd
--- /dev/null
+++ b/packages/core/src/__fixtures__/package-workspaces-catalog/package.json
@@ -0,0 +1,29 @@
+{
+  "name": "workspace-protocol-specs",
+  "description": "workspace protocol specifiers should act just like recognized semver ranges",
+  "private": true,
+  "version": "0.0.0-lerna",
+  "dependencies": {
+    "package-2": "catalog:"
+  },
+  "workspaces": {
+    "packages": ["packages/**"],
+    "catalog": {
+      "package-1": "2.3.4",
+      "fs-extra": "^11.2.0",
+      "pkg-1": "1.0.0",
+      "p-map": "^7.0.3",
+      "tinyrainbow": "^2.0.0"
+    },
+    "catalogs": {
+      "react17": {
+        "react": "^17.0.2",
+        "react-dom": "^17.0.2"
+      },
+      "react18": {
+        "react": "^18.2.0",
+        "react-dom": "^18.2.0"
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/packages/core/src/__fixtures__/pnpm-catalog/lerna.json b/packages/core/src/__fixtures__/pnpm-catalog/lerna.json
new file mode 100644
index 00000000..0735fc9e
--- /dev/null
+++ b/packages/core/src/__fixtures__/pnpm-catalog/lerna.json
@@ -0,0 +1,4 @@
+{
+  "version": "1.0.0",
+  "npmClient": "pnpm"
+}
diff --git a/packages/core/src/__fixtures__/catalog-protocol/package.json b/packages/core/src/__fixtures__/pnpm-catalog/package.json
similarity index 100%
rename from packages/core/src/__fixtures__/catalog-protocol/package.json
rename to packages/core/src/__fixtures__/pnpm-catalog/package.json
diff --git a/packages/core/src/__fixtures__/catalog-protocol/pnpm-workspace.yaml b/packages/core/src/__fixtures__/pnpm-catalog/pnpm-workspace.yaml
similarity index 68%
rename from packages/core/src/__fixtures__/catalog-protocol/pnpm-workspace.yaml
rename to packages/core/src/__fixtures__/pnpm-catalog/pnpm-workspace.yaml
index cd2bcb0c..8e381f7f 100644
--- a/packages/core/src/__fixtures__/catalog-protocol/pnpm-workspace.yaml
+++ b/packages/core/src/__fixtures__/pnpm-catalog/pnpm-workspace.yaml
@@ -3,11 +3,11 @@ packages:
 
 # Define a catalog of version ranges.
 catalog:
-  package-1: '2.3.4'
-  fs-extra: '^11.2.0'
-  pkg-1: '1.0.0'
-  p-map: '^7.0.3'
-  tinyrainbow: '^2.0.0'
+  package-1: 2.3.4
+  fs-extra: ^11.2.0
+  pkg-1: 1.0.0
+  p-map: ^7.0.3
+  tinyrainbow: ^2.0.0
 
 catalogs:
   # Can be referenced through "catalog:react17"
diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts
index 0e4b93f6..91987250 100644
--- a/packages/core/src/command.ts
+++ b/packages/core/src/command.ts
@@ -325,9 +325,14 @@ export class Command<T extends AvailableCommandOption> {
       chain = chain.then(() => this.project.getPackages());
       chain = chain.then((packages) => {
         const { graphType } = this.options.command?.[this.commandName] ?? {};
-        this.packageGraph = new PackageGraph(packages || [], graphType ?? 'allDependencies');
+        this.packageGraph = new PackageGraph(packages || [], graphType ?? 'allDependencies', 'auto', this.options.npmClient);
         return getFilteredPackages(this.packageGraph, this.execOpts, this.options).then((filteredPackages: Package[]) => {
-          this.packageGraph = new PackageGraph(filteredPackages || [], graphType ?? 'allDependencies');
+          this.packageGraph = new PackageGraph(
+            filteredPackages || [],
+            graphType ?? 'allDependencies',
+            'auto',
+            this.options.npmClient
+          );
         });
       });
     }
diff --git a/packages/core/src/models/command-options.ts b/packages/core/src/models/command-options.ts
index 1f58e875..daafd3ef 100644
--- a/packages/core/src/models/command-options.ts
+++ b/packages/core/src/models/command-options.ts
@@ -1,4 +1,4 @@
-import type { ArboristLoadOption, ChangelogPresetOptions, RemoteClientType } from './interfaces.js';
+import type { ArboristLoadOption, ChangelogPresetOptions, NpmClient, RemoteClientType } from './interfaces.js';
 
 export interface ChangedCommandOption {
   /** use conventional-changelog to determine version bump and generate CHANGELOG. */
@@ -438,7 +438,7 @@ export interface RunCommandOption {
   dryRun?: boolean;
 
   /** Defaults to 'npm', executable used to run scripts (npm, yarn, pnpm, ...). */
-  npmClient?: string;
+  npmClient?: NpmClient;
 
   /** Stream output with lines prefixed by package. */
   stream?: boolean;
diff --git a/packages/core/src/models/interfaces.ts b/packages/core/src/models/interfaces.ts
index 4637060f..93aabbc8 100644
--- a/packages/core/src/models/interfaces.ts
+++ b/packages/core/src/models/interfaces.ts
@@ -151,6 +151,8 @@ export type NpaResolveResult = (npa.FileResult | npa.HostedGitResult | npa.URLRe
   workspaceSpec?: string;
 };
 
+export type NpmClient = 'npm' | 'pnpm' | 'yarn' | 'bun';
+
 export interface LernaConfig {
   command?: {
     init?: InitCommandOption;
@@ -165,7 +167,7 @@ export interface LernaConfig {
   loglevel?: 'silent' | 'error' | 'warn' | 'notice' | 'http' | 'timing' | 'info' | 'verbose' | 'silly';
 
   /** executable used to install dependencies (npm, yarn, pnpm, ...) */
-  npmClient?: 'npm' | 'pnpm' | 'yarn';
+  npmClient?: NpmClient;
 
   /** enables integration with Yarn or other package manager that use `workspaces` property in `package.json` */
   useWorkspaces?: boolean;
diff --git a/packages/core/src/package-graph/package-graph.ts b/packages/core/src/package-graph/package-graph.ts
index dda9137b..81da1f36 100644
--- a/packages/core/src/package-graph/package-graph.ts
+++ b/packages/core/src/package-graph/package-graph.ts
@@ -1,12 +1,9 @@
-import { existsSync, readFileSync } from 'node:fs';
-import { join } from 'node:path';
-
 import { log } from '@lerna-lite/npmlog';
 import npa from 'npm-package-arg';
-import { parse } from 'yaml';
 
-import type { NpaResolveResult } from '../models/interfaces.js';
+import type { NpaResolveResult, NpmClient } from '../models/interfaces.js';
 import type { Package } from '../package.js';
+import { extractCatalogConfigFromPkg, extractCatalogConfigFromYaml } from '../utils/catalog-utils.js';
 import { ValidationError } from '../validation-error.js';
 import { CyclicPackageGraphNode } from './lib/cyclic-package-graph-node.js';
 import { PackageGraphNode } from './lib/package-graph-node.js';
@@ -35,6 +32,9 @@ export class PackageGraph extends Map<string, PackageGraphNode> {
   /** does the workspace project use pnpm catalog? */
   hasWorkspaceCatalog = false;
 
+  /** npm client being used */
+  npmClient: NpmClient = 'npm';
+
   /**
    * @param {Package[]} packages - An array of Packages to build the graph out of.
    * @param {'allDependencies' | 'dependencies'} [graphType]
@@ -45,7 +45,8 @@ export class PackageGraph extends Map<string, PackageGraphNode> {
   constructor(
     packages: Package[],
     graphType: 'allDependencies' | 'dependencies' = 'allDependencies',
-    localDependencies: boolean | 'auto' | 'force' | 'explicit' | 'forceLocal' = 'auto'
+    localDependencies: boolean | 'auto' | 'force' | 'explicit' | 'forceLocal' = 'auto',
+    client: NpmClient = 'npm'
   ) {
     // For backward compatibility
     if (localDependencies === true || localDependencies === 'forceLocal') {
@@ -73,7 +74,8 @@ export class PackageGraph extends Map<string, PackageGraphNode> {
       }
     }
 
-    const { catalog, catalogs } = this.readWorkspaceCatalogConfig();
+    this.npmClient = client;
+    const { catalog, catalogs } = this.readWorkspaceCatalogConfig(this.npmClient);
 
     if (Object.keys(catalog).length > 0 || Object.keys(catalogs).length > 0) {
       this.hasWorkspaceCatalog = true;
@@ -190,16 +192,15 @@ export class PackageGraph extends Map<string, PackageGraphNode> {
     return this.extendList(filteredPackages, 'localDependents');
   }
 
-  readWorkspaceCatalogConfig() {
-    const workspaceConfigPath = join(process.cwd(), 'pnpm-workspace.yaml');
-    const { catalog = {}, catalogs = {} } = existsSync(workspaceConfigPath)
-      ? parse(readFileSync(workspaceConfigPath, 'utf8'))
-      : {};
+  /** read the workspaces catalog depending on the npm client (currently support 'pnpm' and 'bun') */
+  readWorkspaceCatalogConfig(npmClient: NpmClient) {
+    if (npmClient === 'pnpm') {
+      return extractCatalogConfigFromYaml();
+    } else if (npmClient === 'bun') {
+      return extractCatalogConfigFromPkg();
+    }
 
-    return {
-      catalog,
-      catalogs,
-    };
+    return { catalog: {}, catalogs: {} };
   }
 
   /**
diff --git a/packages/core/src/project/project.ts b/packages/core/src/project/project.ts
index bc9e95ad..e2e5473b 100644
--- a/packages/core/src/project/project.ts
+++ b/packages/core/src/project/project.ts
@@ -13,6 +13,7 @@ import { writeJsonFile } from 'write-json-file';
 
 import type { ProjectConfig, RawManifest } from '../models/interfaces.js';
 import { Package } from '../package.js';
+import { looselyJsonParse } from '../utils/object-utils.js';
 import { ValidationError } from '../validation-error.js';
 import { applyExtends } from './lib/apply-extends.js';
 import { makeFileFinder, makeSyncFileFinder } from './lib/make-file-finder.js';
@@ -262,15 +263,7 @@ export class Project {
      * we perform an automated config migration, e.g. via `lerna repair` and they will be lost.
      * (Although that will be easy enough for the user to see and updated in their `git diff`)
      */
-    try {
-      return JSON5.parse(content);
-    } catch (err: unknown) {
-      if (err instanceof Error) {
-        err.name = 'JSONError';
-        err.message = `Error in: ${filepath}\n${err.message}`;
-      }
-      throw err;
-    }
+    return looselyJsonParse(content, filepath);
   }
 
   serializeConfig(): Promise<string> {
diff --git a/packages/core/src/utils/catalog-utils.ts b/packages/core/src/utils/catalog-utils.ts
new file mode 100644
index 00000000..90263701
--- /dev/null
+++ b/packages/core/src/utils/catalog-utils.ts
@@ -0,0 +1,89 @@
+import { existsSync, readFileSync } from 'node:fs';
+import { join } from 'node:path';
+
+import { parse as yamlParse } from 'yaml';
+
+import { looselyJsonParse } from './object-utils.js';
+
+export type CatalogConfig = {
+  catalog: Record<string, string>;
+  catalogs: Record<string, Record<string, string>>;
+};
+
+/**
+ * Extract catalog config from `pnpm-workspace.yaml` located in the project root.
+ * From a file content that is either provided as input or read from the 'pnpm-workspace.yaml',
+ * it will then parse that file content and return pnpm catalog(s) config
+ * @param {String} [yamlContent] - optional yaml file content
+ * @returns
+ */
+export function extractCatalogConfigFromYaml(yamlContent?: string): CatalogConfig {
+  let fileContent = yamlContent || '';
+  if (!yamlContent) {
+    const yamlPath = join(process.cwd(), 'pnpm-workspace.yaml');
+    if (existsSync(yamlPath)) {
+      fileContent = readFileSync(yamlPath, 'utf8');
+    }
+  }
+
+  const config = yamlParse(fileContent) || {};
+  return {
+    catalog: config.catalog || {},
+    catalogs: config.catalogs || {},
+  };
+}
+
+/**
+ * Extract catalog config from the `workspaces` located in the project root `package.json`.
+ * From a file content that is either provided as input or read from the 'package.json',
+ * it will then parse that file content and return pnpm catalog(s) config
+ * @param {String} [pkgContent] - optional JSON stringified package file content
+ * @returns
+ */
+export function extractCatalogConfigFromPkg(pkgContent?: string): CatalogConfig {
+  let fileContent = pkgContent || '';
+  if (!pkgContent) {
+    const pkgPath = join(process.cwd(), 'package.json');
+    if (existsSync(pkgPath)) {
+      fileContent = readFileSync(pkgPath, 'utf8');
+    }
+  }
+
+  const pkg = looselyJsonParse(fileContent) || {};
+  const ws = pkg.workspaces || {};
+  return {
+    catalog: ws.catalog || {},
+    catalogs: ws.catalogs || {},
+  };
+}
+
+/**
+ * Compare the before/after catalog(s) config and return any dependencies that changed in the workspace catalog.
+ * @param {CatalogConfig} prev - previous catalog(s) config
+ * @param {CatalogConfig} curr - current catalog(s) config
+ * @returns
+ */
+export function diffCatalogs(prev: CatalogConfig, curr: CatalogConfig): Set<string> {
+  const changed = new Set<string>();
+
+  // Helper to compare two dependency maps for added, removed, or changed deps
+  const compareDeps = (prevDeps: Record<string, string> = {}, currDeps: Record<string, string> = {}) => {
+    const allDeps = new Set([...Object.keys(prevDeps), ...Object.keys(currDeps)]);
+    for (const dep of allDeps) {
+      if (!(dep in prevDeps) || !(dep in currDeps) || prevDeps[dep] !== currDeps[dep]) {
+        changed.add(dep);
+      }
+    }
+  };
+
+  compareDeps(prev.catalog, curr.catalog);
+
+  const prevCatalogs = prev.catalogs || {};
+  const currCatalogs = curr.catalogs || {};
+  const allCatalogNames = new Set([...Object.keys(prevCatalogs), ...Object.keys(currCatalogs)]);
+  for (const catName of allCatalogNames) {
+    compareDeps(prevCatalogs[catName], currCatalogs[catName]);
+  }
+
+  return changed;
+}
diff --git a/packages/core/src/utils/collect-updates/__helpers__/build-graph.ts b/packages/core/src/utils/collect-updates/__helpers__/build-graph.ts
index 58794fca..17b82db6 100644
--- a/packages/core/src/utils/collect-updates/__helpers__/build-graph.ts
+++ b/packages/core/src/utils/collect-updates/__helpers__/build-graph.ts
@@ -1,7 +1,7 @@
 // helpers
-import { Package, PackageGraph } from '../../../index.js';
+import { NpmClient, Package, PackageGraph } from '../../../index.js';
 
-function buildGraph(mapPackages = (pkg) => pkg) {
+function buildGraph(mapPackages = (pkg) => pkg, npmClient: NpmClient = 'npm') {
   // cat __fixtures__/toposort/packages/*/package.json
   const packages = [
     {
@@ -69,7 +69,7 @@ function buildGraph(mapPackages = (pkg) => pkg) {
     .map(mapPackages)
     .map((json) => new Package(json, `/test/packages/${json.name}`, '/test'));
 
-  return new PackageGraph(packages);
+  return new PackageGraph(packages, 'allDependencies', 'auto', npmClient);
 }
 
 export default buildGraph;
diff --git a/packages/core/src/utils/collect-updates/collect-updates.ts b/packages/core/src/utils/collect-updates/collect-updates.ts
index 3f90dc56..438a24e6 100644
--- a/packages/core/src/utils/collect-updates/collect-updates.ts
+++ b/packages/core/src/utils/collect-updates/collect-updates.ts
@@ -105,7 +105,7 @@ export function collectUpdates(
 
   const changedCatalogDeps: string[] = [];
   if (prevTag && packageGraph.hasWorkspaceCatalog) {
-    changedCatalogDeps.push(...diffWorkspaceCatalog(prevTag));
+    changedCatalogDeps.push(...diffWorkspaceCatalog(prevTag, packageGraph.npmClient));
   }
 
   const hasDiff = makeDiffPredicate(
diff --git a/packages/core/src/utils/collect-updates/lib/make-diff-predicate.ts b/packages/core/src/utils/collect-updates/lib/make-diff-predicate.ts
index 986a0299..c67bcd18 100644
--- a/packages/core/src/utils/collect-updates/lib/make-diff-predicate.ts
+++ b/packages/core/src/utils/collect-updates/lib/make-diff-predicate.ts
@@ -1,15 +1,20 @@
-import { readFileSync } from 'node:fs';
+import { existsSync, readFileSync } from 'node:fs';
 import { dirname, join, relative } from 'node:path';
 
 import { log } from '@lerna-lite/npmlog';
 import { filter as minimatchFilter } from 'minimatch';
 import slash from 'slash';
 import { globSync } from 'tinyglobby';
-import { parse as yamlParse } from 'yaml';
 
 import { execSync } from '../../../child-process.js';
-import type { ExecOpts } from '../../../models/interfaces.js';
+import type { ExecOpts, NpmClient } from '../../../models/interfaces.js';
 import { type PackageGraphNode } from '../../../package-graph/lib/package-graph-node.js';
+import {
+  type CatalogConfig,
+  diffCatalogs,
+  extractCatalogConfigFromPkg,
+  extractCatalogConfigFromYaml,
+} from '../../catalog-utils.js';
 
 /**
  * @param {string} committish
@@ -113,62 +118,38 @@ function diffSinceIn(committish: string, location: string, execOpts: ExecOpts, d
 }
 
 /**
- * When using pnpm workspace catalog(s), we will compare current catalogs against the previous commited catalogs
- * and return dependencies that changed since then.
+ * Returns dependencies whose semver ranges changed, were added, or removed
+ * in pnpm-workspace.yaml
+ * or package.json catalog(s) since prevTag.
  */
-export function diffWorkspaceCatalog(prevTag: string): string[] {
-  const changedDependencies: string[] = [];
+export function diffWorkspaceCatalog(prevTag: string, npmClient: NpmClient): string[] {
   try {
-    // Get the previous commit's file contents
-    const prevWorkspaceContent = execSync('git', ['show', `${prevTag}:pnpm-workspace.yaml`]);
-
-    // Get the current commit's file contents
-    const workspaceConfigPath = join(process.cwd(), 'pnpm-workspace.yaml');
-    const currentWorkspaceContent = readFileSync(workspaceConfigPath, 'utf8');
-
-    // Parse the YAML files
-    const previousConfig = yamlParse(prevWorkspaceContent);
-    const currentConfig = yamlParse(currentWorkspaceContent);
-
-    // If either config is missing catalog, fallback to diff
-    if (currentConfig.catalog || currentConfig.catalogs) {
-      // Find the changed dependencies
-      Object.keys(currentConfig.catalog || {}).forEach((depName) => {
-        if (!previousConfig.catalog[depName] || previousConfig.catalog[depName] !== currentConfig.catalog[depName]) {
-          changedDependencies.push(depName);
-        }
-      });
-      Object.keys(currentConfig.catalogs || {}).forEach((catName) => {
-        Object.keys(currentConfig.catalogs[catName]).forEach((depName) => {
-          if (
-            !currentConfig.catalogs[catName][depName] ||
-            previousConfig.catalogs[catName][depName] !== currentConfig.catalogs[catName][depName]
-          ) {
-            changedDependencies.push(depName);
-          }
-        });
-      });
-
-      // Get the diff output for the catalog file
-      const diffOutput = execSync('git', ['diff', `${prevTag}..HEAD`, '--', 'pnpm-workspace.yaml']);
-      const diffLines = diffOutput.split('\n');
-      diffLines.forEach((line) => {
-        if (line.startsWith('+  ') && line.includes(':')) {
-          const key = line.substring(3).split(':')[0].trim();
-          if (key && !changedDependencies.includes(key)) {
-            changedDependencies.push(key);
-          }
-        } else if (line.startsWith('+ catalog.') && line.includes(':')) {
-          const key = line.substring(10).split(':')[0].trim();
-          if (key && !changedDependencies.includes(key)) {
-            changedDependencies.push(key);
-          }
-        }
-      });
+    const cwd = process.cwd();
+    const yamlPath = join(cwd, 'pnpm-workspace.yaml');
+    const jsonPath = join(cwd, 'package.json');
+
+    let prevConfig: CatalogConfig = { catalog: {}, catalogs: {} };
+    let currConfig: CatalogConfig = { catalog: {}, catalogs: {} };
+
+    if (npmClient === 'pnpm' && existsSync(yamlPath)) {
+      // pnpm workspace
+      const prevYamlStr = execSync(`git show ${prevTag}:pnpm-workspace.yaml`);
+      const currYamlStr = readFileSync(yamlPath, 'utf8');
+      prevConfig = extractCatalogConfigFromYaml(prevYamlStr);
+      currConfig = extractCatalogConfigFromYaml(currYamlStr);
+    } else if (npmClient === 'bun' && existsSync(jsonPath)) {
+      // Bun workspace
+      const prevJsonStr = execSync(`git show ${prevTag}:package.json`);
+      const currJsonStr = readFileSync(jsonPath, 'utf8');
+      prevConfig = extractCatalogConfigFromPkg(prevJsonStr);
+      currConfig = extractCatalogConfigFromPkg(currJsonStr);
+    } else {
+      // No supported workspace file found
+      return [];
     }
+
+    return Array.from(diffCatalogs(prevConfig, currConfig));
   } catch {
-    // do nothing, an empty array will be returned
+    return [];
   }
-
-  return changedDependencies;
 }
diff --git a/packages/core/src/utils/object-utils.ts b/packages/core/src/utils/object-utils.ts
index c85c83dd..81b9259c 100644
--- a/packages/core/src/utils/object-utils.ts
+++ b/packages/core/src/utils/object-utils.ts
@@ -1,4 +1,5 @@
 import { log } from '@lerna-lite/npmlog';
+import JSON5 from 'json5';
 
 /**
  * From a dot (.) notation path, find and delete a property within an object if found given a complex object path
@@ -45,3 +46,21 @@ export function getComplexObjectValue<T>(object: any, path: string): T {
 export function isEmpty(obj: object) {
   return !obj || Object.keys(obj).length === 0;
 }
+
+/**
+ * Loosely parse a stringified JSON file content, accepting comments using json5 parser
+ * @param {String} content - file content
+ * @param {String} filepath - optional file path location for the error
+ * @returns
+ */
+export function looselyJsonParse(content: string, filepath = '') {
+  try {
+    return JSON5.parse(content || '{}');
+  } catch (err: unknown) {
+    if (err instanceof Error) {
+      err.name = 'JSONError';
+      err.message = `Error in: ${filepath}\n${err.message}`;
+    }
+    throw err;
+  }
+}
diff --git a/packages/publish/README.md b/packages/publish/README.md
index 19a70147..79370f34 100644
--- a/packages/publish/README.md
+++ b/packages/publish/README.md
@@ -592,12 +592,14 @@ lerna will run [npm lifecycle scripts](https://docs.npmjs.com/cli/v8/using-npm/s
 
 # `catalog:` protocol
 
-The `catalog:` protocol ([pnpm catalog](https://pnpm.io/catalogs)) can be recognized by Lerna-Lite. When publishing, they will be replaced "as is" by reading and using the version range defined in your global catalog. If you need to bump the version of a package in a catalog, you will need to edit `pnpm-workspace.yaml` manually. If you wish them to be bumped automatically, then we strongly suggest that you use the [`workspace:`](#workspace-protocol) protocol instead which is better for local workspace dependencies.
+The `catalog:` protocol ([pnpm catalogs](https://pnpm.io/catalogs), [Bun catalogs](https://bun.sh/docs/install/catalogs)) can be recognized by Lerna-Lite. When publishing, they will be replaced "as is" by reading and using the version range defined in your global catalog. If you need to bump the version of a package in a catalog, you will need to edit `pnpm-workspace.yaml` (or `package.json > workspaces`) manually. If you wish them to be bumped automatically, then we strongly suggest that you use the [`workspace:`](#workspace-protocol) protocol instead of catalog which is better for local workspace dependencies.
 
 > [!NOTE]
-> Lerna-Lite only has a read access to the catalog and will pull all dependency versions it finds in your `pnpm-workspace.yaml` catalog, but it will **never write** back to that file. If you want version bump while running lerna version/publish commands, then you should use `workspace:` for local dependencies. A side note though, is that it does work with local dependencies but only if the local dependency version changed in a previous git commit **before** lerna commands are executed (since again Lerna-Lite will never write or update the catalog).
+> Lerna-Lite only has a read access to the catalog and will pull all dependency versions it finds from your `pnpm-workspace.yaml` (or `package.json`) catalog, but it will **never write** back to that file. If you want version bump while running lerna version/publish commands, then you should use `workspace:` for local dependencies. A side note though, it does work with local dependencies but only if the local dependency version changed in a previous git commit **before** lerna commands are executed (since again Lerna-Lite will never write or update the catalog).
 
-So for example, if our `pnpm-workspace.yaml` file has the following configuration
+So for example, if our `pnpm-workspace.yaml` (or `package.json > workspaces`) file has the following configuration
+
+from a `pnpm-workspace.yaml`
 
 ```yaml
 packages:
@@ -619,7 +621,32 @@ catalogs:
     react-dom: ^18.2.0
 ```
 
-and then if one of our package has the following dependencies defined in our `package.json`
+or from project root `package.json > workspaces`
+
+```json
+{
+  "name": "my-monorepo",
+  "workspaces": {
+    "packages": ["packages/*"],
+    "catalog": {
+      "fs-extra": "^11.2.0",
+      "typescript": "^5.7.0"
+    },
+    "catalogs": {
+      "react17": {
+        "react": "^17.0.0",
+        "react-dom": "^17.0.0"
+      },
+      "react18": {
+        "react": "^18.2.0",
+        "react-dom": "^18.2.0"
+      }
+    }
+  }
+}
+```
+
+and then if one of our monorepo package have the following dependencies defined in our `package.json`
 
 ```json
 {
diff --git a/packages/version/README.md b/packages/version/README.md
index 228af252..90fef84a 100644
--- a/packages/version/README.md
+++ b/packages/version/README.md
@@ -603,7 +603,7 @@ Displays the git command that would be performed without actually executing it,
 > **Note** changelogs will still be created (when enabled) even in dry-run mode, so it could be useful to see what gets created without them being committed (however, make sure to revert the changes and roll back your version in `lerna.json` once you're satisfied with the output).
 
 ```sh
-$ lerna run watch --dry-run
+$ lerna version --dry-run
 ```
 
 ### `--git-tag-command <cmd>`
@@ -988,16 +988,16 @@ lerna will run [npm lifecycle scripts](https://docs.npmjs.com/cli/v8/using-npm/s
 
 # `catalog:` protocol
 
-The pnpm `catalog:` protocol ([pnpm catalogs](https://pnpm.io/catalogs) which is only available in pnpm) is also supported by Lerna-Lite, here's a quote from pnpm docs that best describes the feature.
+The `catalog:` protocol ([pnpm catalogs](https://pnpm.io/catalogs), [Bun catalogs](https://bun.sh/docs/install/catalogs)) is also supported by Lerna-Lite, here's a quote from pnpm docs that best describes the feature.
 
 > "Catalogs" are a [workspace feature](https://pnpm.io/workspaces) for defining dependency version ranges as reusable constants. Constants defined in catalogs can later be referenced in `package.json` files.
 
-Lerna-Lite will replace all `catalog:` protocol with the version ranges pulled from the `pnpm-workspace.yaml` global catalog(s) while executing lerna version or publish commands.
+Lerna-Lite will replace all `catalog:` protocol with the version ranges pulled from the `pnpm-workspace.yaml` or `package.json > workspaces` global catalog(s) while executing lerna version or publish commands.
 
 > [!NOTE]
-> Lerna-Lite only has a read access to the catalog and will pull all dependency versions it finds in your `pnpm-workspace.yaml` catalog, but it will **never write** back to that file. If you want version bump while running lerna version/publish commands, then you should use `workspace:` for local dependencies. A side note though, is that it does work with local dependencies but only if the local dependency version changed in a previous git commit **before** lerna commands are executed (since again Lerna-Lite will never write or update the catalog).
+> Lerna-Lite only has a read access to the catalog and will pull all dependency versions it finds from your `pnpm-workspace.yaml` (or `package.json`) catalog, but it will **never write** back to that file. If you want version bump while running lerna version/publish commands, then you should use `workspace:` for local dependencies. A side note though, it does work with local dependencies but only if the local dependency version changed in a previous git commit **before** lerna commands are executed (since again Lerna-Lite will never write or update the catalog).
 
-For more details about how Catalog works in Lerna-Lite, take a look at the [`lerna publish#catalog`](https://github.com/lerna-lite/lerna-lite/blob/main/packages/publish/README.md#catalog-protocol) documentation.
+For more details about how Catalog works in Lerna-Lite, take a look at the [`lerna publish#catalog`](../publish/README.md#catalog-protocol) documentation.
 
 # `workspace:` protocol
 
diff --git a/packages/version/src/lib/update-lockfile-version.ts b/packages/version/src/lib/update-lockfile-version.ts
index 0ebfd5f4..d215134c 100644
--- a/packages/version/src/lib/update-lockfile-version.ts
+++ b/packages/version/src/lib/update-lockfile-version.ts
@@ -2,7 +2,7 @@ import { promises } from 'node:fs';
 import { EOL } from 'node:os';
 import { join } from 'node:path';
 
-import type { Package } from '@lerna-lite/core';
+import type { NpmClient, Package } from '@lerna-lite/core';
 import { execPackageManager, execPackageManagerSync } from '@lerna-lite/core';
 import { log } from '@lerna-lite/npmlog';
 import { loadJsonFile } from 'load-json-file';
@@ -120,12 +120,12 @@ export function updateNpmLockFileVersion2(obj: any, pkgName: string, newVersion:
 
 /**
  * Run `npm install --package-lock-only` or equivalent depending on the package manager defined in `npmClient`
- * @param {'npm' | 'pnpm' | 'yarn'} npmClient
+ * @param {NpmClient} npmClient
  * @param {String} cwd
  * @returns {Promise<string | undefined>} lockfile name when executed successfully
  */
 export async function runInstallLockFileOnly(
-  npmClient: 'npm' | 'pnpm' | 'yarn',
+  npmClient: NpmClient,
   cwd: string,
   options: {
     npmClientArgs: string[];


Test patch:
diff --git a/packages/core/src/package-graph/__tests__/package-graph.spec.ts b/packages/core/src/package-graph/__tests__/package-graph.spec.ts
index 7919ef60..c4c1a112 100644
--- a/packages/core/src/package-graph/__tests__/package-graph.spec.ts
+++ b/packages/core/src/package-graph/__tests__/package-graph.spec.ts
@@ -14,7 +14,7 @@ describe('PackageGraph', () => {
   let testDir = '';
 
   beforeAll(async () => {
-    testDir = await initFixture('catalog-protocol');
+    testDir = await initFixture('pnpm-catalog');
   });
 
   afterEach(() => {
@@ -36,31 +36,6 @@ describe('PackageGraph', () => {
       expect(() => new PackageGraph(pkgs)).toThrowError('Package name "pkg-2" used in multiple packages:\n\t/test/pkg-2\n\t/test/pkg-3');
     });
 
-    it('can read catalog & catalogs from "pnpm-workspace.yaml" file', () => {
-      const pkgs = [];
-      const graph = new PackageGraph(pkgs);
-
-      expect(graph.readWorkspaceCatalogConfig()).toEqual({
-        catalog: {
-          'package-1': '2.3.4',
-          'fs-extra': '^11.2.0',
-          'p-map': '^7.0.3',
-          'pkg-1': '1.0.0',
-          tinyrainbow: '^2.0.0',
-        },
-        catalogs: {
-          react17: {
-            react: '^17.0.2',
-            'react-dom': '^17.0.2',
-          },
-          react18: {
-            react: '^18.2.0',
-            'react-dom': '^18.2.0',
-          },
-        },
-      });
-    });
-
     it('externalizes non-satisfied semver of local sibling', () => {
       const pkgs = [
         new Package(
@@ -131,7 +106,115 @@ describe('PackageGraph', () => {
       expect(pkg2.localDependencies.has('pkg-1')).toBe(true);
     });
 
-    it('only localizes catalog: siblings when it must be explicit', () => {
+    it('only localizes workspace: siblings when it must be explicit', () => {
+      const pkgs = [
+        new Package(
+          {
+            name: 'pkg-1',
+            version: '1.0.0',
+          } as unknown as RawManifest,
+          '/test/pkg-1'
+        ),
+        new Package(
+          {
+            name: 'pkg-2',
+            version: '1.0.0',
+            dependencies: {
+              'pkg-1': '^1.0.0',
+            },
+          } as unknown as RawManifest,
+          '/test/pkg-2'
+        ),
+        new Package(
+          {
+            name: 'pkg-3',
+            version: '1.0.0',
+            dependencies: {
+              'pkg-1': 'workspace:^1.0.0',
+            },
+            peerDependencies: {
+              'pkg-1': 'workspace:^1.0.0',
+            },
+          } as unknown as RawManifest,
+          '/test/pkg-3'
+        ),
+        new Package(
+          {
+            name: 'pkg-4',
+            version: '1.0.0',
+            dependencies: {
+              'pkg-1': 'workspace:*',
+            },
+          } as unknown as RawManifest,
+          '/test/pkg-4'
+        ),
+        new Package(
+          {
+            name: 'pkg-5',
+            version: '1.0.0',
+            dependencies: {
+              'pkg-1': 'workspace:^',
+              'pkg-2': 'workspace:~',
+              'pkg-3': 'workspace:^1.0.0',
+              'pkg-4': 'workspace:>=1.0.0',
+            },
+            peerDependencies: {
+              'pkg-1': 'workspace:^1.0.0',
+            },
+          } as unknown as RawManifest,
+          '/test/pkg-5'
+        ),
+      ];
+
+      const graph = new PackageGraph(pkgs, 'allDependencies', 'explicit');
+      const [pkg1, pkg2, pkg3, pkg4, pkg5] = graph.values();
+
+      expect(pkg1.localDependents.has('pkg-2')).toBe(false);
+      expect(pkg2.localDependencies.has('pkg-1')).toBe(false);
+      expect(pkg1.localDependents.has('pkg-3')).toBe(true);
+      expect(pkg3.localDependencies.has('pkg-1')).toBe(true);
+      expect(pkg4.localDependencies.has('pkg-1')).toBe(true);
+      expect(pkg4.localDependencies.get('pkg-1').workspaceSpec).toBe('workspace:*');
+      expect(pkg5.localDependencies.has('pkg-1')).toBe(true);
+      expect(pkg5.localDependencies.has('pkg-2')).toBe(true);
+      expect(pkg5.localDependencies.get('pkg-1').workspaceSpec).toBe('workspace:^');
+      expect(pkg5.localDependencies.get('pkg-2').workspaceSpec).toBe('workspace:~');
+      expect(pkg5.localDependencies.get('pkg-3').workspaceSpec).toBe('workspace:^1.0.0');
+      expect(pkg5.localDependencies.get('pkg-4').workspaceSpec).toBe('workspace:>=1.0.0');
+    });
+  });
+
+  describe('pnpm workspace catalog', () => {
+    beforeAll(async () => {
+      testDir = await initFixture('pnpm-catalog');
+    });
+
+    it('can read catalog & catalogs from "pnpm-workspace.yaml" file', () => {
+      const pkgs = [];
+      const graph = new PackageGraph(pkgs);
+
+      expect(graph.readWorkspaceCatalogConfig('pnpm')).toEqual({
+        catalog: {
+          'package-1': '2.3.4',
+          'fs-extra': '^11.2.0',
+          'p-map': '^7.0.3',
+          'pkg-1': '1.0.0',
+          tinyrainbow: '^2.0.0',
+        },
+        catalogs: {
+          react17: {
+            react: '^17.0.2',
+            'react-dom': '^17.0.2',
+          },
+          react18: {
+            react: '^18.2.0',
+            'react-dom': '^18.2.0',
+          },
+        },
+      });
+    });
+
+    it('only localizes catalog: siblings when it must be explicit using pnpm', () => {
       const logWarnSpy = vi.spyOn(log, 'warn');
       const pkgs = [
         new Package(
@@ -177,7 +260,7 @@ describe('PackageGraph', () => {
         ),
       ];
 
-      const graph = new PackageGraph(pkgs, 'allDependencies', 'explicit');
+      const graph = new PackageGraph(pkgs, 'allDependencies', 'explicit', 'pnpm');
       const [pkg1, pkg2, pkg3, pkg4] = graph.values();
 
       expect(pkg1.localDependents.has('pkg-2')).toBe(false);
@@ -193,8 +276,40 @@ describe('PackageGraph', () => {
       expect(pkg4.localDependencies.get('pkg-2').fetchSpec).toBe(''); // not found in global catalog so it will show warning below
       expect(logWarnSpy).toHaveBeenCalledWith('graph', 'No version found in "default" catalog for "pkg-2"');
     });
+  });
 
-    it('only localizes workspace: siblings when it must be explicit', () => {
+  describe('package.json workspace catalog', () => {
+    beforeAll(async () => {
+      testDir = await initFixture('package-workspaces-catalog');
+    });
+
+    it('can read catalog & catalogs from "package.json" file', () => {
+      const pkgs = [];
+      const graph = new PackageGraph(pkgs);
+
+      expect(graph.readWorkspaceCatalogConfig('bun')).toEqual({
+        catalog: {
+          'package-1': '2.3.4',
+          'fs-extra': '^11.2.0',
+          'p-map': '^7.0.3',
+          'pkg-1': '1.0.0',
+          tinyrainbow: '^2.0.0',
+        },
+        catalogs: {
+          react17: {
+            react: '^17.0.2',
+            'react-dom': '^17.0.2',
+          },
+          react18: {
+            react: '^18.2.0',
+            'react-dom': '^18.2.0',
+          },
+        },
+      });
+    });
+
+    it('only localizes catalog: siblings when it must be explicit using bun', () => {
+      const logWarnSpy = vi.spyOn(log, 'warn');
       const pkgs = [
         new Package(
           {
@@ -218,10 +333,11 @@ describe('PackageGraph', () => {
             name: 'pkg-3',
             version: '1.0.0',
             dependencies: {
-              'pkg-1': 'workspace:^1.0.0',
+              'pkg-1': 'catalog:',
+              react: 'catalog:react18',
             },
             peerDependencies: {
-              'pkg-1': 'workspace:^1.0.0',
+              'pkg-1': 'catalog:',
             },
           } as unknown as RawManifest,
           '/test/pkg-3'
@@ -231,44 +347,28 @@ describe('PackageGraph', () => {
             name: 'pkg-4',
             version: '1.0.0',
             dependencies: {
-              'pkg-1': 'workspace:*',
+              'pkg-2': 'catalog:',
             },
           } as unknown as RawManifest,
           '/test/pkg-4'
         ),
-        new Package(
-          {
-            name: 'pkg-5',
-            version: '1.0.0',
-            dependencies: {
-              'pkg-1': 'workspace:^',
-              'pkg-2': 'workspace:~',
-              'pkg-3': 'workspace:^1.0.0',
-              'pkg-4': 'workspace:>=1.0.0',
-            },
-            peerDependencies: {
-              'pkg-1': 'workspace:^1.0.0',
-            },
-          } as unknown as RawManifest,
-          '/test/pkg-5'
-        ),
       ];
 
-      const graph = new PackageGraph(pkgs, 'allDependencies', 'explicit');
-      const [pkg1, pkg2, pkg3, pkg4, pkg5] = graph.values();
+      const graph = new PackageGraph(pkgs, 'allDependencies', 'explicit', 'bun');
+      const [pkg1, pkg2, pkg3, pkg4] = graph.values();
 
       expect(pkg1.localDependents.has('pkg-2')).toBe(false);
       expect(pkg2.localDependencies.has('pkg-1')).toBe(false);
       expect(pkg1.localDependents.has('pkg-3')).toBe(true);
       expect(pkg3.localDependencies.has('pkg-1')).toBe(true);
-      expect(pkg4.localDependencies.has('pkg-1')).toBe(true);
-      expect(pkg4.localDependencies.get('pkg-1').workspaceSpec).toBe('workspace:*');
-      expect(pkg5.localDependencies.has('pkg-1')).toBe(true);
-      expect(pkg5.localDependencies.has('pkg-2')).toBe(true);
-      expect(pkg5.localDependencies.get('pkg-1').workspaceSpec).toBe('workspace:^');
-      expect(pkg5.localDependencies.get('pkg-2').workspaceSpec).toBe('workspace:~');
-      expect(pkg5.localDependencies.get('pkg-3').workspaceSpec).toBe('workspace:^1.0.0');
-      expect(pkg5.localDependencies.get('pkg-4').workspaceSpec).toBe('workspace:>=1.0.0');
+      expect(pkg4.localDependencies.has('pkg-1')).toBe(false);
+      expect(pkg3.localDependencies.get('pkg-1').catalogSpec).toBe('catalog:');
+      expect(pkg3.localDependencies.get('pkg-1').fetchSpec).toBe('1.0.0');
+      expect(pkg3.externalDependencies.get('react').catalogSpec).toBe('catalog:react18'); // named catalog
+      expect(pkg3.externalDependencies.get('react').fetchSpec).toBe('^18.2.0');
+      expect(pkg4.localDependencies.get('pkg-2').catalogSpec).toBe('catalog:');
+      expect(pkg4.localDependencies.get('pkg-2').fetchSpec).toBe(''); // not found in global catalog so it will show warning below
+      expect(logWarnSpy).toHaveBeenCalledWith('graph', 'No version found in "default" catalog for "pkg-2"');
     });
   });
 
diff --git a/packages/core/src/utils/collect-updates/__tests__/collect-updates.spec.ts b/packages/core/src/utils/collect-updates/__tests__/collect-updates.spec.ts
index 7650944d..b7ceba43 100644
--- a/packages/core/src/utils/collect-updates/__tests__/collect-updates.spec.ts
+++ b/packages/core/src/utils/collect-updates/__tests__/collect-updates.spec.ts
@@ -67,11 +67,12 @@ describe('collectUpdates()', () => {
   it('returns node with changes', () => {
     changedPackages.add('package-standalone');
 
-    const graph = buildGraph();
+    const graph = buildGraph(undefined, 'pnpm');
     const pkgs = graph.rawPackageList;
     const execOpts = { cwd: '/test' };
     const updates = collectUpdates(pkgs, graph, execOpts, {});
 
+    expect(diffWorkspaceCatalog).toHaveBeenCalled();
     expect(updates).toEqual([
       expect.objectContaining({
         name: 'package-standalone',
diff --git a/packages/core/src/utils/collect-updates/__tests__/lib-make-diff-predicate.spec.ts b/packages/core/src/utils/collect-updates/__tests__/lib-make-diff-predicate.spec.ts
index 23129713..f71934b3 100644
--- a/packages/core/src/utils/collect-updates/__tests__/lib-make-diff-predicate.spec.ts
+++ b/packages/core/src/utils/collect-updates/__tests__/lib-make-diff-predicate.spec.ts
@@ -10,9 +10,11 @@ vi.mock('tinyglobby', async () => ({
   ...(await vi.importActual('tinyglobby')),
   globSync: globMock,
 }));
+const { existsFileMock } = vi.hoisted(() => ({ existsFileMock: vi.fn() }));
 const { readFileMock } = vi.hoisted(() => ({ readFileMock: vi.fn() }));
 vi.mock('node:fs', async () => ({
   ...(await vi.importActual('node:fs')),
+  existsSync: existsFileMock,
   readFileSync: readFileMock,
 }));
 
@@ -25,7 +27,7 @@ function setup(changes) {
 }
 
 beforeEach(() => {
-  (readFileMock as Mock).mockReset();
+  readFileMock.mockReset();
   (childProcesses.execSync as Mock).mockReset();
 });
 
@@ -140,7 +142,7 @@ test('not exclude any subpackages when --independent-subpackages option is enabl
   });
 });
 
-describe('pnpm catalog', () => {
+describe('pnpm workspace catalog', () => {
   test('diffWorkspaceCatalog with external dependency changes in pnpm workspace catalog', () => {
     setup([]);
 
@@ -169,97 +171,94 @@ describe('pnpm catalog', () => {
     expect(childProcesses.execSync).toHaveBeenLastCalledWith('git', ['diff', '--name-only', 'v1.0.0', '--', 'packages/pkg-1'], { cwd: '/test' });
   });
 
-  test('diff workspace catalog returning dependencies that changed in the catalog', () => {
+  test('diff pnpm workspace catalog returning dependencies that changed in the catalog', () => {
     // make a diff on the catalog for the "execa" dependency
-    const mockPrevCatalogCommit = 'packages:\n  - packages/**\n\ncatalog:\n  execa: ^8.0.0\n  fs-extra: ^11.3.0';
-    const mockNewCatalogCommit = 'packages:\n  - packages/**\n\ncatalog:\n  execa: ^9.5.2\n  fs-extra: ^11.3.0';
-    const diff =
-      'diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml\nindex 1994993f..bf229582 100644\n--- a/pnpm-workspace.yaml\n+++ b/pnpm-workspace.yaml\n@@ -5,16 +5,16 @@ catalog:' +
-      '\n-  execa: ^8.0.1\n+  execa: ^9.5.2\n   fs-extra: ^11.3.0' +
-      'diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml\nindex 1994993f..bf229582 100644\n--- a/pnpm-workspace.yaml\n+++ b/pnpm-workspace.yaml' +
-      '\n@@ -11,13 +11,13 @@ catalog:\n-  execa: ^8.0.1\n+  execa: ^9.5.2\n   fs-extra: ^11.3.0\n@@ -30,7 +30,7';
-
-    (readFileMock as Mock).mockName('readFile').mockReturnValueOnce(mockNewCatalogCommit);
-    (childProcesses.execSync as Mock)
-      .mockReturnValueOnce(mockPrevCatalogCommit) // first call is to get previous catalog commit
-      .mockReturnValueOnce(diff); // next call is to get diff between current and previous catalog
+    const prevFileContent = 'packages:\n  - packages/**\n\ncatalog:\n  execa: ^8.0.1\n  fs-extra: ^11.3.0';
+    const newFileContent = 'packages:\n  - packages/**\n\ncatalog:\n  execa: ^9.5.2\n  fs-extra: ^11.3.0';
+
+    existsFileMock.mockReturnValueOnce(true);
+    readFileMock.mockReturnValueOnce(newFileContent);
+    (childProcesses.execSync as Mock).mockReturnValueOnce(prevFileContent); // first call is to get previous catalog commit
 
-    const changes = diffWorkspaceCatalog('v1.0.0');
+    const changes = diffWorkspaceCatalog('v1.0.0', 'pnpm');
     expect(changes).toEqual(['execa']);
   });
 
-  test('diff workspace catalogs returning dependencies that changed in the catalog', () => {
+  test('diff pnpm workspace catalogs returning dependencies that changed in the catalog', () => {
     // make a diff on the catalog for the "execa" dependency
-    const mockPrevCatalogCommit = 'packages:\n  - packages/**\n\ncatalogs:\n  build:\n    execa: ^8.0.0\n    fs-extra: ^11.3.0';
-    const mockNewCatalogCommit = 'packages:\n  - packages/**\n\ncatalogs:\n  build:\n    execa: ^9.5.2\n    fs-extra: ^11.3.0';
-    const diff =
-      'diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml\nindex 1994993f..bf229582 100644\n--- a/pnpm-workspace.yaml\n+++ b/pnpm-workspace.yaml\n@@ -5,16 +5,16 @@ catalogs:' +
-      '\n  build:' +
-      '\n-    execa: ^8.0.1\n+    execa: ^9.5.2\n     fs-extra: ^11.3.0' +
-      'diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml\nindex 1994993f..bf229582 100644\n--- a/pnpm-workspace.yaml\n+++ b/pnpm-workspace.yaml' +
-      '\n@@ -11,13 +11,13 @@ catalog:\n  build:\n-    execa: ^8.0.1\n+    execa: ^9.5.2\n     fs-extra: ^11.3.0\n@@ -30,7 +30,7';
-
-    (readFileMock as Mock).mockName('readFile').mockReturnValueOnce(mockNewCatalogCommit);
-    (childProcesses.execSync as Mock)
-      .mockReturnValueOnce(mockPrevCatalogCommit) // first call is to get previous catalog commit
-      .mockReturnValueOnce(diff); // next call is to get diff between current and previous catalog
+    const prevFileContent = 'packages:\n  - packages/**\n\ncatalogs:\n  build:\n    execa: ^8.0.1\n    fs-extra: ^11.3.0';
+    const newFileContent = 'packages:\n  - packages/**\n\ncatalogs:\n  build:\n    execa: ^9.5.2\n    fs-extra: ^11.3.0';
+
+    existsFileMock.mockReturnValueOnce(true);
+    readFileMock.mockReturnValueOnce(newFileContent);
+    (childProcesses.execSync as Mock).mockReturnValueOnce(prevFileContent); // first call is to get previous catalog commit
 
-    const changes = diffWorkspaceCatalog('v1.0.0');
+    const changes = diffWorkspaceCatalog('v1.0.0', 'pnpm');
     expect(changes).toEqual(['execa']);
   });
 
-  test('diffWorkspaceCatalog returns changed external dependencies when catalog values differ', () => {
+  test('diff pnpm workspace catalog returns changed external dependencies when catalog values differ', () => {
     const prev = 'packages:\n  - packages/**\n\ncatalog:\n  execa: ^8.0.0\n  fs-extra: ^11.3.0';
     const curr = 'packages:\n  - packages/**\n\ncatalog:\n  execa: ^9.5.2\n  fs-extra: ^11.3.0';
-    (readFileMock as Mock).mockReturnValueOnce(curr);
+
+    existsFileMock.mockReturnValueOnce(true);
+    readFileMock.mockReturnValueOnce(curr);
     (childProcesses.execSync as Mock).mockReturnValueOnce(prev);
 
-    const changes = diffWorkspaceCatalog('v1.0.0');
+    const changes = diffWorkspaceCatalog('v1.0.0', 'pnpm');
     expect(changes).toEqual(['execa']);
   });
 
-  test('diffWorkspaceCatalog returns changed local dependencies when catalog values differ', () => {
+  test('diff pnpm workspace catalog returns changed local dependencies when catalog values differ', () => {
     const prev = 'packages:\n  - packages/**\n\ncatalog:\n  "@mono/pkg-1": ^8.0.0\n  fs-extra: ^11.3.0';
     const curr = 'packages:\n  - packages/**\n\ncatalog:\n  "@mono/pkg-1": ^9.5.2\n  fs-extra: ^11.3.0';
-    (readFileMock as Mock).mockReturnValueOnce(curr);
+
+    existsFileMock.mockReturnValueOnce(true);
+    readFileMock.mockReturnValueOnce(curr);
     (childProcesses.execSync as Mock).mockReturnValueOnce(prev);
 
-    const changes = diffWorkspaceCatalog('v1.0.0');
+    const changes = diffWorkspaceCatalog('v1.0.0', 'pnpm');
     expect(changes).toEqual(['@mono/pkg-1']);
   });
 
-  test('diffWorkspaceCatalog returns multiple changed dependencies', () => {
+  test('diff pnpm workspace catalog returns multiple changed dependencies', () => {
     const prev = 'packages:\n  - packages/**\n\ncatalog:\n  execa: ^8.0.0\n  fs-extra: ^10.0.0';
     const curr = 'packages:\n  - packages/**\n\ncatalog:\n  execa: ^9.5.2\n  fs-extra: ^11.3.0';
-    (readFileMock as Mock).mockReturnValueOnce(curr);
+
+    existsFileMock.mockReturnValueOnce(true);
+    readFileMock.mockReturnValueOnce(curr);
     (childProcesses.execSync as Mock).mockReturnValueOnce(prev);
 
-    const changes = diffWorkspaceCatalog('v1.0.0');
+    const changes = diffWorkspaceCatalog('v1.0.0', 'pnpm');
     expect(changes.sort()).toEqual(['execa', 'fs-extra']);
   });
 
-  test('diffWorkspaceCatalog returns empty array if no changes', () => {
+  test('diff pnpm workspace catalog returns empty array if no changes', () => {
     const prev = 'packages:\n  - packages/**\n\ncatalog:\n  execa: ^9.5.2\n  fs-extra: ^11.3.0';
     const curr = prev;
-    (readFileMock as Mock).mockReturnValueOnce(curr);
+
+    existsFileMock.mockReturnValueOnce(true);
+    readFileMock.mockReturnValueOnce(curr);
     (childProcesses.execSync as Mock).mockReturnValueOnce(prev);
 
-    const changes = diffWorkspaceCatalog('v1.0.0');
+    const changes = diffWorkspaceCatalog('v1.0.0', 'pnpm');
     expect(changes).toEqual([]);
   });
 
-  test('diffWorkspaceCatalog returns empty array if catalog key is missing', () => {
+  test('diff pnpm workspace catalog returns empty array if catalog key is missing', () => {
     const prev = 'packages:\n  - packages/**\n\n';
     const curr = 'packages:\n  - packages/**\n\n';
-    (readFileMock as Mock).mockReturnValueOnce(curr);
+
+    existsFileMock.mockReturnValueOnce(true);
+    readFileMock.mockReturnValueOnce(curr);
     (childProcesses.execSync as Mock).mockReturnValueOnce(prev);
 
-    const changes = diffWorkspaceCatalog('v1.0.0');
+    const changes = diffWorkspaceCatalog('v1.0.0', 'pnpm');
     expect(changes).toEqual([]);
   });
 
-  test('diffWorkspaceCatalog returns empty array if all methods fail', () => {
-    (readFileMock as Mock).mockImplementationOnce(() => {
+  test('diff pnpm workspace catalog returns empty array if all methods fail', () => {
+    readFileMock.mockImplementationOnce(() => {
       throw new Error('fail');
     });
     (childProcesses.execSync as Mock)
@@ -270,31 +269,122 @@ describe('pnpm catalog', () => {
         throw new Error('fail');
       }); // fail git diff
 
-    const changes = diffWorkspaceCatalog('v1.0.0');
+    const changes = diffWorkspaceCatalog('v1.0.0', 'pnpm');
     expect(changes).toEqual([]);
   });
 
-  test('diffWorkspaceCatalog adds dependency from indented diff line when YAML shows no changes', () => {
+  test('diff pnpm workspace catalog adds dependency from indented diff line when YAML shows no changes', () => {
     const prev = 'packages:\n  - packages/**\n\ncatalog:\n  foo: ^1.0.0';
-    const curr = 'packages:\n  - packages/**\n\ncatalog:\n  foo: ^1.0.0';
-    (readFileMock as Mock).mockReturnValueOnce(curr);
-    (childProcesses.execSync as Mock)
-      .mockReturnValueOnce(prev) // previous YAML
-      .mockReturnValueOnce('+  bar: ^2.0.0\n'); // diff output
+    const curr = 'packages:\n  - packages/**\n\ncatalog:\n  foo: ^1.0.0\n  bar: ^2.0.0';
+    existsFileMock.mockReturnValueOnce(true);
+    readFileMock.mockReturnValueOnce(curr);
+    (childProcesses.execSync as Mock).mockReturnValueOnce(prev); // previous YAML
 
-    const changes = diffWorkspaceCatalog('v1.0.0');
+    const changes = diffWorkspaceCatalog('v1.0.0', 'pnpm');
     expect(changes).toEqual(['bar']);
   });
+});
 
-  test('diffWorkspaceCatalog adds dependency from dot notation diff line when YAML shows no changes', () => {
-    const prev = 'packages:\n  - packages/**\n\ncatalog:\n  foo: ^1.0.0';
-    const curr = 'packages:\n  - packages/**\n\ncatalog:\n  foo: ^1.0.0';
-    (readFileMock as Mock).mockReturnValueOnce(curr);
+describe('package.json workspaces catalog', () => {
+  test('diff package workspace catalogs returning dependencies that changed in the catalog', () => {
+    // make a diff on the catalog for the "execa" dependency
+    const prevFileContent = `{"name":"monorepo","workspaces":{"packages":["packages/**"],"catalogs":{"build":{"react":"^18.0.0","react-dom":"^18.0.0"}}}}`;
+    const newFileContent = `{"name":"monorepo","workspaces":{"packages":["packages/**"],"catalogs":{"build":{"react":"^19.0.1","react-dom":"^18.0.0"}}}}`;
+
+    existsFileMock.mockReturnValueOnce(true);
+    readFileMock.mockReturnValueOnce(newFileContent);
+    (childProcesses.execSync as Mock).mockReturnValueOnce(prevFileContent); // first call is to get previous catalog commit
+
+    const changes = diffWorkspaceCatalog('v1.0.0', 'bun');
+    expect(changes).toEqual(['react']);
+  });
+
+  test('diff package workspace catalog returns changed external dependencies when catalog values differ', () => {
+    const prevFileContent = `{"name":"monorepo","workspaces":{"packages":["packages/**"],"catalog":{"react":"^18.0.0","react-dom":"^18.0.0"}}}`;
+    const newFileContent = `{"name":"monorepo","workspaces":{"packages":["packages/**"],"catalog":{"react":"^19.0.1","react-dom":"^19.0.2"}}}`;
+
+    existsFileMock.mockReturnValueOnce(true);
+    readFileMock.mockReturnValueOnce(newFileContent);
+    (childProcesses.execSync as Mock).mockReturnValueOnce(prevFileContent);
+
+    const changes = diffWorkspaceCatalog('v1.0.0', 'bun');
+    expect(changes).toEqual(['react', 'react-dom']);
+  });
+
+  test('diff package workspace catalog returns changed local dependencies when catalog values differ', () => {
+    const prevFileContent = `{"name":"monorepo","workspaces":{"packages":["packages/**"],"catalog":{"@mono/pkg-1":"^8.0.0","react":"^18.0.0","react-dom":"^18.0.0"}}}`;
+    const newFileContent = `{"name":"monorepo","workspaces":{"packages":["packages/**"],"catalog":{"@mono/pkg-1":"^8.2.1","react":"^18.0.0","react-dom":"^18.0.0"}}}`;
+
+    existsFileMock.mockReturnValueOnce(true);
+    readFileMock.mockReturnValueOnce(newFileContent);
+    (childProcesses.execSync as Mock).mockReturnValueOnce(prevFileContent);
+
+    const changes = diffWorkspaceCatalog('v1.0.0', 'bun');
+    expect(changes).toEqual(['@mono/pkg-1']);
+  });
+
+  test('diff package workspace catalog returns multiple changed dependencies', () => {
+    const prevFileContent = `{"name":"monorepo","workspaces":{"packages":["packages/**"],"catalog":{"react":"^18.0.0","react-dom":"^18.0.0"}}}`;
+    const newFileContent = `{"name":"monorepo","workspaces":{"packages":["packages/**"],"catalog":{"react":"^19.0.1","react-dom":"^19.2.0"}}}`;
+
+    existsFileMock.mockReturnValueOnce(true);
+    readFileMock.mockReturnValueOnce(newFileContent);
+    (childProcesses.execSync as Mock).mockReturnValueOnce(prevFileContent);
+
+    const changes = diffWorkspaceCatalog('v1.0.0', 'bun');
+    expect(changes.sort()).toEqual(['react', 'react-dom']);
+  });
+
+  test('diff package workspace catalog returns empty array if no changes', () => {
+    const prevFileContent = `{"name":"monorepo","workspaces":{"packages":["packages/**"],"catalog":{"react":"^18.0.0","react-dom":"^18.0.0"}}}`;
+    const newFileContent = prevFileContent;
+
+    existsFileMock.mockReturnValueOnce(true);
+    readFileMock.mockReturnValueOnce(newFileContent);
+    (childProcesses.execSync as Mock).mockReturnValueOnce(prevFileContent);
+
+    const changes = diffWorkspaceCatalog('v1.0.0', 'bun');
+    expect(changes).toEqual([]);
+  });
+
+  test('diff package workspace catalog returns empty array if catalog key is missing', () => {
+    const prevFileContent = `{"name":"monorepo","workspaces":{}`;
+    const newFileContent = `{"name":"monorepo","workspaces":{}`;
+
+    existsFileMock.mockReturnValueOnce(true);
+    readFileMock.mockReturnValueOnce(newFileContent);
+    (childProcesses.execSync as Mock).mockReturnValueOnce(prevFileContent);
+
+    const changes = diffWorkspaceCatalog('v1.0.0', 'bun');
+    expect(changes).toEqual([]);
+  });
+
+  test('diff package workspace catalog returns empty array if all methods fail', () => {
+    existsFileMock.mockReturnValueOnce(true);
+    readFileMock.mockImplementationOnce(() => {
+      throw new Error('fail');
+    });
     (childProcesses.execSync as Mock)
-      .mockReturnValueOnce(prev) // previous YAML
-      .mockReturnValueOnce('+ catalog.baz: ^3.0.0\n'); // diff output
+      .mockImplementationOnce(() => {
+        throw new Error('fail');
+      }) // fail YAML parse
+      .mockImplementationOnce(() => {
+        throw new Error('fail');
+      }); // fail git diff
 
-    const changes = diffWorkspaceCatalog('v1.0.0');
-    expect(changes).toEqual(['baz']);
+    const changes = diffWorkspaceCatalog('v1.0.0', 'bun');
+    expect(changes).toEqual([]);
+  });
+
+  test('diff package workspace catalog adds dependency from indented diff line when YAML shows no changes', () => {
+    const prevFileContent = `{"name":"monorepo","workspaces":{"packages":["packages/**"],"catalog":{"foo":"^1.0.0"}}}`;
+    const newFileContent = `{"name":"monorepo","workspaces":{"packages":["packages/**"],"catalog":{"foo":"^1.0.0","bar":"^2.0.0"}}}`;
+
+    existsFileMock.mockReturnValueOnce(true);
+    readFileMock.mockReturnValueOnce(newFileContent);
+    (childProcesses.execSync as Mock).mockReturnValueOnce(prevFileContent); // previous YAML
+
+    const changes = diffWorkspaceCatalog('v1.0.0', 'bun');
+    expect(changes).toEqual(['bar']);
   });
 });