mirror of
https://github.com/pnpm/pnpm.git
synced 2026-05-12 01:54:53 -04:00
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:
12
.changeset/fix-11529-view-published-time.md
Normal file
12
.changeset/fix-11529-view-published-time.md
Normal 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
|
||||
```
|
||||
112
deps/inspection/commands/src/view/index.ts
vendored
112
deps/inspection/commands/src/view/index.ts
vendored
@@ -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
|
||||
}
|
||||
|
||||
11
deps/inspection/commands/test/view.ts
vendored
11
deps/inspection/commands/test/view.ts
vendored
@@ -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 /)
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user