Repository: lerna-lite/lerna-lite
Pull request #975 (2025-01-30 05:58:55)

feat(publish): support pnpm `catalog:` protocol with lerna publish
## Description

Support pnpm `catalog:` protocol in `lerna publish` command (see [pnpm catalogs](https://pnpm.io/catalogs) for more info), best describe by pnpm as: 

> "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.

## Motivation and Context

pnm catalogs was brought in last Lerna-Lite release [v3.11.0](https://github.com/lerna-lite/lerna-lite/releases/tag/v3.11.0) (via PR #965) but only partially since it was targeting `lerna version` command only, and so it wasn't working for `lerna publish` and user had to rely on executing `pnpm publish` on their own in each of their packages to publish (probably via a script). This PR closes the gap and makes it work with both lerna commands (version/publish) without requiring the need to use `pnpm publish` at all (Lerna/Lerna-Lite always publish via npm even when different client are configured)

closes issues
#901
#902

## How Has This Been Tested?

I defined a default catalog within Lerna-Lite itself to first implement it and troubleshooting it. I later added all necessary unit tests to cover everything

## Types of changes

- [ ] 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:

- [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.


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/lerna-lite/lerna-lite/pull/975?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lerna-lite) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 97.84%. Comparing base [(`4584533`)](https://app.codecov.io/gh/lerna-lite/lerna-lite/commit/45845331cd147fde844a553ebdceae7569be49e3?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lerna-lite) to head [(`50e113d`)](https://app.codecov.io/gh/lerna-lite/lerna-lite/commit/50e113dd3890e8ac861150fd4ce7e286b5e63d1c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lerna-lite).
> Report is 2 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #975      +/-   ##
==========================================
+ Coverage   97.74%   97.84%   +0.11%     
==========================================
  Files         148      148              
  Lines        8655     8701      +46     
  Branches     2267     2285      +18     
==========================================
+ Hits         8459     8513      +54     
+ Misses        195      187       -8     
  Partials        1        1              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/lerna-lite/lerna-lite/pull/975?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lerna-lite).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=lerna-lite).

ghiscoding: @azu @johnsoncodehk this upcoming new feature might interest you... and Happy New Lunar Year 🎆 

Patch:
diff --git a/README.md b/README.md
index 26abfd2d..f6430d76 100644
--- a/README.md
+++ b/README.md
@@ -50,7 +50,7 @@
 
 Take 30sec. to complete this 1 question [poll survey 🔘](https://github.com/lerna-lite/lerna-lite/discussions/156) if you are using this feature. It's a simple poll to find out which package manager is the most popular with `workspace:` protocol feature (so far, about 60% pnpm and 40% yarn).
 
-Lerna-Lite itself is also using [pnpm workspaces](https://pnpm.io/workspaces) with the `workspace:` protocol 🎉
+Lerna-Lite itself is also using [pnpm catalogs](https://pnpm.io/catalogs) and [pnpm workspaces](https://pnpm.io/workspaces) with both `catalog:` and `workspace:` protocols 🎉
 
 ---
 
@@ -112,6 +112,7 @@ Below are the main reasons as to why this fork was created:
 5. Lerna-Lite added a few unique features (not available in Lerna itself):
    - [`workspace:` protocol support](https://github.com/lerna-lite/lerna-lite/tree/main/packages/version#workspace-protocol)
      * _Lerna added support for the same feature 6 months later in their v6.0 release_
+   - [`catalog:` protocol support](https://github.com/lerna-lite/lerna-lite/tree/main/packages/version#catalog-protocol) for both `version` and `publish` commands
    - [--dry-run](https://github.com/lerna-lite/lerna-lite/tree/main/packages/version#--dry-run) to preview version/publish & changelogs locally (will show git changes without committing them)
    - [lerna version --changelog-header-message "msg"](https://github.com/lerna-lite/lerna-lite/tree/main/packages/version#--changelog-header-message-msg) for showing banner or sponsors in your changelogs
    - [lerna version --changelog-include-commits-client-login](https://github.com/lerna-lite/lerna-lite/tree/main/packages/version#--changelog-include-commits-client-login-msg) to add PR contributors in GitHub releases
diff --git a/package.json b/package.json
index ebe15641..a45cbab9 100644
--- a/package.json
+++ b/package.json
@@ -87,7 +87,7 @@
     "execa": "^8.0.1",
     "file-url": "^4.0.0",
     "find-up": "^7.0.0",
-    "fs-extra": "^11.3.0",
+    "fs-extra": "catalog:",
     "load-json-file": "^7.0.1",
     "normalize-newline": "^4.1.0",
     "normalize-path": "^3.0.0",
diff --git a/packages/core/package.json b/packages/core/package.json
index 8ae3e7de..246a074f 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -45,7 +45,7 @@
     "cosmiconfig": "^9.0.0",
     "dedent": "^1.5.3",
     "execa": "^8.0.1",
-    "fs-extra": "^11.3.0",
+    "fs-extra": "catalog:",
     "glob-parent": "^6.0.2",
     "is-ci": "^4.1.0",
     "json5": "^2.2.3",
diff --git a/packages/core/src/__fixtures__/catalog-protocol/lerna.json b/packages/core/src/__fixtures__/catalog-protocol/lerna.json
new file mode 100644
index 00000000..0735fc9e
--- /dev/null
+++ b/packages/core/src/__fixtures__/catalog-protocol/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__/catalog-protocol/package.json
new file mode 100644
index 00000000..f29f0b10
--- /dev/null
+++ b/packages/core/src/__fixtures__/catalog-protocol/package.json
@@ -0,0 +1,9 @@
+{
+  "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:"
+  }
+}
\ No newline at end of file
diff --git a/packages/core/src/__fixtures__/catalog-protocol/pnpm-workspace.yaml b/packages/core/src/__fixtures__/catalog-protocol/pnpm-workspace.yaml
new file mode 100644
index 00000000..cd2bcb0c
--- /dev/null
+++ b/packages/core/src/__fixtures__/catalog-protocol/pnpm-workspace.yaml
@@ -0,0 +1,21 @@
+packages:
+  - "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'
+
+catalogs:
+  # Can be referenced through "catalog:react17"
+  react17:
+    react: ^17.0.2
+    react-dom: ^17.0.2
+
+  # Can be referenced through "catalog:react18"
+  react18:
+    react: ^18.2.0
+    react-dom: ^18.2.0
\ No newline at end of file
diff --git a/packages/core/src/models/interfaces.ts b/packages/core/src/models/interfaces.ts
index f8b1ca2a..173674ca 100644
--- a/packages/core/src/models/interfaces.ts
+++ b/packages/core/src/models/interfaces.ts
@@ -38,6 +38,7 @@ export interface ChangelogPresetOptions {
 }
 
 export type CommandType = '' | 'changed' | 'exec' | 'info' | 'init' | 'list' | 'publish' | 'run' | 'version';
+export type DependenciesType = 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies';
 
 export interface DescribeRefOptions {
   /* Defaults to `process.cwd()` */
@@ -145,7 +146,10 @@ export interface TopologicalConfig extends QueryGraphConfig {
 }
 
 export type NpaResolveResult = (npa.FileResult | npa.HostedGitResult | npa.URLResult | npa.AliasResult | npa.RegistryResult) & {
-  /** the specifier part used when deailing with a `workspace:` protocol resource */
+  /** the specifier part used when dealing with a `catalog:` protocol resource */
+  catalogSpec?: string;
+
+  /** the specifier part used when dealing with a `workspace:` protocol resource */
   workspaceSpec?: string;
 };
 
diff --git a/packages/core/src/package-graph/package-graph.ts b/packages/core/src/package-graph/package-graph.ts
index 70903a60..a21d132d 100644
--- a/packages/core/src/package-graph/package-graph.ts
+++ b/packages/core/src/package-graph/package-graph.ts
@@ -1,3 +1,4 @@
+import { log } from '@lerna-lite/npmlog';
 import fs from 'node:fs';
 import path from 'node:path';
 import npa from 'npm-package-arg';
@@ -96,25 +97,25 @@ export class PackageGraph extends Map<string, PackageGraphNode> {
 
         // handle catalog: protocol supported by pnpm
         const isCatalogSpec = /^catalog:/.test(spec);
-
+        let originalCatalogSpec: string | undefined;
         if (isCatalogSpec) {
+          originalCatalogSpec = spec;
           spec = spec.replace(/^catalog:/, '');
           const catalogVersion = spec === '' || spec === 'default' ? catalog[depName] : catalogs[spec]?.[depName];
 
           if (catalogVersion) {
             spec = catalogVersion;
           } else {
-            console.warn(`Warning: No version found in ${spec || 'default'} catalog for ${depName}`);
+            log.warn('graph', `No version found in "${spec || 'default'}" catalog for "${depName}"`);
           }
         }
 
         // npa doesn't support the explicit workspace: protocol, supported by
         // pnpm and Yarn.
         const isWorkspaceSpec = /^workspace:/.test(spec);
-
-        let fullWorkspaceSpec: string | undefined;
+        let originalWorkspaceSpec: string | undefined;
         if (isWorkspaceSpec) {
-          fullWorkspaceSpec = spec;
+          originalWorkspaceSpec = spec;
           spec = spec.replace(/^workspace:/, '');
 
           // when dependency is defined as target workspace, like `workspace:*`,
@@ -129,7 +130,8 @@ export class PackageGraph extends Map<string, PackageGraphNode> {
         }
 
         const resolved: NpaResolveResult = npa.resolve(depName, spec, currentNode.location);
-        resolved.workspaceSpec = fullWorkspaceSpec;
+        resolved.catalogSpec = originalCatalogSpec;
+        resolved.workspaceSpec = originalWorkspaceSpec;
 
         if (!depNode) {
           // it's an external dependency, store the resolution and bail
@@ -137,12 +139,13 @@ export class PackageGraph extends Map<string, PackageGraphNode> {
         }
 
         if (
+          isCatalogSpec ||
           isWorkspaceSpec ||
           localDependencies === 'force' ||
           resolved.fetchSpec === depNode.location ||
           (localDependencies !== 'explicit' && depNode.satisfies(resolved))
         ) {
-          // a local file: specifier, a matching semver or a workspace: version
+          // could be a local `file:` specifier, a matching semver, a `catalog:` or a `workspace:` protocols
           currentNode.localDependencies.set(depName, resolved);
           depNode.localDependents.set(currentName, currentNode);
         } else {
diff --git a/packages/core/src/package.ts b/packages/core/src/package.ts
index 0f45c070..abdb2c64 100644
--- a/packages/core/src/package.ts
+++ b/packages/core/src/package.ts
@@ -4,7 +4,7 @@ import { basename, dirname, join, resolve as pathResolve, relative } from 'node:
 import { writePackage } from 'write-package';
 import { log } from '@lerna-lite/npmlog';
 
-import { CommandType, NpaResolveResult, RawManifest } from './models/interfaces.js';
+import { CommandType, DependenciesType, NpaResolveResult, RawManifest } from './models/interfaces.js';
 
 // symbol used to 'hide' internal state
 const PKG = Symbol('pkg');
@@ -254,15 +254,19 @@ export class Package {
   }
 
   /**
-   * Mutate given dependency (could be local/external) spec according to type
+   * Remove any given dependencies collections which contains a `workspace:` spec is found and replace them with their resolved fetchSpec
    * @param {String} pkgName - package name
    * @param {Object} resolved npa metadata
    */
   removeDependencyWorkspaceProtocolPrefix(pkgName: string, resolved: NpaResolveResult) {
     const depName = resolved.name as string;
     const workspaceSpec = resolved?.workspaceSpec ?? '';
-    const localDependencies = this.retrievePackageDependencies(depName);
-    const inspectDependencies = [localDependencies];
+    const inspectDependencies = this.retrieveAllDependenciesWithName(depName, [
+      'dependencies',
+      'devDependencies',
+      'optionalDependencies',
+      'peerDependencies',
+    ]);
 
     // package could be found in both a local dependencies and peerDependencies, so we need to include it when found
     if (this.peerDependencies?.[depName]) {
@@ -271,19 +275,40 @@ export class Package {
 
     for (const depCollection of inspectDependencies) {
       if (depCollection && (resolved.registry || resolved.type === 'directory') && /^(workspace:)+(.*)$/.test(workspaceSpec)) {
-        if (workspaceSpec) {
-          if (resolved.fetchSpec === 'latest' || resolved.fetchSpec === '') {
-            log.error(
-              `publish`,
-              [
-                `Your package named "${pkgName}" has external dependencies not handled by Lerna-Lite and without workspace version suffix, `,
-                `we recommend using defined versions with workspace protocol. `,
-                `Your dependency is currently being published with "${depName}": "${resolved.fetchSpec}".`,
-              ].join('')
-            );
-          }
-          depCollection[depName] = resolved.fetchSpec as string;
+        if (workspaceSpec && (resolved.fetchSpec === 'latest' || resolved.fetchSpec === '')) {
+          log.error(
+            `publish`,
+            [
+              `Your package named "${pkgName}" has external dependencies not handled by Lerna-Lite and without workspace version suffix, `,
+              `we recommend using defined versions with "workspace:" protocol. `,
+              `Your dependency is currently being published with "${depName}": "${resolved.fetchSpec}".`,
+            ].join('')
+          );
         }
+        depCollection[depName] = resolved.fetchSpec as string;
+      }
+    }
+  }
+
+  /**
+   * Mutate given dependency and anywhere a `catalog:` spec is found, we'll replace them with their resolved fetchSpec coming from the global catalog version
+   * @param {Object} resolved npa metadata
+   */
+  updateDependencyCatalogProtocol(resolved: NpaResolveResult) {
+    // find all dependencies collections that could have `catalog:` protocol
+    // most `catalog:` refs will be found in externalDependencies, but we need to check and replace all types
+    const depName = resolved.name as string;
+    const inspectDependencies = this.retrieveAllDependenciesWithName(depName, [
+      'dependencies',
+      'devDependencies',
+      'optionalDependencies',
+      'peerDependencies',
+    ]);
+
+    // loop through all dependencies collections and replace any `catalog:` protocol with the resolved fetchSpec (from global catalog)
+    for (const depCollection of inspectDependencies) {
+      if (depCollection && (resolved.registry || resolved.type === 'directory') && resolved?.catalogSpec) {
+        depCollection[depName] = resolved.fetchSpec as string;
       }
     }
   }
@@ -303,7 +328,7 @@ export class Package {
     updatedByCommand?: CommandType
   ) {
     const depName = resolved.name as string;
-    const localDependencies = this.retrievePackageDependencies(depName);
+    const localDependencies = this.retrieveDependenciesWithName(depName);
     const updatingDependencies = [localDependencies];
 
     // peer dependencies will not be bumped by default unless we use allowPeerDependenciesUpdate
@@ -393,11 +418,29 @@ export class Package {
   }
 
   /**
-   * Retrieve the dependencies collection which contain the dependency name provided, we'll search in all type of dependencies/devDependencies/...
+   * Retrieve all dependencies collections in which the dependency name was found
+   * @param {String} depName - dependency name
+   * @param {Array<String>} dependenciesTypes - array of dependencies types to search in
+   * @returns {Array<Object>} - array of dependencies that contains the dependency name provided
+   */
+  retrieveAllDependenciesWithName(depName: string, dependenciesTypes: DependenciesType[]) {
+    const inspectDependencies: Array<{ [depName: string]: string }> = [];
+
+    dependenciesTypes.forEach((depType) => {
+      if (this[depType]?.[depName]) {
+        inspectDependencies.push(this[depType]);
+      }
+    });
+
+    return inspectDependencies;
+  }
+
+  /**
+   * Retrieve the first dependencies collection which includes the dependency name inside it, we'll search in all type of dependencies/devDependencies/...
    * @param {String} depName - dependency name
    * @returns {Array<String>} - array of dependencies that contains the dependency name provided
    */
-  retrievePackageDependencies(depName: string): { [depName: string]: string } {
+  retrieveDependenciesWithName(depName: string): { [depName: string]: string } {
     // first, try runtime dependencies
     let depCollection = this.dependencies;
 
diff --git a/packages/diff/package.json b/packages/diff/package.json
index db801e0c..9d4adb4d 100644
--- a/packages/diff/package.json
+++ b/packages/diff/package.json
@@ -42,7 +42,7 @@
   "devDependencies": {
     "@types/fs-extra": "^11.0.4",
     "execa": "^8.0.1",
-    "fs-extra": "^11.3.0",
+    "fs-extra": "catalog:",
     "yargs-parser": "^21.1.1"
   }
 }
diff --git a/packages/exec/package.json b/packages/exec/package.json
index 5fb18b4c..67039fa3 100644
--- a/packages/exec/package.json
+++ b/packages/exec/package.json
@@ -44,7 +44,7 @@
   },
   "devDependencies": {
     "@types/fs-extra": "^11.0.4",
-    "fs-extra": "^11.3.0",
+    "fs-extra": "catalog:",
     "tinyglobby": "^0.2.10",
     "yargs": "^17.7.2",
     "yargs-parser": "^21.1.1"
diff --git a/packages/init/package.json b/packages/init/package.json
index 038cf442..413ecf95 100644
--- a/packages/init/package.json
+++ b/packages/init/package.json
@@ -36,7 +36,7 @@
   },
   "dependencies": {
     "@lerna-lite/core": "workspace:*",
-    "fs-extra": "^11.3.0",
+    "fs-extra": "catalog:",
     "p-map": "^7.0.3",
     "write-json-file": "^6.0.0"
   },
diff --git a/packages/profiler/package.json b/packages/profiler/package.json
index f74b04a6..29ca8a5f 100644
--- a/packages/profiler/package.json
+++ b/packages/profiler/package.json
@@ -37,7 +37,7 @@
   "dependencies": {
     "@lerna-lite/core": "workspace:*",
     "@lerna-lite/npmlog": "workspace:*",
-    "fs-extra": "^11.3.0",
+    "fs-extra": "catalog:",
     "upath": "^2.0.1"
   },
   "devDependencies": {
diff --git a/packages/publish/README.md b/packages/publish/README.md
index b37430a2..16a03085 100644
--- a/packages/publish/README.md
+++ b/packages/publish/README.md
@@ -579,7 +579,66 @@ 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 kept as is. If you need to bump the version of a package in a catalog, you will need to edit `pnpm-workspace.yaml` manually. So we suggest using `workspace:` protocol instead for workspace dependencies.
+The `catalog:` protocol ([pnpm catalog](https://pnpm.io/catalogs)) can be recognized by Lerna-Lite. When publishing, they will be replaced as is and use 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 suggest using [`workspace:`](#workspace-protocol) protocol instead for any workspace dependencies.
+
+So for example, if our `pnpm-workspace.yaml` file has the following configuration
+
+```yaml
+packages:
+  - packages/*
+
+# Define a catalog of version ranges.
+catalog:
+  fs-extra: '^11.2.0'
+  typescript: '^5.7.0'
+
+# named catalogs are also supported
+catalogs:
+  # Can be referenced through "catalog:react17"
+  react17:
+    react: ^17.0.0
+    react-dom: ^17.0.0
+  # Can be referenced through "catalog:react18"
+  react18:
+    react: ^18.2.0
+    react-dom: ^18.2.0
+```
+
+and then one of our package with the following dependencies defined in our `package.json`
+
+```json
+{
+  "dependencies": {
+    "fs-extra": "catalog:"
+  },
+  "devDependencies": {
+    "typescript": "catalog:"
+  },
+  "peerDependencies": {
+    "react": "catalog:react18",
+    "react-dom": "catalog:react18"
+  }
+}
+```
+
+#### versions that will be published
+
+Lerna-Lite will replace all `catalog:` protocol with the version ranges defined in the global catalog(s) and publish the following:
+
+```json
+{
+  "dependencies": {
+    "fs-extra": "^11.2.0"
+  },
+  "devDependencies": {
+    "typescript": "^5.7.0"
+  },
+  "peerDependencies": {
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
+  }
+}
+```
 
 # `workspace:` protocol
 
diff --git a/packages/publish/package.json b/packages/publish/package.json
index 9ba751f1..6f1eb3fb 100644
--- a/packages/publish/package.json
+++ b/packages/publish/package.json
@@ -43,7 +43,7 @@
     "@npmcli/package-json": "^5.2.1",
     "byte-size": "^9.0.1",
     "columnify": "^1.6.0",
-    "fs-extra": "^11.3.0",
+    "fs-extra": "catalog:",
     "has-unicode": "^2.0.1",
     "libnpmaccess": "^8.0.6",
     "libnpmpublish": "^9.0.9",
diff --git a/packages/publish/src/publish-command.ts b/packages/publish/src/publish-command.ts
index 78fe5571..80fca8ec 100644
--- a/packages/publish/src/publish-command.ts
+++ b/packages/publish/src/publish-command.ts
@@ -271,6 +271,7 @@ export class PublishCommand extends Command<PublishCommandOption> {
     }
 
     await this.resolveLocalDependencyLinks();
+    await this.resolveDependencyWithCatalogProtocols();
     await this.resolveLocalDependencyWorkspaceProtocols();
 
     if (this.options.removePackageFields) {
@@ -665,6 +666,33 @@ export class PublishCommand extends Command<PublishCommandOption> {
     });
   }
 
+  // resolve `catalog:` protocol from both local/external dependencies and translates them to their actual version target/range
+  resolveDependencyWithCatalogProtocols() {
+    // detect if any of the packages to publish have `catalog:` protocol in their dependencies
+    const publishingPackagesWithCatalogs = this.updates.filter(
+      (node: PackageGraphNode) =>
+        Array.from<NpaResolveResult>(node.externalDependencies.values()).some((resolved) => resolved.catalogSpec) ||
+        Array.from<NpaResolveResult>(node.localDependencies.values()).some((resolved) => resolved.catalogSpec)
+    );
+
+    return pMap(publishingPackagesWithCatalogs, (node: PackageGraphNode) => {
+      // regardless of where the version comes from, we can't publish 'catalog:' specs, it has to be transformed for any dependencies
+      // e.g. considering version is `^1.2.3` and we have a global `catalog:` it will be converted to version "^1.2.3" with strict match
+
+      // update catalog version with global catalog version of external dependencies
+      const externalDeps = Array.from<NpaResolveResult>(node.externalDependencies.values()).filter((node) => node.catalogSpec);
+      const localDeps = Array.from<NpaResolveResult>(node.localDependencies.values()).filter((node) => node.catalogSpec);
+      for (const deps of [externalDeps, localDeps]) {
+        for (const resolved of deps) {
+          // it no longer matters if we mutate the shared Package instance
+          node.pkg.updateDependencyCatalogProtocol(resolved);
+        }
+      }
+
+      // writing changes to disk handled in serializeChanges()
+    });
+  }
+
   resolveLocalDependencyWorkspaceProtocols() {
     // resolve workspace protocol: translates to their actual version target/range
     const publishingPackagesWithLocalWorkspaces = this.updates.filter((node: PackageGraphNode) =>
diff --git a/packages/run/package.json b/packages/run/package.json
index 7382c48f..52507ac9 100644
--- a/packages/run/package.json
+++ b/packages/run/package.json
@@ -39,7 +39,7 @@
     "@lerna-lite/core": "workspace:*",
     "@lerna-lite/npmlog": "workspace:*",
     "@lerna-lite/profiler": "workspace:*",
-    "fs-extra": "^11.3.0",
+    "fs-extra": "catalog:",
     "p-map": "^7.0.3",
     "tinyrainbow": "^2.0.0"
   },
diff --git a/packages/version/README.md b/packages/version/README.md
index e7dfe11b..0f18c9bb 100644
--- a/packages/version/README.md
+++ b/packages/version/README.md
@@ -126,6 +126,7 @@ $ lerna version --scope my-component test
   - [Tips](#tips)
     - [Generating Initial Changelogs](#generating-initial-changelogs)
   - [Lifecycle Scripts](#lifecycle-scripts)
+  - [`catalog:` protocol](#catalog-protocol)
   - [`workspace:` protocol](#workspace-protocol)
 
 ### `--allow-branch <glob>`
@@ -918,6 +919,16 @@ lerna will run [npm lifecycle scripts](https://docs.npmjs.com/cli/v8/using-npm/s
 9. Push commit and tag(s) to remote, if [enabled](#--no-push)
 10. Create release, if [enabled](#--create-release-type)
 
+# `catalog:` protocol
+
+The pnpm `catalog:` protocol ([pnpm catalogs](https://pnpm.io/catalogs) only available in pnpm) 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 versions defined in your `pnpm-workspace.yaml` global catalog(s).
+
+For more info take a look at the [`lerna publish#catalog`](https://github.com/lerna-lite/lerna-lite/blob/main/packages/publish/README.md#catalog-protocol)
+
 # `workspace:` protocol
 
 The `workspace:` protocol ([pnpm workspace](https://pnpm.io/workspaces), [yarn workspace](https://yarnpkg.com/features/workspaces#workspace-ranges-workspace)) is also supported by Lerna-Lite. We also strongly suggest that you use this in combo with the new [`--sync-workspace-lock`](#--sync-workspace-lock) flag to properly update your root project lock file. When versioning `workspace:` dependencies, it will do the following:
diff --git a/packages/version/package.json b/packages/version/package.json
index 935470d8..68354fa8 100644
--- a/packages/version/package.json
+++ b/packages/version/package.json
@@ -46,7 +46,7 @@
     "conventional-commits-parser": "^5.0.0",
     "conventional-recommended-bump": "^9.0.0",
     "dedent": "^1.5.3",
-    "fs-extra": "^11.3.0",
+    "fs-extra": "catalog:",
     "get-stream": "^9.0.1",
     "git-url-parse": "^16.0.0",
     "graceful-fs": "^4.2.11",
@@ -84,7 +84,7 @@
     "@types/pify": "^5.0.4",
     "@types/semver": "^7.5.8",
     "execa": "^8.0.1",
-    "fs-extra": "^11.3.0",
+    "fs-extra": "catalog:",
     "js-yaml": "^4.1.0",
     "write-package": "^7.1.0",
     "yargs": "^17.7.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bf5c6548..f6259acc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4,6 +4,12 @@ settings:
   autoInstallPeers: true
   excludeLinksFromLockfile: false
 
+catalogs:
+  default:
+    fs-extra:
+      specifier: ^11.2.0
+      version: 11.3.0
+
 importers:
 
   .:
@@ -90,7 +96,7 @@ importers:
         specifier: ^7.0.0
         version: 7.0.0
       fs-extra:
-        specifier: ^11.3.0
+        specifier: 'catalog:'
         version: 11.3.0
       load-json-file:
         specifier: ^7.0.1
@@ -227,7 +233,7 @@ importers:
         specifier: ^8.0.1
         version: 8.0.1
       fs-extra:
-        specifier: ^11.3.0
+        specifier: 'catalog:'
         version: 11.3.0
       glob-parent:
         specifier: ^6.0.2
@@ -346,7 +352,7 @@ importers:
         specifier: ^8.0.1
         version: 8.0.1
       fs-extra:
-        specifier: ^11.3.0
+        specifier: 'catalog:'
         version: 11.3.0
       yargs-parser:
         specifier: ^21.1.1
@@ -377,7 +383,7 @@ importers:
         specifier: ^11.0.4
         version: 11.0.4
       fs-extra:
-        specifier: ^11.3.0
+        specifier: 'catalog:'
         version: 11.3.0
       tinyglobby:
         specifier: ^0.2.10
@@ -395,7 +401,7 @@ importers:
         specifier: workspace:*
         version: link:../core
       fs-extra:
-        specifier: ^11.3.0
+        specifier: 'catalog:'
         version: 11.3.0
       p-map:
         specifier: ^7.0.3
@@ -480,7 +486,7 @@ importers:
         specifier: workspace:*
         version: link:../npmlog
       fs-extra:
-        specifier: ^11.3.0
+        specifier: 'catalog:'
         version: 11.3.0
       upath:
         specifier: ^2.0.1
@@ -520,7 +526,7 @@ importers:
         specifier: ^1.6.0
         version: 1.6.0
       fs-extra:
-        specifier: ^11.3.0
+        specifier: 'catalog:'
         version: 11.3.0
       has-unicode:
         specifier: ^2.0.1
@@ -605,7 +611,7 @@ importers:
         specifier: workspace:*
         version: link:../profiler
       fs-extra:
-        specifier: ^11.3.0
+        specifier: 'catalog:'
         version: 11.3.0
       p-map:
         specifier: ^7.0.3
@@ -666,7 +672,7 @@ importers:
         specifier: ^1.5.3
         version: 1.5.3
       fs-extra:
-        specifier: ^11.3.0
+        specifier: 'catalog:'
         version: 11.3.0
       get-stream:
         specifier: ^9.0.1
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index bcd587b9..89ebb830 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -4,4 +4,8 @@ packages:
 
   # exclude packages that are inside test directories
   - '!**/__fixtures__/**'
-  - '!**/__test__/**'
\ No newline at end of file
+  - '!**/__test__/**'
+
+# Define a catalog of version ranges.
+catalog:
+  fs-extra: '^11.2.0'
\ No newline at end of file


Test patch:
diff --git a/packages/core/src/__tests__/package.spec.ts b/packages/core/src/__tests__/package.spec.ts
index 451319f7..1cd524cd 100644
--- a/packages/core/src/__tests__/package.spec.ts
+++ b/packages/core/src/__tests__/package.spec.ts
@@ -13,7 +13,7 @@ vi.mock('write-package');
 
 // file under test
 import { Package } from '../package.js';
-import { NpaResolveResult, RawManifest } from '../models/index.js';
+import { NpaResolveResult, RawManifest } from '../models/interfaces.js';
 
 const __filename = fileURLToPath(import.meta.url);
 const __dirname = dirname(__filename);
@@ -868,7 +868,7 @@ describe('Package', () => {
           'publish',
           [
             `Your package named "package-1" has external dependencies not handled by Lerna-Lite and without workspace version suffix, `,
-            `we recommend using defined versions with workspace protocol. Your dependency is currently being published with "a": "latest".`,
+            `we recommend using defined versions with "workspace:" protocol. Your dependency is currently being published with "a": "latest".`,
           ].join('')
         );
         expect(pkg.toJSON()).toMatchInlineSnapshot(`
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 3b3986e0..ff4331cb 100644
--- a/packages/core/src/package-graph/__tests__/package-graph.spec.ts
+++ b/packages/core/src/package-graph/__tests__/package-graph.spec.ts
@@ -1,13 +1,32 @@
-import { describe, expect, it } from 'vitest';
+import { log } from '@lerna-lite/npmlog';
+import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
 
-import { RawManifest } from '../../models/index.js';
+import { RawManifest } from '../../models/interfaces.js';
 import { Package } from '../../package.js';
 import { PackageGraphNode } from '../lib/package-graph-node.js';
 
 // file under test
 import { PackageGraph } from '../package-graph.js';
 
+// helpers
+import { initFixtureFactory } from '@lerna-test/helpers';
+const initFixture = initFixtureFactory(__dirname);
+
 describe('PackageGraph', () => {
+  let testDir = '';
+
+  beforeAll(async () => {
+    testDir = await initFixture('catalog-protocol');
+  });
+
+  afterEach(() => {
+    // ensure common CWD is restored when individual tests
+    // initialize their own fixture (which changes CWD)
+    if (process.cwd() !== testDir) {
+      process.chdir(testDir);
+    }
+  });
+
   describe('constructor', () => {
     it('throws an error when duplicate package names are present', () => {
       const pkgs = [
@@ -19,6 +38,31 @@ 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(
@@ -89,6 +133,69 @@ describe('PackageGraph', () => {
       expect(pkg2.localDependencies.has('pkg-1')).toBe(true);
     });
 
+    it('only localizes catalog: siblings when it must be explicit', () => {
+      const logWarnSpy = vi.spyOn(log, 'warn');
+      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': 'catalog:',
+              react: 'catalog:react18',
+            },
+            peerDependencies: {
+              'pkg-1': 'catalog:',
+            },
+          } as unknown as RawManifest,
+          '/test/pkg-3'
+        ),
+        new Package(
+          {
+            name: 'pkg-4',
+            version: '1.0.0',
+            dependencies: {
+              'pkg-2': 'catalog:',
+            },
+          } as unknown as RawManifest,
+          '/test/pkg-4'
+        ),
+      ];
+
+      const graph = new PackageGraph(pkgs, 'allDependencies', 'explicit');
+      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(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"');
+    });
+
     it('only localizes workspace: siblings when it must be explicit', () => {
       const pkgs = [
         new Package(
diff --git a/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/lerna.json b/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/lerna.json
new file mode 100644
index 00000000..0735fc9e
--- /dev/null
+++ b/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/lerna.json
@@ -0,0 +1,4 @@
+{
+  "version": "1.0.0",
+  "npmClient": "pnpm"
+}
diff --git a/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/package.json b/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/package.json
new file mode 100644
index 00000000..c45be4a8
--- /dev/null
+++ b/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/package.json
@@ -0,0 +1,9 @@
+{
+  "name": "catalog-protocol-specs",
+  "description": "catalog protocol specifiers should act just like recognized semver ranges",
+  "private": true,
+  "version": "0.0.0-lerna",
+  "dependencies": {
+    "package-2": "catalog:"
+  }
+}
\ No newline at end of file
diff --git a/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/packages/package-1/package.json b/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/packages/package-1/package.json
new file mode 100644
index 00000000..6a38e57b
--- /dev/null
+++ b/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/packages/package-1/package.json
@@ -0,0 +1,9 @@
+{
+  "name": "package-1",
+  "version": "2.3.4",
+  "dependencies": {
+    "fs-extra": "catalog:",
+    "tinyrainbow": "catalog:",
+    "tiny-tarball": "^1.0.0"
+  }
+}
\ No newline at end of file
diff --git a/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/packages/package-2/package.json b/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/packages/package-2/package.json
new file mode 100644
index 00000000..12c4dbe3
--- /dev/null
+++ b/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/packages/package-2/package.json
@@ -0,0 +1,14 @@
+{
+  "name": "package-2",
+  "version": "2.3.4",
+  "dependencies": {
+    "package-1": "catalog:",
+    "tinyrainbow": "catalog:"
+  },
+  "peerDependencies": {
+    "package-1": "catalog:",
+    "react": "catalog:react18",
+    "react-dom": "catalog:react18",
+    "tinyrainbow": "catalog:"
+  }
+}
\ No newline at end of file
diff --git a/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/packages/package-3/package.json b/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/packages/package-3/package.json
new file mode 100644
index 00000000..b2edeafe
--- /dev/null
+++ b/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/packages/package-3/package.json
@@ -0,0 +1,9 @@
+{
+  "name": "package-3",
+  "version": "2.3.4",
+  "dependencies": {
+    "fs-extra": "catalog:",
+    "p-map": "catalog:",
+    "tinyrainbow": "catalog:"
+  }
+}
diff --git a/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/packages/package-4/package.json b/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/packages/package-4/package.json
new file mode 100644
index 00000000..4580b2c0
--- /dev/null
+++ b/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/packages/package-4/package.json
@@ -0,0 +1,13 @@
+{
+  "name": "package-4",
+  "version": "2.3.4",
+  "dependencies": {
+    "fs-extra": "catalog:",
+    "p-map": "catalog:"
+  },
+  "peerDependencies": {
+    "fs-extra": "^11.2.0",
+    "react": "catalog:react17",
+    "react-dom": "catalog:react17"
+  }
+}
diff --git a/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/packages/package-5/package.json b/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/packages/package-5/package.json
new file mode 100644
index 00000000..202b8163
--- /dev/null
+++ b/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/packages/package-5/package.json
@@ -0,0 +1,10 @@
+{
+  "name": "package-5",
+  "version": "1.0.0",
+  "dependencies": {
+    "tinyrainbow": "catalog:"
+  },
+  "peerDependencies": {
+    "tinyrainbow": "catalog:"
+  }
+}
\ No newline at end of file
diff --git a/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/pnpm-workspace.yaml b/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/pnpm-workspace.yaml
new file mode 100644
index 00000000..a2c3a6e1
--- /dev/null
+++ b/packages/publish/src/__tests__/__fixtures__/catalog-protocol-specs/pnpm-workspace.yaml
@@ -0,0 +1,20 @@
+packages:
+  - "packages/*"
+
+# Define a catalog of version ranges.
+catalog:
+  package-1: '2.3.4'
+  fs-extra: '^11.2.0'
+  p-map: '^7.0.3'
+  tinyrainbow: '^2.0.0'
+
+catalogs:
+  # Can be referenced through "catalog:react17"
+  react17:
+    react: ^17.0.2
+    react-dom: ^17.0.2
+
+  # Can be referenced through "catalog:react18"
+  react18:
+    react: ^18.2.0
+    react-dom: ^18.2.0
\ No newline at end of file
diff --git a/packages/publish/src/__tests__/publish-catalog-protocol-specifiers.spec.ts b/packages/publish/src/__tests__/publish-catalog-protocol-specifiers.spec.ts
new file mode 100644
index 00000000..9e04814a
--- /dev/null
+++ b/packages/publish/src/__tests__/publish-catalog-protocol-specifiers.spec.ts
@@ -0,0 +1,128 @@
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('write-package', async () => await vi.importActual('../../../version/src/lib/__mocks__/write-package'));
+
+// FIXME: better mock for version command
+vi.mock('../../../version/src/lib/git-push', async () => await vi.importActual<any>('../../../version/src/lib/__mocks__/git-push'));
+vi.mock('../../../version/src/lib/is-anything-committed', async () => await vi.importActual<any>('../../../version/src/lib/__mocks__/is-anything-committed'));
+vi.mock('../../../version/src/lib/is-behind-upstream', async () => await vi.importActual<any>('../../../version/src/lib/__mocks__/is-behind-upstream'));
+vi.mock('../../../version/src/lib/remote-branch-exists', async () => await vi.importActual<any>('../../../version/src/lib/__mocks__/remote-branch-exists'));
+
+// mocked modules of @lerna-lite/core
+vi.mock('@lerna-lite/core', async () => ({
+  ...(await vi.importActual<any>('@lerna-lite/core')), // return the other real methods, below we'll mock only 2 of the methods
+  Command: (await vi.importActual<any>('../../../core/src/command')).Command,
+  conf: (await vi.importActual<any>('../../../core/src/command')).conf,
+  collectUpdates: (await vi.importActual<any>('../../../core/src/__mocks__/collect-updates')).collectUpdates,
+  throwIfUncommitted: (await vi.importActual<any>('../../../core/src/__mocks__/check-working-tree')).throwIfUncommitted,
+  getOneTimePassword: () => Promise.resolve('654321'),
+  logOutput: (await vi.importActual<any>('../../../core/src/__mocks__/output')).logOutput,
+  promptConfirmation: (await vi.importActual<any>('../../../core/src/__mocks__/prompt')).promptConfirmation,
+  promptSelectOne: (await vi.importActual<any>('../../../core/src/__mocks__/prompt')).promptSelectOne,
+  promptTextInput: (await vi.importActual<any>('../../../core/src/__mocks__/prompt')).promptTextInput,
+}));
+
+// also point to the local publish command so that all mocks are properly used even by the command-runner
+vi.mock('@lerna-lite/publish', async () => await vi.importActual<any>('../publish-command'));
+vi.mock('@lerna-lite/version', async () => await vi.importActual('../../../version/src/version-command'));
+
+// local modules _must_ be explicitly mocked
+vi.mock('../lib/get-packages-without-license', async () => await vi.importActual<any>('../lib/__mocks__/get-packages-without-license'));
+vi.mock('../lib/verify-npm-package-access', async () => await vi.importActual<any>('../lib/__mocks__/verify-npm-package-access'));
+vi.mock('../lib/get-npm-username', async () => await vi.importActual<any>('../lib/__mocks__/get-npm-username'));
+vi.mock('../lib/get-two-factor-auth-required', async () => await vi.importActual<any>('../lib/__mocks__/get-two-factor-auth-required'));
+vi.mock('../lib/pack-directory', async () => await vi.importActual<any>('../lib/__mocks__/pack-directory'));
+vi.mock('../lib/npm-publish', async () => await vi.importActual<any>('../lib/__mocks__/npm-publish'));
+
+import { outputFile } from 'fs-extra/esm';
+import { dirname, join } from 'node:path';
+
+// mocked modules
+import * as writePkg from 'write-package';
+
+// helpers
+import { fileURLToPath } from 'node:url';
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+import { gitAdd } from '@lerna-test/helpers';
+import { gitTag } from '@lerna-test/helpers';
+import { gitCommit } from '@lerna-test/helpers';
+import { initFixtureFactory } from '@lerna-test/helpers';
+const initFixture = initFixtureFactory(__dirname);
+
+// test command
+import { PublishCommand } from '../index.js';
+
+import yargParser from 'yargs-parser';
+import { PublishCommandOption } from '@lerna-lite/core';
+
+const createArgv = (cwd, ...args) => {
+  args.unshift('publish');
+  if (args.length > 0 && args[1]?.length > 0 && !args[1].startsWith('-')) {
+    args[1] = `--bump=${args[1]}`;
+  }
+  const parserArgs = args.join(' ');
+  const argv = yargParser(parserArgs);
+  argv['$0'] = cwd;
+  return argv as unknown as PublishCommandOption;
+};
+
+describe("catalog protocol 'catalog:' specifiers", () => {
+  const setupChanges = async (cwd, pkgRoot = 'packages') => {
+    await outputFile(join(cwd, `${pkgRoot}/package-1/hello.js`), 'world');
+    await gitAdd(cwd, '.');
+    await gitCommit(cwd, 'setup');
+  };
+
+  it('overwrites catalog protocol with local major bumped version before npm publish but after git commit', async () => {
+    const cwd = await initFixture('catalog-protocol-specs');
+
+    await gitTag(cwd, 'v1.0.0');
+    await setupChanges(cwd);
+    await new PublishCommand(createArgv(cwd, '--bump', 'major', '--yes'));
+
+    expect((writePkg as any).updatedVersions()).toEqual({
+      'package-1': '2.0.0',
+      'package-2': '2.0.0',
+      'package-3': '2.0.0',
+      'package-4': '2.0.0',
+      'package-5': '2.0.0',
+    });
+
+    expect((writePkg as any).updatedManifest('package-1').dependencies).toMatchObject({
+      'fs-extra': '^11.2.0', // catalog:
+      tinyrainbow: '^2.0.0', // catalog:
+      'tiny-tarball': '^1.0.0',
+    });
+    expect((writePkg as any).updatedManifest('package-2').dependencies).toMatchObject({
+      'package-1': '2.3.4', // catalog:
+      tinyrainbow: '^2.0.0',
+    });
+    expect((writePkg as any).updatedManifest('package-2').peerDependencies).toMatchObject({
+      'package-1': '2.3.4', // catalog:
+      react: '^18.2.0',
+      'react-dom': '^18.2.0',
+      tinyrainbow: '^2.0.0',
+    });
+    expect((writePkg as any).updatedManifest('package-3').dependencies).toMatchObject({
+      'fs-extra': '^11.2.0', // catalog:
+      'p-map': '^7.0.3', // catalog:
+      tinyrainbow: '^2.0.0',
+    });
+    expect((writePkg as any).updatedManifest('package-4').dependencies).toMatchObject({
+      'fs-extra': '^11.2.0', // catalog:
+      'p-map': '^7.0.3', // catalog:
+    });
+    expect((writePkg as any).updatedManifest('package-4').peerDependencies).toMatchObject({
+      'fs-extra': '^11.2.0',
+      react: '^17.0.2',
+      'react-dom': '^17.0.2',
+    });
+    expect((writePkg as any).updatedManifest('package-5').dependencies).toMatchObject({
+      tinyrainbow: '^2.0.0', // catalog:
+    });
+    expect((writePkg as any).updatedManifest('package-5').peerDependencies).toMatchObject({
+      tinyrainbow: '^2.0.0', // catalog:
+    });
+  });
+});
diff --git a/packages/publish/src/__tests__/publish-workspace-protocol-specifiers.spec.ts b/packages/publish/src/__tests__/publish-workspace-protocol-specifiers.spec.ts
index 6cc63693..334919ba 100644
--- a/packages/publish/src/__tests__/publish-workspace-protocol-specifiers.spec.ts
+++ b/packages/publish/src/__tests__/publish-workspace-protocol-specifiers.spec.ts
@@ -204,7 +204,7 @@ describe("workspace protocol 'workspace:' specifiers", () => {
       'publish',
       [
         `Your package named "package-6" has external dependencies not handled by Lerna-Lite and without workspace version suffix, `,
-        `we recommend using defined versions with workspace protocol. Your dependency is currently being published with "tiny-registry": "".`,
+        `we recommend using defined versions with "workspace:" protocol. Your dependency is currently being published with "tiny-registry": "".`,
       ].join('')
     );
     expect((writePkg as any).updatedManifest('package-6').dependencies).toMatchObject({