feat: view command display published time (#11532)

* **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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
btea
2026-05-11 18:28:04 +08:00
committed by GitHub
parent 6bd335e65c
commit 8543b89fb2
4 changed files with 129 additions and 44 deletions

View File

@@ -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
```

View File

@@ -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<string, unknown> {
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
}

View File

@@ -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 /)
})

View File

@@ -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 {