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

Issue:
Logger broken, after last release
### Describe the bug

Hi

in previos version when run command at root of project, the package name exists in prefix of logger, but now, the `[object Object]` exists!

### Expectation

_No response_

### Reproduction

Update lerna to latest and see

### Lerna config and logs

<details><summary>lerna.json</summary><p>
<!-- browsers demand the next line be empty -->

```json
<!-- Please paste your `lerna.json` here -->
```
</p></details>

<details><summary>lerna-debug.log</summary><p>
<!-- browsers demand the next line be empty -->

```txt
<!-- If you have a `lerna-debug.log` available, please paste it here -->
<!-- Otherwise, feel free to delete this <details> block -->
```
</p></details>


### Environment Info

  System:
    OS: macOS 15.5
    CPU: (8) arm64 Apple M1
    Memory: 66.69 MB / 8.00 GB
    Shell: 5.2.37 - /opt/homebrew/bin/bash
  Binaries:
    Node: 22.17.0 - ~/.nvm/versions/node/v22.17.0/bin/node
    Yarn: 4.9.2 - ~/.nvm/versions/node/v22.17.0/bin/yarn
    npm: 10.9.2 - ~/.nvm/versions/node/v22.17.0/bin/npm
  Browsers:
    Safari: 18.5
  npmPackages:
    @lerna-lite/changed: ^4.6.0 => 4.6.0 
    @lerna-lite/cli: ^4.6.0 => 4.6.0 
    @lerna-lite/diff: ^4.6.0 => 4.6.0 
    @lerna-lite/exec: ^4.6.0 => 4.6.0 
    @lerna-lite/publish: ^4.6.0 => 4.6.0 
    @lerna-lite/run: ^4.6.0 => 4.6.0 
    @lerna-lite/version: ^4.6.0 => 4.6.0 

### Used Package Manager

yarn >= 2.x

### 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](https://github.com/lerna-lite/lerna-lite/issues) that reports the same bug to avoid creating a duplicate.
- [x] Check that this is a concrete bug. For Q&A open a [GitHub Discussion](https://github.com/lerna-lite/lerna-lite/discussions).
- [x] The provided reproduction is a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) of the bug.

Pull request description:
fix(core): reimplement custom strong-log-transformer, fixes #1089
fixes #1089

## Description

Reimplement `strong-log-transformer` external dependency that was first implemented in PR #1085 but was not correctly tested with `lerna run` command that have extra package name as prefix.

## Motivation and Context

`strong-log-transformer` has this deprecation warning that existed for a while now

> DeprecationWarning: The `util._extend` API is deprecated. Please use `Object.assign()` instead


## How Has This Been Tested?

There's a lot more tests now, pretty much every tests from the original library and also tested manually by running some `lerna run` commands

## 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)
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] 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.
- [ ] My change requires a change to the documentation.
- [ ] I have updated the documentation accordingly.
- [ ] I have added tests to cover my changes.
- [x] All new and existing tests passed.


Patch:
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 3bbb6809..ffda36f2 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -53,7 +53,7 @@
       "type": "node",
       "request": "launch",
       "runtimeExecutable": "node",
-      "args": ["packages/cli/dist/cli.js", "run", "pack-tarball", "--stream", "--no-bail"],
+      "args": ["packages/cli/dist/cli.js", "run", "build", "--stream", "--include-dependents", "--scope=@lerna-lite/list"],
       "cwd": "${workspaceRoot}",
       "console": "integratedTerminal",
       "internalConsoleOptions": "openOnSessionStart",
