diff --git a/package.json b/package.json index d14cc9c..dda0537 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "author": "Salesforce", "bugs": "https://github.com/oclif/plugin-version/issues", "dependencies": { - "@oclif/core": "^3.26.5" + "@oclif/core": "^3.26.5", + "ansis": "^3.2.0" }, "devDependencies": { "@commitlint/config-conventional": "^18", diff --git a/src/commands/version.ts b/src/commands/version.ts index 9b9e0c1..4a7cb86 100644 --- a/src/commands/version.ts +++ b/src/commands/version.ts @@ -1,10 +1,87 @@ import {Command, Flags, Interfaces} from '@oclif/core' +import {Ansis} from 'ansis' +import {exec} from 'node:child_process' import {EOL} from 'node:os' +const ansis = new Ansis() + export type VersionDetail = { pluginVersions?: string[] } & Omit +type NpmDetails = { + date: string + 'dist-tags': Record + name: string + time: Record + version: string + versions: string[] +} + +async function getNpmDetails(pkg: string): Promise { + return new Promise((resolve) => { + exec(`npm view ${pkg} --json`, (error, stdout) => { + if (error) { + resolve(false) + } else { + resolve(JSON.parse(stdout) as NpmDetails) + } + }) + }) +} + +function daysAgo(date: string): number { + const now = new Date() + const then = new Date(date) + const diff = now.getTime() - then.getTime() + return Math.floor(diff / (1000 * 60 * 60 * 24)) +} + +function humanReadableDate(date: string): string { + return new Date(date).toDateString() +} + +async function formatPlugins( + config: Interfaces.Config, + plugins: Record, +): Promise { + const sorted = Object.entries(plugins) + .map(([name, plugin]) => ({name, ...plugin})) + .sort((a, b) => (a.name > b.name ? 1 : -1)) + + return Promise.all( + sorted.map(async (plugin) => { + const base = + `${getFriendlyName(config, plugin.name)} ${ansis.dim(plugin.version)} ${ansis.dim(`(${plugin.type})`)} ${ + plugin.type === 'link' ? ansis.dim(plugin.root) : '' + }`.trim() + if (plugin.type === 'user') { + const npmDetails = await getNpmDetails(plugin.name) + const publishedString = npmDetails + ? ansis.dim( + ` published ${daysAgo(npmDetails.time[plugin.version])} days ago (${humanReadableDate(npmDetails.time[plugin.version])})`, + ) + : '' + const notLatestWarning = + npmDetails && plugin.version !== npmDetails['dist-tags'].latest + ? ansis.red(` (latest is ${npmDetails['dist-tags'].latest})`) + : '' + return `${base}${publishedString}${notLatestWarning}` + } + + return base + }), + ) +} + +function getFriendlyName(config: Interfaces.Config, name: string): string { + const {scope} = config.pjson.oclif + if (!scope) return name + const match = name.match(`@${scope}/plugin-(.+)`) + if (!match) return name + return match[1] +} + export default class Version extends Command { static enableJsonFlag = true @@ -23,28 +100,39 @@ export default class Version extends Command { let output = `${this.config.userAgent}` if (flags.verbose) { - versionDetail.pluginVersions = this.formatPlugins(pluginVersions ?? {}) + const details = await getNpmDetails(this.config.pjson.name) + + const cliPublishedString = details + ? ansis.dim( + ` published ${daysAgo(details.time[details.version])} days ago (${humanReadableDate(details.time[details.version])})`, + ) + : '' + const notLatestWarning = + details && this.config.version !== details['dist-tags'].latest + ? ansis.red(` (latest is ${details['dist-tags'].latest})`) + : '' + versionDetail.pluginVersions = await formatPlugins(this.config, pluginVersions ?? {}) versionDetail.shell ??= 'unknown' - output = ` CLI Version: -\t${versionDetail.cliVersion} + output = ` ${ansis.bold('CLI Version')}: +\t${versionDetail.cliVersion}${cliPublishedString}${notLatestWarning} - Architecture: + ${ansis.bold('Architecture')}: \t${versionDetail.architecture} - Node Version: + ${ansis.bold('Node Version')}: \t${versionDetail.nodeVersion} - Plugin Version: -\t${flags.verbose ? (versionDetail.pluginVersions ?? []).join(EOL + '\t') : ''} + ${ansis.bold('Plugin Version')}: +\t${(versionDetail.pluginVersions ?? []).join(EOL + '\t')} - OS and Version: + ${ansis.bold('OS and Version')}: \t${versionDetail.osVersion} - Shell: + ${ansis.bold('Shell')}: \t${versionDetail.shell} - Root Path: + ${ansis.bold('Root Path')}: \t${versionDetail.rootPath} ` } @@ -52,30 +140,14 @@ export default class Version extends Command { this.log(output) return flags.verbose - ? versionDetail + ? { + ...versionDetail, + pluginVersions: versionDetail.pluginVersions?.map((plugin) => ansis.strip(plugin)), + } : { architecture: versionDetail.architecture, cliVersion: versionDetail.cliVersion, nodeVersion: versionDetail.nodeVersion, } } - - private formatPlugins(plugins: Record): string[] { - return Object.entries(plugins) - .map(([name, plugin]) => ({name, ...plugin})) - .sort((a, b) => (a.name > b.name ? 1 : -1)) - .map((plugin) => - `${this.getFriendlyName(plugin.name)} ${plugin.version} (${plugin.type}) ${ - plugin.type === 'link' ? plugin.root : '' - }`.trim(), - ) - } - - private getFriendlyName(name: string): string { - const {scope} = this.config.pjson.oclif - if (!scope) return name - const match = name.match(`@${scope}/plugin-(.+)`) - if (!match) return name - return match[1] - } } diff --git a/test/commands/version.test.ts b/test/commands/version.test.ts index 47c13dc..0d7a7cb 100644 --- a/test/commands/version.test.ts +++ b/test/commands/version.test.ts @@ -1,8 +1,11 @@ import {expect, test} from '@oclif/test' +import {Ansis} from 'ansis' import {readFileSync} from 'node:fs' import {release as osRelease, type as osType, userInfo as osUserInfo} from 'node:os' import {resolve, sep} from 'node:path' +const ansis = new Ansis() + const pjson = JSON.parse(readFileSync(resolve('package.json'), 'utf8')) const getShell = () => osUserInfo().shell?.split(sep)?.pop() || 'unknown' @@ -22,19 +25,20 @@ describe('version', () => { .stdout() .command(['version', '--verbose']) .end('runs version --verbose', (output) => { - expect(output.stdout).to.contain(' CLI Version:') - expect(output.stdout).to.contain(`\t@oclif/plugin-version/${pjson.version}`) - expect(output.stdout).to.contain(' Architecture:') - expect(output.stdout).to.contain(`\t${process.platform}-${process.arch}`) - expect(output.stdout).to.contain(' Node Version:') - expect(output.stdout).to.contain(`\tnode-${process.version}`) - expect(output.stdout).to.contain(' Plugin Version:') - expect(output.stdout).to.contain(' OS and Version:') - expect(output.stdout).to.contain(`\t${osType()} ${osRelease()}`) - expect(output.stdout).to.contain(' Shell:') - expect(output.stdout).to.contain(`\t${getShell()}`) - expect(output.stdout).to.contain(' Root Path:') - expect(output.stdout).to.contain(`\t${process.cwd()}`) + const stdout = ansis.strip(output.stdout) + expect(stdout).to.contain(' CLI Version:') + expect(stdout).to.contain(`\t@oclif/plugin-version/${pjson.version}`) + expect(stdout).to.contain(' Architecture:') + expect(stdout).to.contain(`\t${process.platform}-${process.arch}`) + expect(stdout).to.contain(' Node Version:') + expect(stdout).to.contain(`\tnode-${process.version}`) + expect(stdout).to.contain(' Plugin Version:') + expect(stdout).to.contain(' OS and Version:') + expect(stdout).to.contain(`\t${osType()} ${osRelease()}`) + expect(stdout).to.contain(' Shell:') + expect(stdout).to.contain(`\t${getShell()}`) + expect(stdout).to.contain(' Root Path:') + expect(stdout).to.contain(`\t${process.cwd()}`) }) test diff --git a/yarn.lock b/yarn.lock index e7cbada..c8e2321 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2431,6 +2431,11 @@ ansicolors@~0.3.2: resolved "https://registry.yarnpkg.com/ansicolors/-/ansicolors-0.3.2.tgz#665597de86a9ffe3aa9bfbe6cae5c6ea426b4979" integrity sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg== +ansis@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.2.0.tgz#0e050c5be94784f32ffdac4b84fccba064aeae4b" + integrity sha512-Yk3BkHH9U7oPyCN3gL5Tc7CpahG/+UFv/6UG03C311Vy9lzRmA5uoxDTpU9CO3rGHL6KzJz/pdDeXZCZ5Mu/Sg== + anymatch@~3.1.2: version "3.1.3" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"