From 8543b89fb220eed9d63ee697b043d7297262cd12 Mon Sep 17 00:00:00 2001 From: btea <2356281422@qq.com> Date: Mon, 11 May 2026 18:28:04 +0800 Subject: [PATCH] feat: `view` command display published time (#11532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * **New Features** * `pnpm view` now shows publish age (e.g., "published 2 hours ago") and, when available, publisher attribution ("by …"); invalid or future timestamps fall back to "just now". * Console styling for package metadata (name/version, license, counts, links, dependencies, maintainers) was improved for readability. * **Tests** * Added tests verifying the published timestamp and publisher attribution appear in `pnpm view` output. --- .changeset/fix-11529-view-published-time.md | 12 +++ deps/inspection/commands/src/view/index.ts | 112 +++++++++++++++----- deps/inspection/commands/test/view.ts | 11 ++ resolving/npm-resolver/src/index.ts | 38 ++++--- 4 files changed, 129 insertions(+), 44 deletions(-) create mode 100644 .changeset/fix-11529-view-published-time.md diff --git a/.changeset/fix-11529-view-published-time.md b/.changeset/fix-11529-view-published-time.md new file mode 100644 index 0000000000..607924788b --- /dev/null +++ b/.changeset/fix-11529-view-published-time.md @@ -0,0 +1,12 @@ +--- +"@pnpm/deps.inspection.commands": patch +"pnpm": patch +--- + +Added "published X ago by Y" information to the `pnpm view` command output, similar to `npm view`. This is useful when comparing against `minimumReleaseAge`. + +For example, `pnpm view pnpm` now shows: + +``` +published 17 hours ago by GitHub Actions +``` diff --git a/deps/inspection/commands/src/view/index.ts b/deps/inspection/commands/src/view/index.ts index ddf24adae9..0363e60243 100644 --- a/deps/inspection/commands/src/view/index.ts +++ b/deps/inspection/commands/src/view/index.ts @@ -1,9 +1,11 @@ import { type Config, type ConfigContext, types as allTypes } from '@pnpm/config.reader' import { PnpmError } from '@pnpm/error' +import { formatTimeAgo } from '@pnpm/resolving.npm-resolver' +import chalk from 'chalk' import { pick } from 'ramda' import { renderHelp } from 'render-help' -import { fetchPackageInfo } from '../fetchPackageInfo.js' +import { type ExtendedPackageInfo, fetchPackageInfo } from '../fetchPackageInfo.js' export function rcOptionsTypes (): Record { return pick(['registry'], allTypes) @@ -95,21 +97,21 @@ export async function handler ( const headerParts: string[] = [] if (info.name && info.version) { - headerParts.push(`${info.name}@${info.version}`) + headerParts.push(chalk.cyan(`${info.name}@${info.version}`)) } if (info.license) { - headerParts.push(info.license) + headerParts.push(chalk.green(info.license)) } if (info.depsCount !== undefined) { - headerParts.push(`deps: ${info.depsCount}`) + headerParts.push(`deps: ${chalk.cyan(info.depsCount)}`) } else { headerParts.push('deps: none') } if (info.versionsCount !== undefined) { - headerParts.push(`versions: ${info.versionsCount}`) + headerParts.push(`versions: ${chalk.cyan(info.versionsCount)}`) } const lines = [headerParts.join(' | ')] @@ -119,54 +121,62 @@ export async function handler ( } if (info.homepage) { - lines.push(info.homepage) + lines.push(chalk.underline.blue(info.homepage)) } if (info.keywords && info.keywords.length > 0) { lines.push('') - lines.push(`keywords: ${info.keywords.join(', ')}`) + lines.push(`keywords: ${chalk.cyan(info.keywords.join(', '))}`) + } + + if (info.dist) { + lines.push('') + lines.push(chalk.bold('dist')) + if (info.dist.tarball) { + lines.push(`.tarball: ${chalk.underline.blue(info.dist.tarball)}`) + } + if (info.dist.shasum) { + lines.push(`.shasum: ${chalk.green(info.dist.shasum)}`) + } + if (info.dist.integrity) { + lines.push(`.integrity: ${chalk.green(info.dist.integrity)}`) + } + if (info.dist.unpackedSize != null) { + lines.push(`.unpackedSize: ${chalk.blue(formatBytes(info.dist.unpackedSize))}`) + } } if (info.dependencies && Object.keys(info.dependencies).length > 0) { lines.push('') lines.push('dependencies:') - const depEntries = Object.entries(info.dependencies).map(([name, version]) => `${name}: ${version}`) + const depEntries = Object.entries(info.dependencies).map(([name, version]) => `${chalk.blue(name)}: ${version}`) lines.push(depEntries.join(', ')) } - if (info.dist) { - lines.push('') - lines.push('dist') - if (info.dist.tarball) { - lines.push(`.tarball: ${info.dist.tarball}`) - } - if (info.dist.shasum) { - lines.push(`.shasum: ${info.dist.shasum}`) - } - if (info.dist.integrity) { - lines.push(`.integrity: ${info.dist.integrity}`) - } - if (info.dist.unpackedSize != null) { - lines.push(`.unpackedSize: ${formatBytes(info.dist.unpackedSize)}`) - } - } - if (info.maintainers && info.maintainers.length > 0) { lines.push('') lines.push('maintainers:') for (const maintainer of info.maintainers) { - lines.push(`- ${maintainer.name}`) + const email = maintainer.email + const name = email ? `${chalk.blue(maintainer.name)} <${chalk.dim(email)}>` : chalk.blue(maintainer.name) + lines.push(`- ${name}`) } } if (info.distTags && Object.keys(info.distTags).length > 0) { lines.push('') - lines.push('dist-tags:') + lines.push(chalk.bold('dist-tags:')) for (const [tag, tagVersion] of Object.entries(info.distTags)) { - lines.push(`${tag}: ${tagVersion}`) + lines.push(`${chalk.blue(tag)}: ${tagVersion}`) } } + const publishedInfo = getPublishedInfo(info) + if (publishedInfo) { + lines.push('') + lines.push(publishedInfo) + } + return lines.join('\n') } @@ -196,3 +206,49 @@ function formatFieldValue (value: unknown): string { } return String(value) } + +function getPublishedInfo (info: ExtendedPackageInfo): string | null { + if (!info.version || !info.time) { + return null + } + const publishedTime = info.time[info.version] + if (!publishedTime) { + return null + } + const publishedDate = new Date(publishedTime) + if (isNaN(publishedDate.getTime())) { + return null + } + const timeAgo = formatTimeAgo(publishedDate) ?? 'just now' + + const publisher = getPublisher(info) + if (publisher) { + return `published ${chalk.cyan(timeAgo)} by ${publisher}` + } + return `published ${chalk.cyan(timeAgo)}` +} + +/** + * Retrieves the publisher name from package metadata. + * Checks fields in order: _npmUser, maintainers, author. + * Returns null if no publisher information is available. + */ +function getPublisher (info: ExtendedPackageInfo): string | null { + if (info._npmUser?.name) { + const email = info._npmUser.email + return email ? `${chalk.blue(info._npmUser.name)} <${chalk.dim(email)}>` : chalk.blue(info._npmUser.name) + } + if (info.maintainers && info.maintainers.length > 0) { + const first = info.maintainers[0] + const email = first.email + const name = email ? `${chalk.blue(first.name)} <${chalk.dim(email)}>` : chalk.blue(first.name) + if (info.maintainers.length === 1) { + return name + } + return `${name} et al.` + } + if (info.author) { + return String(info.author) + } + return null +} diff --git a/deps/inspection/commands/test/view.ts b/deps/inspection/commands/test/view.ts index ce75bcbb5e..309ff4a55e 100644 --- a/deps/inspection/commands/test/view.ts +++ b/deps/inspection/commands/test/view.ts @@ -209,3 +209,14 @@ test('view: time field returns publish timestamps', async () => { expect(typeof parsed).toBe('object') expect(parsed['1.0.0']).toBeDefined() }) + +test('view: published info includes timestamp', async () => { + const result = await view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, ['is-negative@1.0.0']) as string + expect(result).toMatch(/published .* ago/) +}) + +test('view: published info includes publisher when maintainer data is available', async () => { + // Note: is-negative package has maintainer data in the mock registry + const result = await view.handler(VIEW_OPTIONS as unknown as Config & ConfigContext, ['is-negative@1.0.0']) as string + expect(result).toMatch(/published .* ago by /) +}) diff --git a/resolving/npm-resolver/src/index.ts b/resolving/npm-resolver/src/index.ts index ff040af60b..e07adad265 100644 --- a/resolving/npm-resolver/src/index.ts +++ b/resolving/npm-resolver/src/index.ts @@ -76,7 +76,7 @@ export class NoMatchingVersionError extends PnpmError { let errorMessage: string if (opts.publishedBy && opts.immatureVersion && opts.packageMeta.time) { const time = new Date(opts.packageMeta.time[opts.immatureVersion]) - const releaseAgeText = formatTimeAgo(time) + const releaseAgeText = formatTimeAgo(time) ?? 'just now' const pkgName = opts.wantedDependency.alias ?? opts.packageMeta.name errorMessage = `Version ${opts.immatureVersion} (released ${releaseAgeText}) of ${pkgName} does not meet the minimumReleaseAge constraint` } else { @@ -88,26 +88,32 @@ export class NoMatchingVersionError extends PnpmError { } } -function formatTimeAgo (date: Date): string { +export function formatTimeAgo (date: Date): string | null { + const ts = date.getTime() + if (isNaN(ts)) { + return null + } const now = Date.now() - const diffMs = now - date.getTime() + const diffMs = now - ts - // Handle clock skew (future dates) and very recent releases (< 1 minute) - if (diffMs < 60 * 1000) { - return 'just now' + // Handle clock skew (future dates) + if (diffMs < 0) { + return null } - const diffMinutes = Math.floor(diffMs / (60 * 1000)) - const diffHours = Math.floor(diffMs / (60 * 60 * 1000)) - const diffDays = Math.floor(diffMs / (24 * 60 * 60 * 1000)) + const diffSec = Math.floor(diffMs / 1000) + const diffMin = Math.floor(diffSec / 60) + const diffHour = Math.floor(diffMin / 60) + const diffDay = Math.floor(diffHour / 24) + const diffMonth = Math.floor(diffDay / 30) + const diffYear = Math.floor(diffDay / 365) - if (diffHours >= 48) { - return `${diffDays} day${diffDays === 1 ? '' : 's'} ago` - } - if (diffMinutes >= 90) { - return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago` - } - return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago` + if (diffYear > 0) return `${diffYear} year${diffYear === 1 ? '' : 's'} ago` + if (diffMonth > 0) return `${diffMonth} month${diffMonth === 1 ? '' : 's'} ago` + if (diffDay > 0) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago` + if (diffHour > 0) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago` + if (diffMin > 0) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago` + return 'a few seconds ago' } export {