diff --git a/README.md b/README.md
index 15d2a2a5..d970b7a7 100644
--- a/README.md
+++ b/README.md
@@ -122,7 +122,7 @@ Below are the main reasons as to why this fork was created:
    - [lerna version --allow-peer-dependencies-update](https://github.com/lerna-lite/lerna-lite/tree/main/packages/version#--allow-peer-dependencies-update) to also update your peer dependencies
    - [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 to GitHub releases
-   - [lerna publish --remove-package-fields](https://github.com/lerna-lite/lerna-lite/tree/main/packages/publish#--remove-package-fields-fields) (remove certain fields from `package.json` before publishing)
+   - [lerna publish --strip-package-keys](https://github.com/lerna-lite/lerna-lite/tree/main/packages/publish#--strip-package-keys-keys) (strip certain keys from `package.json` before publishing)
      - i.e.: we use it in here to publish Lerna-Lite without any `scripts` or `devDependencies`
    - [lerna version --skip-bump-only-releases](https://github.com/lerna-lite/lerna-lite/tree/main/packages/version#--skip-bump-only-releases), avoid cluttering your GitHub releases when using `independent`
    - [lerna version --sync-workspace-lock](https://github.com/lerna-lite/lerna-lite/tree/main/packages/version#--sync-workspace-lock) to sync lock file before publishing (not needed w/`workspace:` protocol)
diff --git a/lerna.json b/lerna.json
index cf16ee61..ad35c35f 100644
--- a/lerna.json
+++ b/lerna.json
@@ -6,7 +6,7 @@
   "command": {
     "publish": {
       "cleanupTempFiles": true,
-      "removePackageFields": [
+      "stripPackageKeys": [
         "devDependencies",
         "scripts"
       ]
diff --git a/package.json b/package.json
index 14b99331..9aeab7d0 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
     "clean": "remove --glob=\"packages/*/dist\" --glob=\"packages/*/tsconfig.tsbuildinfo\" --stat",
     "clean:tarball": "remove --glob packages/**/*.tgz --stat",
     "clean:temp": "node scripts/cleanup-temp-files.mjs",
-    "build": "tsc --build ./tsconfig.packages.json",
+    "build": "pnpm clean && tsc --build ./tsconfig.packages.json",
     "build:clean": "pnpm clean && tsc --build ./tsconfig.packages.json && echo TSC Build Completed",
     "build:full": "pnpm clean && pnpm lint && pnpm prettier:check && tsc --build ./tsconfig.packages.json && echo TSC Build Completed",
     "build:pnpm": "pnpm -r --stream build",
@@ -21,7 +21,7 @@
     "exec-win": "lerna exec --scope {@lerna-lite/cli,@lerna-lite/core} -- cross-env-shell echo hello from package: $LERNA_PACKAGE_NAME",
     "exec:preview": "lerna exec -- git log -p -2",
     "pack-tarball": "lerna run pack-tarball",
-    "preview:build": "lerna run build --stream --include-dependents --scope=@lerna-lite/listable",
+    "preview:build": "lerna run build --stream --include-dependents --scope=@lerna-lite/list",
     "preview:watch:build": "lerna watch --no-bail --scope=@lerna-lite/listable --include-dependents -- cross-env-shell lerna run echo:test --stream --include-dependents --scope=$LERNA_PACKAGE_NAME",
     "preview:watch:build:pnpm": "lerna watch --no-bail --scope=@lerna-lite/listable --include-dependents -- cross-env-shell pnpm run --stream --filter ...$LERNA_PACKAGE_NAME echo:test",
     "preview:watch:files": "lerna watch --glob=\"src/**/*.ts\" --ignored=\"**/__tests__/**\" --scope=@lerna-lite/listable --include-dependents -- cross-env-shell echo $LERNA_FILE_CHANGES in package $LERNA_PACKAGE_NAME\"",
diff --git a/packages/cli/schemas/lerna-schema.json b/packages/cli/schemas/lerna-schema.json
index 023de1c1..90abd2fa 100644
--- a/packages/cli/schemas/lerna-schema.json
+++ b/packages/cli/schemas/lerna-schema.json
@@ -382,6 +382,9 @@
             "removePackageFields": {
               "$ref": "#/$defs/commandOptions/publish/removePackageFields"
             },
+            "stripPackageKeys": {
+              "$ref": "#/$defs/commandOptions/publish/stripPackageKeys"
+            },
             "noGitReset": {
               "$ref": "#/$defs/commandOptions/publish/noGitReset"
             },
@@ -1246,6 +1249,13 @@
           },
           "description": "Remove fields from each package.json before publishing them to the registry, removing fields from a complex object is also supported via the dot notation (ie 'scripts.build')"
         },
+        "stripPackageKeys": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "description": "@deprecated @use `stripPackageKeys` instead. Remove fields from each package.json before publishing them to the registry, removing fields from a complex object is also supported via the dot notation (ie 'scripts.build')"
+        },
         "noGitReset": {
           "type": "boolean",
           "description": "During `lerna publish`, when true, do not reset changes to working tree after publishing is complete."
diff --git a/packages/cli/src/cli-commands/cli-publish-commands.ts b/packages/cli/src/cli-commands/cli-publish-commands.ts
index 819134f3..2e4a9175 100644
--- a/packages/cli/src/cli-commands/cli-publish-commands.ts
+++ b/packages/cli/src/cli-commands/cli-publish-commands.ts
@@ -105,7 +105,12 @@ export default {
       },
       'remove-package-fields': {
         describe:
-          'Remove fields from each package.json before publishing them to the registry, removing fields from a complex object is also supported via the dot notation (ie "scripts.build").',
+          '@deprecated: Remove fields from each package.json before publishing them to the registry, removing fields from a complex object is also supported via the dot notation (ie "scripts.build").',
+        type: 'array',
+      },
+      'strip-package-keys': {
+        describe:
+          'Strip fields from each package.json before publishing them to the registry, removing fields from a complex object is also supported via the dot notation (ie "scripts.build").',
         type: 'array',
       },
       'no-git-reset': {
diff --git a/packages/core/src/models/command-options.ts b/packages/core/src/models/command-options.ts
index daafd3ef..35fb74ef 100644
--- a/packages/core/src/models/command-options.ts
+++ b/packages/core/src/models/command-options.ts
@@ -405,9 +405,12 @@ export interface PublishCommandOption extends VersionCommandOption {
   /** Use the specified registry for all npm client operations. */
   registry?: string;
 
-  /** Remove fields from each package.json before publishing them to the registry, removing fields from a complex object is also supported via the dot notation (ie "scripts.build") */
+  /** @deprecated @use `stripPackageKeys` */
   removePackageFields?: string[];
 
+  /** Remove fields from each package.json before publishing them to the registry, removing fields from a complex object is also supported via the dot notation (ie "scripts.build") */
+  stripPackageKeys?: string[];
+
   /** Do not reset changes to working tree after publishing is complete. */
   noGitReset?: boolean;
 
diff --git a/packages/core/src/utils/log-prefix-transformer.ts b/packages/core/src/utils/log-prefix-transformer.ts
index d357e6ef..ed636516 100644
--- a/packages/core/src/utils/log-prefix-transformer.ts
+++ b/packages/core/src/utils/log-prefix-transformer.ts
@@ -1,22 +1,99 @@
-import { Transform } from 'stream';
-
-/**
- * Creates a Transform stream that adds a specified prefix to each line of input data.
- * This function is designed to replace the `strong-log-transformer` and provides a way to
- * format output by prepending a prefix to each line of text. It is particularly useful for
- * enhancing log messages or command output by clearly identifying the source or context of
- * the messages.
- *
- * @param {string} [prefix] - The prefix to be added to the beginning of each line. If no
- * prefix is provided, the output will remain unchanged.
- */
-export function addPrefixTransformer(prefix?: string) {
-  const newLineSeparator = process.platform.startsWith('win') ? '\r\n' : '\n';
+import { Transform } from 'node:stream';
+import { StringDecoder } from 'node:string_decoder';
+
+export function addPrefixTransformer(options: any = {}) {
+  const { tag = '', mergeMultiline = false, timeStamp = false, format = 'text' } = options;
+
+  const decoder = new StringDecoder('utf8');
+  let last = '';
+  let previousLine: any = null; // To store the previous line for merging
+
   return new Transform({
-    transform(chunk: Buffer | string, _encoding: BufferEncoding, callback: Function) {
-      const list = chunk.toString().split(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g);
-      list.filter(Boolean).forEach((m) => this.push(prefix ? prefix + ' ' + m + newLineSeparator : m + newLineSeparator));
+    transform(chunk, _enc, callback) {
+      last += decoder.write(chunk);
+      const lines = last.split(/\r\n|[\n\v\f\r\x85\u2028\u2029]/g);
+      last = lines.pop() || ''; // Keep the last incomplete line
+
+      for (const line of lines) {
+        if (line) {
+          if (mergeMultiline && /^\s+/.test(line)) {
+            // If merging is enabled and the line starts with whitespace
+            if (previousLine) {
+              previousLine.msg += '\n' + line; // Merge with the previous line
+            } else {
+              previousLine = {
+                msg: line,
+                time: Date.now(),
+                tag,
+              };
+            }
+          } else {
+            // If not merging, push the previous line if it exists
+            if (previousLine) {
+              this.push(formatLogEvent(previousLine, { timeStamp, format }));
+              previousLine = null; // Reset previous line
+            }
+            // Create a new log event for the current line
+            const logEvent = {
+              msg: line,
+              time: Date.now(),
+              tag,
+            };
+            this.push(formatLogEvent(logEvent, { timeStamp, format }));
+          }
+        }
+      }
+      callback();
+    },
+    flush(callback) {
+      last += decoder.end();
+      if (last) {
+        const logEvent = {
+          msg: last,
+          time: Date.now(),
+          tag,
+        };
+        this.push(formatLogEvent(logEvent, { timeStamp, format }));
+      }
+      // Push the previous line if it exists
+      if (previousLine) {
+        this.push(formatLogEvent(previousLine, { timeStamp, format }));
+      }
       callback();
     },
   });
 }
+
+function formatLogEvent(logEvent, options) {
+  const tagString = formatTag(logEvent.tag);
+  let line;
+
+  if (options.format === 'json') {
+    // Format as JSON
+    line = JSON.stringify({
+      msg: logEvent.msg,
+      time: options.timeStamp ? new Date(logEvent.time).toISOString() : undefined,
+      tag: tagString,
+    });
+  } else {
+    // Default text format
+    line = logEvent.msg;
+    if (tagString) {
+      line = `${tagString} ${line}`;
+    }
+    if (options.timeStamp) {
+      line = `${new Date(logEvent.time).toISOString()} ${line}`;
+    }
+  }
+
+  return line.replace(/\n/g, '\\n') + '\n'; // Replace newlines with escaped newlines
+}
+
+function formatTag(tag) {
+  if (typeof tag === 'object') {
+    return Object.entries(tag)
+      .map(([key, value]) => `${key}:${value}`) // No space after the colon
+      .join(' '); // Join with a space
+  }
+  return tag; // If it's a string, return it directly
+}
diff --git a/packages/publish/README.md b/packages/publish/README.md
index 79370f34..15074d3c 100644
--- a/packages/publish/README.md
+++ b/packages/publish/README.md
@@ -94,7 +94,8 @@ $ lerna publish --scope my-component test
     - [`--otp`](#--otp)
     - [`--preid`](#--preid)
     - [`--pre-dist-tag <tag>`](#--pre-dist-tag-tag)
-    - [`--remove-package-fields <fields>`](#--remove-package-fields-fields)
+    - [`--remove-package-fields <fields>`](#--remove-package-fields-fields) (`@deprecated` and renamed to `--strip-package-keys`)
+    - [`--strip-package-keys <keys>`](#--strip-package-keys-keys)
     - [`--registry <url>`](#--registry-url)
     - [`--tag-version-prefix`](#--tag-version-prefix)
     - [`--temp-tag`](#--temp-tag)
@@ -309,22 +310,25 @@ lerna publish --pre-dist-tag next
 Works the same as [`--dist-tag`](#--dist-tag-tag), except only applies to packages being released with a prerelease version.
 
 ### `--remove-package-fields <fields>`
+`@deprecated` and renamed as `strip-package-keys` (see below)
 
-Remove certain fields from every package before publishing them to the registry, we can also remove fields from a complex object structure via the dot notation (ie "scripts.build"). In summary this option is helpful in cleaning each "package.json" of every packages, it allows us to remove any extra fields that do not have any usage outside of the project itself (for example "devDependencies", "scripts", ...).
+### `--strip-package-keys <keys>`
+
+Strip (remove) certain keys from every package before publishing them to the registry, we can also strip keys from a complex object structure via the dot notation (e.g. "scripts.build"). This option is helpful in cleaning up each "package.json" of every packages, it allows us to strip and remove any keys that do not have any usage outside of the project itself (e.g. "devDependencies", "scripts", ...).
 
 ```sh
-# remove "devDepencies" and "scripts" fields from all packages
-lerna version --remove-package-fields 'devDependencies' 'scripts'
+# strip "devDepencies" and "scripts" fields from all packages
+lerna version --strip-package-keys 'devDependencies' 'scripts'
 ```
 
-> **Note** lifecycle scripts (`prepublish`, `prepublishOnly`, `prepack`, `postpack`) are executed after the field removal process and for that reason if any of these scripts are found, it will leave them in place and skip the removal whenever found.
+> **Note** lifecycle scripts (`prepublish`, `prepublishOnly`, `prepack`, `postpack`) are executed after the field removal process and for that reason, if any of these scripts are found, it will leave them in place and skip the removal whenever found.
 
-> **Note** this option will actually temporarily modify the actual `package.json` just before the publish process starts and will then revert the change after the publish process is completed. If for whatever reason, your publish process fails, it is possible that your each package, are now in an invalid state (e.g. `scripts` could be removed), so it very important to review your `package.json` after a publish failure.
+> **Note** this option will actually temporarily modify the actual `package.json` just before the publish process starts and will then revert the changes after the publish process is completed. If for whatever reason, your publish process fails, it is possible that your packages are transformed into an invalid state (e.g. `scripts` could be stripped), so it very important to review your `package.json` after a publish failure.
 
 Removal of complex object value(s) are also supported via the dot notation as shown below.
 
 ```sh
-lerna version --remove-package-fields 'scripts.build'
+lerna version --strip-package-keys 'scripts.build'
 ```
 
 ##### output
@@ -338,13 +342,13 @@ lerna version --remove-package-fields 'scripts.build'
 }
 ```
 
-This option is probably best specified in `lerna.json` configuration
+This option might be better defined via `lerna.json` configuration
 
 ```json
 {
   "command": {
     "publish": {
-      "removePackageFields": ["devDependencies", "scripts"]
+      "stripPackageKeys": ["devDependencies", "scripts"]
     }
   }
 }
diff --git a/packages/publish/src/publish-command.ts b/packages/publish/src/publish-command.ts
index f43036fc..1257af21 100644
--- a/packages/publish/src/publish-command.ts
+++ b/packages/publish/src/publish-command.ts
@@ -277,7 +277,7 @@ export class PublishCommand extends Command<PublishCommandOption> {
     await this.resolveDependencyWithCatalogProtocols();
     await this.resolveLocalDependencyWorkspaceProtocols();
 
-    if (this.options.removePackageFields) {
+    if (this.options.removePackageFields || this.options.stripPackageKeys) {
       await this.removePackageProperties();
     }
 
@@ -764,12 +764,18 @@ export class PublishCommand extends Command<PublishCommandOption> {
   }
 
   removePackageProperties() {
-    const { removePackageFields } = this.options;
+    const stripPackageKeys = this.options.removePackageFields || this.options.stripPackageKeys;
+    if (this.options.removePackageFields) {
+      this.logger.warn(
+        'DEPRECATION',
+        '"--remove-package-fields" was renamed to "--strip-package-keys" and will be removed in the next major release.'
+      );
+    }
 
     return pMap(this.packagesToPublish, (node: Package) => {
-      if (Array.isArray(removePackageFields)) {
-        for (const removeField of removePackageFields) {
-          if (removeField === 'scripts') {
+      if (Array.isArray(stripPackageKeys)) {
+        for (const fieldToStrip of stripPackageKeys) {
+          if (fieldToStrip === 'scripts') {
             // when deleting field "scripts", we need to keep any lifecycle script(s) to avoid failure when packing tarball
             const scriptNames = Object.keys(node.pkg['scripts'] ?? {});
             const remainingScripts = excludeValuesFromArray(scriptNames, ['prepublish', 'prepublishOnly', 'prepack', 'postpack']);
@@ -780,7 +786,7 @@ export class PublishCommand extends Command<PublishCommandOption> {
               continue; // to next package
             }
           }
-          deleteComplexObjectProp(node.pkg, removeField, `"${node.pkg.name}" package`);
+          deleteComplexObjectProp(node.pkg, fieldToStrip, `"${node.pkg.name}" package`);
         }
       }
     });


Test patch:
diff --git a/packages/core/src/utils/__tests__/log-prefix-transformer.spec.ts b/packages/core/src/utils/__tests__/log-prefix-transformer.spec.ts
index 8c864de7..ec46ec2c 100644
--- a/packages/core/src/utils/__tests__/log-prefix-transformer.spec.ts
+++ b/packages/core/src/utils/__tests__/log-prefix-transformer.spec.ts
@@ -1,14 +1,14 @@
-import { describe, expect, it } from 'vitest';
+import { describe, expect, it, test } from 'vitest';
 
 import { addPrefixTransformer } from '../log-prefix-transformer.js';
 
 describe('addPrefixTransformer', () => {
-  const newLineSeparator = process.platform.startsWith('win') ? '\r\n' : '\n';
+  const newLineSeparator = '\n'; // process.platform.startsWith('win') ? '\r\n' : '\n';
 
   it('should add a prefix to each line of output', () =>
     new Promise((done: any) => {
       const prefix = 'PREFIX';
-      const transformer = addPrefixTransformer(prefix);
+      const transformer = addPrefixTransformer({ tag: prefix });
 
       const input = 'line1\nline2\nline3';
       const expectedOutput = `PREFIX line1${newLineSeparator}PREFIX line2${newLineSeparator}PREFIX line3${newLineSeparator}`;
@@ -29,7 +29,7 @@ describe('addPrefixTransformer', () => {
 
   it('should handle empty input', () =>
     new Promise((done: any) => {
-      const transformer = addPrefixTransformer('PREFIX');
+      const transformer = addPrefixTransformer({ tag: 'PREFIX' });
 
       const chunks: string[] = [];
       transformer.on('data', (chunk: Buffer) => {
@@ -65,4 +65,383 @@ describe('addPrefixTransformer', () => {
       transformer.write(input);
       transformer.end();
     }));
+
+  describe('test-ansi-color-tags', () => {
+    it('should handle tag object with ANSI escape codes', () =>
+      new Promise((done: any) => {
+        const transformer = addPrefixTransformer({
+          tag: {
+            blue: '\u001b[1m\u001b[34mblue\u001b[39m\u001b[22m',
+            green: '\u001b[32mgreen\u001b[39m',
+          },
+        });
+
+        const input = ['good line', 'good line', 'good line'];
+        const expected = input
+          .map((line) => {
+            return 'blue:\u001b[1m\u001b[34mblue\u001b[39m\u001b[22m green:\u001b[32mgreen\u001b[39m ' + line + '\n';
+          })
+          .join('');
+
+        let received = '';
+
+        transformer.on('data', (buf) => {
+          received += buf.toString('utf8');
+        });
+        transformer.on('end', () => {
+          expect(received).toBe(expected);
+          done();
+        });
+
+        input.forEach((line) => {
+          transformer.write(line + '\n');
+        });
+        transformer.end();
+      }));
+  });
+
+  describe('test-bad-utf8', () => {
+    it('should handle log line containing bad utf8', () =>
+      new Promise((done: any) => {
+        const transformer = addPrefixTransformer();
+        const input = Buffer.from([0x48, 0x69, 0x20, 0x80, 0x0a]); // "Hi\n"
+        const expected = 'Hi \ufffd\n'; // replacement character
+        let received = '';
+
+        transformer.on('data', (buf) => {
+          received += buf.toString('utf8');
+        });
+        transformer.on('end', () => {
+          expect(received).toBe(expected);
+          done();
+        });
+
+        transformer.write(input);
+        transformer.end();
+      }));
+
+    it('should handle chunked bad utf8', () =>
+      new Promise((done: any) => {
+        const transformer = addPrefixTransformer();
+        // Split the bad UTF8 sequence across chunks
+        const input1 = Buffer.from([0x48, 0x69, 0x20]); // "Hi "
+        const input2 = Buffer.from([0x80, 0x0a]); // bad byte + newline
+        const expected = 'Hi \ufffd\n';
+        let received = '';
+
+        transformer.on('data', (buf) => {
+          received += buf.toString('utf8');
+        });
+        transformer.on('end', () => {
+          expect(received).toBe(expected);
+          done();
+        });
+
+        transformer.write(input1);
+        transformer.write(input2);
+        transformer.end();
+      }));
+  });
+
+  describe('test-basic functionality', () => {
+    it('should perform basic text transformation', () =>
+      new Promise((done: any) => {
+        const transformer = addPrefixTransformer();
+        const input = 'hello world\n';
+        const expected = 'hello world\n';
+        let received = '';
+
+        transformer.on('data', (buf) => {
+          received += buf.toString();
+        });
+        transformer.on('end', () => {
+          expect(received).toBe(expected);
+          done();
+        });
+
+        transformer.write(input);
+        transformer.end();
+      }));
+
+    it('should format with timestamp', () =>
+      new Promise((done: any) => {
+        const slt = addPrefixTransformer({ format: 'json', timeStamp: true });
+        const input = 'hello world\n';
+        let received = '';
+
+        slt.on('data', (buf) => {
+          received += buf.toString();
+        });
+        slt.on('end', () => {
+          const parsed = JSON.parse(received.replace('\n', ''));
+          expect(parsed.msg).toBe('hello world');
+          expect(typeof parsed.time).toBe('string');
+          expect(new Date(parsed.time).getTime()).toBeGreaterThan(0);
+          done();
+        });
+
+        slt.write(input);
+        slt.end();
+      }));
+
+    describe('test-stringTag functionality', () => {
+      test('string tag', () =>
+        new Promise((done: any) => {
+          const slt = addPrefixTransformer({ tag: 'worker:1' });
+          const input = 'hello world\n';
+          const expected = 'worker:1 hello world\n';
+          let received = '';
+
+          slt.on('data', (buf) => {
+            received += buf.toString();
+          });
+          slt.on('end', () => {
+            expect(received).toBe(expected);
+            done();
+          });
+
+          slt.write(input);
+          slt.end();
+        }));
+    });
+
+    describe('test-text-tag-object functionality', () => {
+      test('object tag', () =>
+        new Promise((done: any) => {
+          const slt = addPrefixTransformer({ tag: { worker: '1', pid: '12345' } });
+          const input = 'hello world\n';
+          const expected = 'worker:1 pid:12345 hello world\n';
+          let received = '';
+
+          slt.on('data', (buf) => {
+            received += buf.toString();
+          });
+          slt.on('end', () => {
+            expect(received).toBe(expected);
+            done();
+          });
+
+          slt.write(input);
+          slt.end();
+        }));
+    });
+  });
+
+  describe('test-text-timestamp functionality', () => {
+    test('text format with timestamp', () =>
+      new Promise((done: any) => {
+        const slt = addPrefixTransformer({ timeStamp: true });
+        const input = 'hello world\n';
+        let received = '';
+
+        slt.on('data', (buf: any) => {
+          received += buf.toString();
+        });
+        slt.on('end', () => {
+          // Should match: "2023-01-01T00:00:00.000Z hello world\n"
+          const timestampRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z hello world\n$/;
+          expect(received).toMatch(timestampRegex);
+          done();
+        });
+
+        slt.write(input);
+        slt.end();
+      }));
+
+    test('text format with timestamp and tag', () =>
+      new Promise((done: any) => {
+        const slt = addPrefixTransformer({ timeStamp: true, tag: 'worker:1' });
+        const input = 'hello world\n';
+        let received = '';
+
+        slt.on('data', (buf: any) => {
+          received += buf.toString();
+        });
+        slt.on('end', () => {
+          // Should match: "2023-01-01T00:00:00.000Z worker:1 hello world\n"
+          const timestampRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z worker:1 hello world\n$/;
+          expect(received).toMatch(timestampRegex);
+          done();
+        });
+
+        slt.write(input);
+        slt.end();
+      }));
+  });
+
+  describe('test-lineMerge functionality', () => {
+    test('merge multiline logs', () =>
+      new Promise((done: any) => {
+        const slt = addPrefixTransformer({ mergeMultiline: true });
+        const input = 'Error: something bad happened\n    at Object.<anonymous> (/path/to/file.js:1:1)\n    at Module._compile (module.js:456:26)\nnext line\n';
+        let received = '';
+
+        slt.on('data', (buf: any) => {
+          received += buf.toString();
+        });
+        slt.on('end', () => {
+          const lines = received.split('\n').filter((line) => line.length > 0);
+          expect(lines).toHaveLength(3);
+          expect(lines[0]).toContain('Error: something bad happened');
+          expect(lines[1]).toContain('at Object.<anonymous>');
+          expect(lines[1]).toContain('at Module._compile');
+          expect(lines[2]).toBe('next line');
+          done();
+        });
+
+        slt.write(input);
+        slt.end();
+      }));
+
+    test('merge multiline with timeout', () =>
+      new Promise((done: any) => {
+        const slt = addPrefixTransformer({ mergeMultiline: true });
+        let received = '';
+        let dataCount = 0;
+
+        slt.on('data', (buf: any) => {
+          received += buf.toString();
+          dataCount++;
+        });
+        slt.on('end', () => {
+          expect(received).toBe('Error: something bad\n    at some location\n');
+          expect(dataCount).toBeGreaterThan(0);
+          done();
+        });
+
+        slt.write('Error: something bad\n');
+        slt.write('    at some location\n');
+
+        // Wait for the timeout to trigger flush
+        setTimeout(() => {
+          slt.end();
+        }, 15);
+      }));
+  });
+
+  describe('newline handling', () => {
+    test('handles different line endings', () =>
+      new Promise((done: any) => {
+        const slt = addPrefixTransformer();
+        const inputs = ['line1\n', 'line2\r\n', 'line3\r', 'line4\v', 'line5\f'];
+        let received = '';
+        let lineCount = 0;
+
+        slt.on('data', (buf: any) => {
+          received += buf.toString();
+          lineCount++;
+        });
+        slt.on('end', () => {
+          expect(lineCount).toBe(5);
+          expect(received).toContain('line1\n');
+          expect(received).toContain('line2\n');
+          expect(received).toContain('line3\n');
+          expect(received).toContain('line4\n');
+          expect(received).toContain('line5\n');
+          done();
+        });
+
+        inputs.forEach((input) => slt.write(input));
+        slt.end();
+      }));
+
+    test('swallows empty lines', () =>
+      new Promise((done: any) => {
+        const slt = addPrefixTransformer();
+        const input = 'line1\n\n\nline2\n';
+        let received = '';
+        let lineCount = 0;
+
+        slt.on('data', (buf: any) => {
+          received += buf.toString();
+          lineCount++;
+        });
+        slt.on('end', () => {
+          expect(lineCount).toBe(2); // Only non-empty lines
+          expect(received).toBe('line1\nline2\n');
+          done();
+        });
+
+        slt.write(input);
+        slt.end();
+      }));
+  });
+
+  describe('edge cases', () => {
+    test('handles newlines in log messages correctly', () =>
+      new Promise((done: any) => {
+        const slt = addPrefixTransformer();
+        const input = 'line with\nnewline in it\n';
+        let received = '';
+        let lineCount = 0;
+
+        slt.on('data', (buf: any) => {
+          received += buf.toString();
+          lineCount++;
+        });
+        slt.on('end', () => {
+          // Input with newlines gets split into separate lines by deLiner
+          expect(lineCount).toBe(2);
+          expect(received).toBe('line with\nnewline in it\n');
+          done();
+        });
+
+        slt.write(input);
+        slt.end();
+      }));
+
+    test('handles incomplete lines at end of stream', () =>
+      new Promise((done: any) => {
+        const slt = addPrefixTransformer();
+        const input = 'incomplete line without newline';
+        let received = '';
+
+        slt.on('data', (buf: any) => {
+          received += buf.toString();
+        });
+        slt.on('end', () => {
+          expect(received).toBe('incomplete line without newline\n');
+          done();
+        });
+
+        slt.write(input);
+        slt.end();
+      }));
+
+    test('handles multiple chunks forming single line', () =>
+      new Promise((done: any) => {
+        const slt = addPrefixTransformer();
+        let received = '';
+
+        slt.on('data', (buf: any) => {
+          received += buf.toString();
+        });
+        slt.on('end', () => {
+          expect(received).toBe('hello world\n');
+          done();
+        });
+
+        slt.write('hello ');
+        slt.write('world\n');
+        slt.end();
+      }));
+
+    test('handles tag with special characters', () =>
+      new Promise((done: any) => {
+        const slt = addPrefixTransformer({ tag: 'worker[1]' });
+        const input = 'test message\n';
+        let received = '';
+
+        slt.on('data', (buf: any) => {
+          received += buf.toString();
+        });
+        slt.on('end', () => {
+          expect(received).toBe('worker[1] test message\n');
+          done();
+        });
+
+        slt.write(input);
+        slt.end();
+      }));
+  });
 });
diff --git a/packages/publish/src/__tests__/__fixtures__/remove-fields/lerna.json b/packages/publish/src/__tests__/__fixtures__/strip-pkg-keys/lerna.json
similarity index 100%
rename from packages/publish/src/__tests__/__fixtures__/remove-fields/lerna.json
rename to packages/publish/src/__tests__/__fixtures__/strip-pkg-keys/lerna.json
diff --git a/packages/publish/src/__tests__/__fixtures__/remove-fields/package.json b/packages/publish/src/__tests__/__fixtures__/strip-pkg-keys/package.json
similarity index 100%
rename from packages/publish/src/__tests__/__fixtures__/remove-fields/package.json
rename to packages/publish/src/__tests__/__fixtures__/strip-pkg-keys/package.json
diff --git a/packages/publish/src/__tests__/__fixtures__/remove-fields/packages/package-1/package.json b/packages/publish/src/__tests__/__fixtures__/strip-pkg-keys/packages/package-1/package.json
similarity index 100%
rename from packages/publish/src/__tests__/__fixtures__/remove-fields/packages/package-1/package.json
rename to packages/publish/src/__tests__/__fixtures__/strip-pkg-keys/packages/package-1/package.json
diff --git a/packages/publish/src/__tests__/__fixtures__/remove-fields/packages/package-2/package.json b/packages/publish/src/__tests__/__fixtures__/strip-pkg-keys/packages/package-2/package.json
similarity index 100%
rename from packages/publish/src/__tests__/__fixtures__/remove-fields/packages/package-2/package.json
rename to packages/publish/src/__tests__/__fixtures__/strip-pkg-keys/packages/package-2/package.json
diff --git a/packages/publish/src/__tests__/__fixtures__/remove-fields/packages/package-3/package.json b/packages/publish/src/__tests__/__fixtures__/strip-pkg-keys/packages/package-3/package.json
similarity index 100%
rename from packages/publish/src/__tests__/__fixtures__/remove-fields/packages/package-3/package.json
rename to packages/publish/src/__tests__/__fixtures__/strip-pkg-keys/packages/package-3/package.json
diff --git a/packages/publish/src/__tests__/__fixtures__/remove-fields/packages/package-4/package.json b/packages/publish/src/__tests__/__fixtures__/strip-pkg-keys/packages/package-4/package.json
similarity index 100%
rename from packages/publish/src/__tests__/__fixtures__/remove-fields/packages/package-4/package.json
rename to packages/publish/src/__tests__/__fixtures__/strip-pkg-keys/packages/package-4/package.json
diff --git a/packages/publish/src/__tests__/__fixtures__/remove-fields/packages/package-5/package.json b/packages/publish/src/__tests__/__fixtures__/strip-pkg-keys/packages/package-5/package.json
similarity index 100%
rename from packages/publish/src/__tests__/__fixtures__/remove-fields/packages/package-5/package.json
rename to packages/publish/src/__tests__/__fixtures__/strip-pkg-keys/packages/package-5/package.json
diff --git a/packages/publish/src/__tests__/__fixtures__/remove-fields/packages/package-6/package.json b/packages/publish/src/__tests__/__fixtures__/strip-pkg-keys/packages/package-6/package.json
similarity index 100%
rename from packages/publish/src/__tests__/__fixtures__/remove-fields/packages/package-6/package.json
rename to packages/publish/src/__tests__/__fixtures__/strip-pkg-keys/packages/package-6/package.json
diff --git a/packages/publish/src/__tests__/publish-remove-package-fields.spec.ts b/packages/publish/src/__tests__/publish-strip-package-keys.spec.ts
similarity index 77%
rename from packages/publish/src/__tests__/publish-remove-package-fields.spec.ts
rename to packages/publish/src/__tests__/publish-strip-package-keys.spec.ts
index 1e00edbf..76b16dc1 100644
--- a/packages/publish/src/__tests__/publish-remove-package-fields.spec.ts
+++ b/packages/publish/src/__tests__/publish-strip-package-keys.spec.ts
@@ -61,13 +61,13 @@ describe('publish --remove-package-fields', () => {
     await gitCommit(cwd, 'setup');
   };
 
-  describe('use --remove-package-fields flag from CLI', () => {
-    it('should be able to remove a field from all packages by providing it in CLI', async () => {
-      const cwd = await initFixture('remove-fields');
+  describe('use --strip-package-keys flag from CLI', () => {
+    it('should be able to strip a field from all packages by providing it in CLI', async () => {
+      const cwd = await initFixture('strip-pkg-keys');
 
       await gitTag(cwd, 'v1.0.0');
       await setupChanges(cwd);
-      await lernaPublish(cwd)('--remove-package-fields', 'browser');
+      await lernaPublish(cwd)('--strip-package-keys', 'browser');
 
       const publishPkg1 = (writePkg as any).updatedManifest('package-1');
       const publishPkg2 = (writePkg as any).updatedManifest('package-2');
@@ -83,27 +83,35 @@ describe('publish --remove-package-fields', () => {
     });
 
     it('should skip configuring --remove-package-fields and work normally without touching t', async () => {
-      const cwd = await initFixture('remove-fields');
+      const cwd = await initFixture('strip-pkg-keys');
       await lernaPublish(cwd)();
 
       const devDeps = (writePkg as any).updatedManifest('package-5').devDependencies;
       expect(devDeps).toEqual({ jest: '^29.0.0', 'tiny-tarball': '^1.0.0' });
     });
 
-    it('should configure a simple --remove-package-fields to remove a single package child field', async () => {
-      const cwd = await initFixture('remove-fields');
-      await lernaPublish(cwd)('--remove-package-fields', 'devDependencies.jest');
+    it('should skip configuring --strip-package-keys and work normally without touching t', async () => {
+      const cwd = await initFixture('strip-pkg-keys');
+      await lernaPublish(cwd)();
+
+      const devDeps = (writePkg as any).updatedManifest('package-5').devDependencies;
+      expect(devDeps).toEqual({ jest: '^29.0.0', 'tiny-tarball': '^1.0.0' });
+    });
+
+    it('should configure a simple --strip-package-keys to strip a single package child field', async () => {
+      const cwd = await initFixture('strip-pkg-keys');
+      await lernaPublish(cwd)('--strip-package-keys', 'devDependencies.jest');
 
       const devDeps = (writePkg as any).updatedManifest('package-5').devDependencies;
       expect(devDeps).toEqual({ 'tiny-tarball': '^1.0.0' });
     });
 
-    it('should be able to remove multiple different child fields using the dot notation and by passing multiple CLI arguments', async () => {
-      const cwd = await initFixture('remove-fields');
+    it('should be able to strip multiple different child fields using the dot notation and by passing multiple CLI arguments', async () => {
+      const cwd = await initFixture('strip-pkg-keys');
 
       await gitTag(cwd, 'v1.0.0');
       await setupChanges(cwd);
-      await lernaPublish(cwd)('--remove-package-fields', 'devDependencies.jest', 'scripts.build:dev', 'exports.index.types');
+      await lernaPublish(cwd)('--strip-package-keys', 'devDependencies.jest', 'scripts.build:dev', 'exports.index.types');
 
       const publishPkg5 = (writePkg as any).updatedManifest('package-5');
       expect(publishPkg5.devDependencies).toEqual({ 'tiny-tarball': '^1.0.0' });
@@ -117,11 +125,11 @@ describe('publish --remove-package-fields', () => {
     });
 
     it('should expect any lifecycle script (prepack, prepublish, ...postpack) to never be deleted even when user defines the entire scripts for deletion', async () => {
-      const cwd = await initFixture('remove-fields');
+      const cwd = await initFixture('strip-pkg-keys');
 
       await gitTag(cwd, 'v1.0.0');
       await setupChanges(cwd);
-      await lernaPublish(cwd)('--remove-package-fields', 'scripts');
+      await lernaPublish(cwd)('--strip-package-keys', 'scripts');
 
       const publishPkg1 = (writePkg as any).updatedManifest('package-1');
       const publishPkg2 = (writePkg as any).updatedManifest('package-2');
@@ -145,9 +153,10 @@ describe('publish --remove-package-fields', () => {
     });
   });
 
-  describe('use "removePackageFields" from Lerna config', () => {
+  describe('use "stripPackageKeys" from Lerna config', () => {
+    // @deprecated renamed as `stripPackageKeys`
     it('should be able to remove a field from all packages by defining it in Lerna config', async () => {
-      const cwd = await initFixture('remove-fields');
+      const cwd = await initFixture('strip-pkg-keys');
 
       await updateLernaConfig(cwd, {
         command: {
@@ -174,13 +183,41 @@ describe('publish --remove-package-fields', () => {
       expect(publishPkg5.browser).toBeUndefined();
     });
 
+    it('should be able to strip a field from all packages by defining it in Lerna config', async () => {
+      const cwd = await initFixture('strip-pkg-keys');
+
+      await updateLernaConfig(cwd, {
+        command: {
+          publish: {
+            stripPackageKeys: ['browser'],
+          },
+        },
+      });
+
+      await gitTag(cwd, 'v1.0.0');
+      await setupChanges(cwd);
+      await lernaPublish(cwd)();
+
+      const publishPkg1 = (writePkg as any).updatedManifest('package-1');
+      const publishPkg2 = (writePkg as any).updatedManifest('package-2');
+      const publishPkg3 = (writePkg as any).updatedManifest('package-3');
+      const publishPkg4 = (writePkg as any).updatedManifest('package-4');
+      const publishPkg5 = (writePkg as any).updatedManifest('package-5');
+
+      expect(publishPkg1.browser).toBeUndefined();
+      expect(publishPkg2.browser).toBeUndefined();
+      expect(publishPkg3.browser).toBeUndefined();
+      expect(publishPkg4.browser).toBeUndefined();
+      expect(publishPkg5.browser).toBeUndefined();
+    });
+
     it('should be able to remove an entire array "devDependencies" field and also remove a child field using the dot notation in Lerna config', async () => {
-      const cwd = await initFixture('remove-fields');
+      const cwd = await initFixture('strip-pkg-keys');
 
       await updateLernaConfig(cwd, {
         command: {
           publish: {
-            removePackageFields: ['devDependencies', 'scripts.build', 'exports.index.types'],
+            stripPackageKeys: ['devDependencies', 'scripts.build', 'exports.index.types'],
           },
         },
       });