From 781918f048ab66fd71f8bb71d16f16e8e44e0903 Mon Sep 17 00:00:00 2001 From: Zoltan Kochan Date: Fri, 8 May 2026 01:56:54 +0200 Subject: [PATCH] feat(default-reporter): add pnpm-render bin to render NDJSON from stdin (#11530) - Adds a `pnpm-render` bin to `@pnpm/cli.default-reporter` that reads pnpm-shaped NDJSON from stdin and pipes it through the default reporter, so external tools that emit `pnpm:*` log records can reuse pnpm's renderer. - Optional first positional arg sets the command name (defaults to `install`), e.g. `pnpm-render add` for piping output from `pacquet add`. ## Motivation [Pacquet](https://github.com/pnpm/pacquet) emits pnpm-shaped `--reporter=ndjson` output for forward-compatibility with pnpm's renderer, but there was no way to actually render it. With this bin: ```sh pacquet install --reporter=ndjson 2>&1 >/dev/null | pnpm-render ``` (pacquet writes NDJSON to stderr, so the redirect is needed.) --- .changeset/default-reporter-bin.md | 5 ++ cli/default-reporter/README.md | 18 +++++++ cli/default-reporter/bin/pnpm-render.mjs | 7 +++ cli/default-reporter/package.json | 6 ++- cli/default-reporter/src/cli.ts | 54 ++++++++++++++++++++ cli/default-reporter/test/cli.ts | 64 ++++++++++++++++++++++++ cspell.json | 1 + 7 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 .changeset/default-reporter-bin.md create mode 100755 cli/default-reporter/bin/pnpm-render.mjs create mode 100644 cli/default-reporter/src/cli.ts create mode 100644 cli/default-reporter/test/cli.ts diff --git a/.changeset/default-reporter-bin.md b/.changeset/default-reporter-bin.md new file mode 100644 index 0000000000..369be182c4 --- /dev/null +++ b/.changeset/default-reporter-bin.md @@ -0,0 +1,5 @@ +--- +"@pnpm/cli.default-reporter": minor +--- + +Added a `pnpm-render` bin that renders pnpm-shaped NDJSON read from stdin, so the same renderer can be used to format output from external tools that emit `pnpm:*` log records (e.g. `pacquet install --reporter=ndjson 2>&1 >/dev/null | pnpm-render`). An optional first positional argument sets the command name (defaults to `install`). diff --git a/cli/default-reporter/README.md b/cli/default-reporter/README.md index 20848ce338..bf98cee78b 100644 --- a/cli/default-reporter/README.md +++ b/cli/default-reporter/README.md @@ -28,6 +28,24 @@ try { } ``` +## `pnpm-render` bin + +Installing this package exposes a `pnpm-render` bin that reads pnpm-shaped NDJSON from stdin and renders it through the default reporter. This lets external tools that emit `pnpm:*` log records reuse pnpm's renderer. + +For example, [pacquet](https://github.com/pnpm/pacquet) emits the same wire format under `--reporter=ndjson` (to stderr), so its output can be piped through `pnpm-render`: + +```sh +pacquet install --reporter=ndjson 2>&1 >/dev/null | pnpm-render +``` + +The redirect (`2>&1 >/dev/null`) is needed because pacquet writes the NDJSON stream to stderr. + +An optional first positional argument sets the command name (defaults to `install`); pass it to match the verb the producer is running so command-specific renderers behave correctly: + +```sh +pacquet add lodash --reporter=ndjson 2>&1 >/dev/null | pnpm-render add +``` + ## Style Guide 1. Never use blue or grey as font color as they are hard to read in many consoles. diff --git a/cli/default-reporter/bin/pnpm-render.mjs b/cli/default-reporter/bin/pnpm-render.mjs new file mode 100755 index 0000000000..32a07f0f64 --- /dev/null +++ b/cli/default-reporter/bin/pnpm-render.mjs @@ -0,0 +1,7 @@ +#!/usr/bin/env node +import { runCli } from '../lib/cli.js' + +runCli(process.argv.slice(2)).catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/cli/default-reporter/package.json b/cli/default-reporter/package.json index d33a2c3a0e..486e2fb6d2 100644 --- a/cli/default-reporter/package.json +++ b/cli/default-reporter/package.json @@ -22,8 +22,12 @@ }, "files": [ "lib", - "!*.map" + "!*.map", + "bin" ], + "bin": { + "pnpm-render": "bin/pnpm-render.mjs" + }, "scripts": { "start": "tsgo --watch", "lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\"", diff --git a/cli/default-reporter/src/cli.ts b/cli/default-reporter/src/cli.ts new file mode 100644 index 0000000000..bcf398373d --- /dev/null +++ b/cli/default-reporter/src/cli.ts @@ -0,0 +1,54 @@ +import { EventEmitter } from 'node:events' +import readline from 'node:readline' + +import type { Log } from '@pnpm/core-loggers' +import type { Reporter, StreamParser } from '@pnpm/logger' + +import { initDefaultReporter } from './index.js' + +export async function runCli (argv: readonly string[]): Promise { + const cmd = argv[0] ?? 'install' + + const emitter = new EventEmitter() + const streamParser: StreamParser = { + on: (event: 'data', reporter: Reporter) => { + emitter.on(event, reporter) + }, + removeListener: (event: 'data', reporter: Reporter) => { + emitter.removeListener(event, reporter) + }, + } + + const close = initDefaultReporter({ + streamParser, + context: { argv: [cmd] }, + reportingOptions: { + throttleProgress: 200, + }, + }) + + // initDefaultReporter registers its 'data' listener via setTimeout(0); wait + // a tick so events emitted from the readline loop below aren't dropped. + await new Promise((resolve) => { + setTimeout(resolve, 0) + }) + + try { + const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }) + for await (const line of rl) { + if (!line) continue + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + continue + } + // Guard against valid JSON that isn't a log object (e.g. `null`, + // numbers, strings) — the reporter dispatches on `log.name`. + if (parsed == null || typeof parsed !== 'object') continue + emitter.emit('data', parsed as Log) + } + } finally { + close() + } +} diff --git a/cli/default-reporter/test/cli.ts b/cli/default-reporter/test/cli.ts new file mode 100644 index 0000000000..d1c2b19b33 --- /dev/null +++ b/cli/default-reporter/test/cli.ts @@ -0,0 +1,64 @@ +import { spawn } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { expect, test } from '@jest/globals' + +const BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'pnpm-render.mjs') + +test('pnpm-render bin renders ndjson piped from stdin', async () => { + const lines = [ + { name: 'pnpm:stage', level: 'debug', prefix: '/tmp/proj', stage: 'resolution_started' }, + { name: 'pnpm:progress', level: 'debug', packageId: 'foo@1.0.0', requester: '/tmp/proj', status: 'resolved' }, + { name: 'pnpm:progress', level: 'debug', packageId: 'bar@2.0.0', requester: '/tmp/proj', status: 'resolved' }, + { name: 'pnpm:summary', level: 'debug', prefix: '/tmp/proj' }, + ] + + const stdout = await runBin(['install'], lines.map((line) => JSON.stringify(line)).join('\n') + '\n') + + // ansi-diff intersperses cursor-movement escapes when the rendered string + // changes (e.g. "1" → "2"), so we can't substring-match the final value + // without terminal emulation. Verifying that any progress line rendered is + // enough to prove the bin wired stdin → reporter correctly. + expect(stdout).toContain('Progress: resolved') +}) + +test('pnpm-render bin ignores malformed and non-object stdin lines', async () => { + const stdout = await runBin(['install'], [ + 'not-json', + 'null', + '42', + '"a string"', + JSON.stringify({ name: 'pnpm:stage', level: 'debug', prefix: '/tmp/proj', stage: 'resolution_started' }), + JSON.stringify({ name: 'pnpm:progress', level: 'debug', packageId: 'foo@1.0.0', requester: '/tmp/proj', status: 'resolved' }), + '', + JSON.stringify({ name: 'pnpm:summary', level: 'debug', prefix: '/tmp/proj' }), + ].join('\n') + '\n') + + expect(stdout).toContain('Progress: resolved 1') +}) + +async function runBin (args: readonly string[], stdin: string): Promise { + const child = spawn(process.execPath, [BIN, ...args], { stdio: ['pipe', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString() + }) + child.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString() + }) + child.stdin.end(stdin) + // Wait for 'close' (not 'exit'): 'exit' can fire before stdout/stderr + // are fully drained, which leads to truncated captures and flaky asserts. + const exitCode = await new Promise((resolve, reject) => { + child.on('error', reject) + child.on('close', (code) => { + resolve(code) + }) + }) + if (exitCode !== 0) { + throw new Error(`pnpm-render exited with ${exitCode}\nstdout:\n${stdout}\nstderr:\n${stderr}`) + } + return stdout +} diff --git a/cspell.json b/cspell.json index 0affa17267..0b9d53e968 100644 --- a/cspell.json +++ b/cspell.json @@ -209,6 +209,7 @@ "packlist", "packr", "packument", + "pacquet", "paralleljs", "parallelly", "parseable",