feat(interactive-update): group dependancies by type, add package url column (#6978)

* feat(interactive-update): group dependancies by type, add package url column

Partially resolves https://github.com/pnpm/pnpm/issues/6314

* refactor: update

* docs: update changeset

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Clark Tomlinson
2023-08-30 15:26:39 -04:00
committed by GitHub
parent ef3609049f
commit 81e5ada3a9
7 changed files with 323 additions and 159 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/plugin-commands-installation": minor
"pnpm": patch
---
Improve the `pnpm update --interactive` output. Dependencies are grouped by dependency types and a new column is added with links to the outdated package's docs [#6978](https://github.com/pnpm/pnpm/pull/6978).

View File

@@ -2,80 +2,120 @@ import colorizeSemverDiff from '@pnpm/colorize-semver-diff'
import { type OutdatedPackage } from '@pnpm/outdated'
import semverDiff from '@pnpm/semver-diff'
import { getBorderCharacters, table } from '@zkochan/table'
import { pipe, groupBy, pluck, uniqBy, pickBy, and } from 'ramda'
import isEmpty from 'ramda/src/isEmpty'
import unnest from 'ramda/src/unnest'
export function getUpdateChoices (outdatedPkgsOfProjects: OutdatedPackage[]) {
const allOutdatedPkgs = mergeOutdatedPkgs(outdatedPkgsOfProjects)
if (isEmpty(allOutdatedPkgs)) {
export interface ChoiceRow {
name: string
value: string
disabled?: boolean
}
type ChoiceGroup = Array<{
name: string
choices: ChoiceRow[]
}>
export function getUpdateChoices (outdatedPkgsOfProjects: OutdatedPackage[], workspacesEnabled: boolean) {
if (isEmpty(outdatedPkgsOfProjects)) {
return []
}
const rowsGroupedByPkgs = Object.entries(allOutdatedPkgs)
.sort(([pkgName1], [pkgName2]) => pkgName1.localeCompare(pkgName2))
.map(([pkgName, outdatedPkgs]) => ({
pkgName,
rows: outdatedPkgsRows(Object.values(outdatedPkgs)),
}))
const renderedTable = alignColumns(
unnest(rowsGroupedByPkgs.map(({ rows }) => rows))
const pkgUniqueKey = (outdatedPkg: OutdatedPackage) => {
return JSON.stringify([outdatedPkg.packageName, outdatedPkg.latestManifest?.version, outdatedPkg.current])
}
const dedupeAndGroupPkgs = pipe(
uniqBy((outdatedPkg: OutdatedPackage) => pkgUniqueKey(outdatedPkg)),
groupBy((outdatedPkg: OutdatedPackage) => outdatedPkg.belongsTo)
)
const choices = []
let i = 0
for (const { pkgName, rows } of rowsGroupedByPkgs) {
choices.push({
message: renderedTable
.slice(i, i + rows.length)
.join('\n '),
name: pkgName,
})
i += rows.length
const groupPkgsByType = dedupeAndGroupPkgs(outdatedPkgsOfProjects)
const headerRow = {
Package: true,
Current: true,
' ': true,
Target: true,
Workspace: workspacesEnabled,
URL: true,
}
return choices
// returns only the keys that are true
const header: string[] = Object.keys(pickBy(and, headerRow))
return Object.entries(groupPkgsByType).reduce((finalChoices: ChoiceGroup, [depGroup, choiceRows]) => {
if (choiceRows.length === 0) {
return finalChoices
}
const rawChoices = choiceRows.map(choice => buildPkgChoice(choice, workspacesEnabled))
// add in a header row for each group
rawChoices.unshift({
raw: header,
name: '',
disabled: true,
})
const renderedTable = alignColumns(pluck('raw', rawChoices)).filter(Boolean)
const choices = rawChoices.map((outdatedPkg, i) => {
if (i === 0) {
return {
name: renderedTable[i],
value: '',
disabled: true,
hint: '',
}
}
return {
name: renderedTable[i],
value: outdatedPkg.name,
}
})
finalChoices.push({ name: depGroup, choices })
return finalChoices
}, [])
}
function mergeOutdatedPkgs (outdatedPkgs: OutdatedPackage[]) {
const allOutdatedPkgs: Record<string, Record<string, OutdatedPackage>> = {}
for (const outdatedPkg of outdatedPkgs) {
if (!allOutdatedPkgs[outdatedPkg.packageName]) {
allOutdatedPkgs[outdatedPkg.packageName] = {}
}
const key = JSON.stringify([
outdatedPkg.latestManifest?.version,
outdatedPkg.current,
])
if (!allOutdatedPkgs[outdatedPkg.packageName][key]) {
allOutdatedPkgs[outdatedPkg.packageName][key] = outdatedPkg
continue
}
if (allOutdatedPkgs[outdatedPkg.packageName][key].belongsTo === 'dependencies') continue
if (outdatedPkg.belongsTo !== 'devDependencies') {
allOutdatedPkgs[outdatedPkg.packageName][key].belongsTo = outdatedPkg.belongsTo
}
function buildPkgChoice (outdatedPkg: OutdatedPackage, workspacesEnabled: boolean): { raw: string[], name: string, disabled?: boolean } {
const sdiff = semverDiff(outdatedPkg.wanted, outdatedPkg.latestManifest!.version)
const nextVersion = sdiff.change === null
? outdatedPkg.latestManifest!.version
: colorizeSemverDiff(sdiff as any) // eslint-disable-line @typescript-eslint/no-explicit-any
const label = outdatedPkg.packageName
const lineParts = {
label,
current: outdatedPkg.current,
arrow: '',
nextVersion,
workspace: outdatedPkg.workspace,
url: getPkgUrl(outdatedPkg),
}
if (!workspacesEnabled) {
delete lineParts.workspace
}
return {
raw: Object.values(lineParts),
name: outdatedPkg.packageName,
}
return allOutdatedPkgs
}
function outdatedPkgsRows (outdatedPkgs: OutdatedPackage[]) {
return outdatedPkgs
.map((outdatedPkg) => {
const sdiff = semverDiff(outdatedPkg.wanted, outdatedPkg.latestManifest!.version)
const nextVersion = sdiff.change === null
? outdatedPkg.latestManifest!.version
: colorizeSemverDiff(sdiff as any) // eslint-disable-line @typescript-eslint/no-explicit-any
let label = outdatedPkg.packageName
switch (outdatedPkg.belongsTo) {
case 'devDependencies': {
label += ' (dev)'
break
}
case 'optionalDependencies': {
label += ' (optional)'
break
}
}
return [label, outdatedPkg.current, '', nextVersion]
})
function getPkgUrl (pkg: OutdatedPackage) {
if (pkg.latestManifest?.homepage) {
return pkg.latestManifest?.homepage
}
if (typeof pkg.latestManifest?.repository !== 'string') {
if (pkg.latestManifest?.repository?.url) {
return pkg.latestManifest?.repository?.url
}
}
return ''
}
function alignColumns (rows: string[][]) {
@@ -86,10 +126,16 @@ function alignColumns (rows: string[][]) {
columnDefault: {
paddingLeft: 0,
paddingRight: 1,
wrapWord: true,
},
columns: {
1: { alignment: 'right' },
},
columns:
{
0: { width: 50, truncate: 100 },
1: { width: 15, alignment: 'right' },
3: { width: 15 },
4: { paddingLeft: 2 },
5: { paddingLeft: 2 },
},
drawHorizontalLine: () => false,
}
).split('\n')

View File

@@ -12,12 +12,12 @@ import { outdatedDepsOfProjects } from '@pnpm/outdated'
import { prompt } from 'enquirer'
import chalk from 'chalk'
import pick from 'ramda/src/pick'
import pluck from 'ramda/src/pluck'
import unnest from 'ramda/src/unnest'
import renderHelp from 'render-help'
import { type InstallCommandOptions } from '../install'
import { installDeps } from '../installDeps'
import { getUpdateChoices } from './getUpdateChoices'
import { type ChoiceRow, getUpdateChoices } from './getUpdateChoices'
export function rcOptionsTypes () {
return pick([
'cache-dir',
@@ -202,7 +202,8 @@ async function interactiveUpdate (
},
timeout: opts.fetchTimeout,
})
const choices = getUpdateChoices(unnest(outdatedPkgsOfProjects))
const workspacesEnabled = !!opts.workspaceDir
const choices = getUpdateChoices(unnest(outdatedPkgsOfProjects), workspacesEnabled)
if (choices.length === 0) {
if (opts.latest) {
return 'All of your dependencies are already up to date'
@@ -221,6 +222,9 @@ async function interactiveUpdate (
`${chalk.cyan('<i>')} to invert selection)`,
name: 'updateDependencies',
pointer: '',
result () {
return this.selected
},
styles: {
dark: chalk.white,
em: chalk.bgBlack.whiteBright,
@@ -247,9 +251,12 @@ async function interactiveUpdate (
// Otherwise, pnpm CLI would print an error and confuse users.
// See related issue: https://github.com/enquirer/enquirer/issues/225
globalInfo('Update canceled')
process.exit(0)
},
} as any) as any // eslint-disable-line @typescript-eslint/no-explicit-any
return update(updateDependencies, opts)
const updatePkgNames = pluck('value', updateDependencies as ChoiceRow[])
return update(updatePkgNames, opts)
}
async function update (

View File

@@ -11,6 +11,7 @@ test('getUpdateChoices()', () => {
latestManifest: {
name: 'foo',
version: '2.0.0',
homepage: 'https://pnpm.io/',
},
packageName: 'foo',
wanted: '1.0.0',
@@ -22,6 +23,9 @@ test('getUpdateChoices()', () => {
latestManifest: {
name: 'foo',
version: '2.0.0',
repository: {
url: 'git://github.com/pnpm/pnpm.git',
},
},
packageName: 'foo',
wanted: '1.0.0',
@@ -70,23 +74,60 @@ test('getUpdateChoices()', () => {
packageName: 'foo',
wanted: '1.0.1',
},
]))
], false))
.toStrictEqual([
{
message: chalk`foo 1.0.0 {redBright.bold 2.0.0} \n foo (dev) 1.0.1 1.{yellowBright.bold 2.0} `,
name: 'foo',
name: 'dependencies',
choices: [
{
name: 'Package Current Target URL ',
disabled: true,
hint: '',
value: '',
},
{
name: chalk`foo 1.0.0 {redBright.bold 2.0.0} https://pnpm.io/ `,
value: 'foo',
},
],
},
{
message: chalk`qar (dev) 1.0.0 1.{yellowBright.bold 2.0} `,
name: 'qar',
name: 'devDependencies',
choices: [
{
name: 'Package Current Target URL ',
disabled: true,
hint: '',
value: '',
},
{
name: chalk`qar 1.0.0 1.{yellowBright.bold 2.0} `,
value: 'qar',
},
{
name: chalk`zoo 1.1.0 1.{yellowBright.bold 2.0} `,
value: 'zoo',
},
{
name: chalk`foo 1.0.1 1.{yellowBright.bold 2.0} `,
value: 'foo',
},
],
},
{
message: chalk`qaz (optional) 1.0.1 1.{yellowBright.bold 2.0} `,
name: 'qaz',
},
{
message: chalk`zoo (dev) 1.1.0 1.{yellowBright.bold 2.0} `,
name: 'zoo',
name: 'optionalDependencies',
choices: [
{
name: 'Package Current Target URL ',
disabled: true,
hint: '',
value: '',
},
{
name: chalk`qaz 1.0.1 1.{yellowBright.bold 2.0} `,
value: 'qaz',
},
],
},
])
})

View File

@@ -1,22 +1,18 @@
import path from 'path'
import { readProjects } from '@pnpm/filter-workspace-packages'
import { type Lockfile } from '@pnpm/lockfile-types'
import { add, install, update } from '@pnpm/plugin-commands-installation'
import { prepare, preparePackages } from '@pnpm/prepare'
import { REGISTRY_MOCK_PORT } from '@pnpm/registry-mock'
import readYamlFile from 'read-yaml-file'
import chalk from 'chalk'
import * as enquirer from 'enquirer'
jest.mock('enquirer', () => ({ prompt: jest.fn() }))
// eslint-disable-next-line
import * as enquirer from 'enquirer'
// eslint-disable-next-line
const prompt = enquirer.prompt as any
// eslint-disable-next-line
import { add, install, update } from '@pnpm/plugin-commands-installation'
const REGISTRY_URL = `http://localhost:${REGISTRY_MOCK_PORT}`
const DEFAULT_OPTIONS = {
@@ -59,21 +55,33 @@ test('interactively update', async () => {
})
const storeDir = path.resolve('pnpm-store')
await add.handler({
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
dir: process.cwd(),
linkWorkspacePackages: true,
save: false,
storeDir,
}, [
'is-negative@1.0.0',
'is-positive@2.0.0',
'micromatch@3.0.0',
])
const headerChoice = {
name: 'Package Current Target URL ',
disabled: true,
hint: '',
value: '',
}
await add.handler(
{
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
dir: process.cwd(),
linkWorkspacePackages: true,
save: false,
storeDir,
},
['is-negative@1.0.0', 'is-positive@2.0.0', 'micromatch@3.0.0']
)
prompt.mockResolvedValue({
updateDependencies: ['is-negative'],
updateDependencies: [
{
value: 'is-negative',
name: chalk`is-negative 1.0.0 1.0.{greenBright.bold 1} https://pnpm.io/ `,
},
],
})
prompt.mockClear()
@@ -89,23 +97,32 @@ test('interactively update', async () => {
expect(prompt.mock.calls[0][0].choices).toStrictEqual([
{
message: chalk`is-negative 1.0.0 1.0.{greenBright.bold 1} `,
name: 'is-negative',
},
{
message: chalk`micromatch 3.0.0 3.{yellowBright.bold 1.10} `,
name: 'micromatch',
choices: [
headerChoice,
{
name: chalk`is-negative 1.0.0 1.0.{greenBright.bold 1} `,
value: 'is-negative',
},
{
name: chalk`micromatch 3.0.0 3.{yellowBright.bold 1.10} `,
value: 'micromatch',
},
],
name: 'dependencies',
},
])
expect(prompt).toBeCalledWith(expect.objectContaining({
footer: '\nEnter to start updating. Ctrl-c to cancel.',
message: 'Choose which packages to update ' +
expect(prompt).toBeCalledWith(
expect.objectContaining({
footer: '\nEnter to start updating. Ctrl-c to cancel.',
message:
'Choose which packages to update ' +
`(Press ${chalk.cyan('<space>')} to select, ` +
`${chalk.cyan('<a>')} to toggle all, ` +
`${chalk.cyan('<i>')} to invert selection)`,
name: 'updateDependencies',
type: 'multiselect',
}))
name: 'updateDependencies',
type: 'multiselect',
})
)
{
const lockfile = await project.readLockfile()
@@ -129,27 +146,36 @@ test('interactively update', async () => {
expect(prompt.mock.calls[0][0].choices).toStrictEqual([
{
message: chalk`is-negative 1.0.1 {redBright.bold 2.1.0} `,
name: 'is-negative',
},
{
message: chalk`is-positive 2.0.0 {redBright.bold 3.1.0} `,
name: 'is-positive',
},
{
message: chalk`micromatch 3.0.0 {redBright.bold 4.0.5} `,
name: 'micromatch',
choices: [
headerChoice,
{
name: chalk`is-negative 1.0.1 {redBright.bold 2.1.0} `,
value: 'is-negative',
},
{
name: chalk`is-positive 2.0.0 {redBright.bold 3.1.0} `,
value: 'is-positive',
},
{
name: chalk`micromatch 3.0.0 {redBright.bold 4.0.5} `,
value: 'micromatch',
},
],
name: 'dependencies',
},
])
expect(prompt).toBeCalledWith(expect.objectContaining({
footer: '\nEnter to start updating. Ctrl-c to cancel.',
message: 'Choose which packages to update ' +
expect(prompt).toBeCalledWith(
expect.objectContaining({
footer: '\nEnter to start updating. Ctrl-c to cancel.',
message:
'Choose which packages to update ' +
`(Press ${chalk.cyan('<space>')} to select, ` +
`${chalk.cyan('<a>')} to toggle all, ` +
`${chalk.cyan('<i>')} to invert selection)`,
name: 'updateDependencies',
type: 'multiselect',
}))
name: 'updateDependencies',
type: 'multiselect',
})
)
{
const lockfile = await project.readLockfile()
@@ -180,10 +206,18 @@ test('interactive update of dev dependencies only', async () => {
const storeDir = path.resolve('store')
prompt.mockResolvedValue({
updateDependencies: ['is-negative'],
updateDependencies: [
{
value: 'is-negative',
name: chalk`is-negative 1.0.0 1.0.{greenBright.bold 1} https://pnpm.io/ `,
},
],
})
const { allProjects, selectedProjectsGraph } = await readProjects(process.cwd(), [])
const { allProjects, selectedProjectsGraph } = await readProjects(
process.cwd(),
[]
)
await install.handler({
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
@@ -218,11 +252,10 @@ test('interactive update of dev dependencies only', async () => {
const lockfile = await readYamlFile<Lockfile>('pnpm-lock.yaml')
expect(
Object.keys(lockfile.packages ?? {})
).toStrictEqual(
['/is-negative@1.0.1', '/is-negative@2.1.0']
)
expect(Object.keys(lockfile.packages ?? {})).toStrictEqual([
'/is-negative@1.0.1',
'/is-negative@2.1.0',
])
})
test('interactively update should ignore dependencies from the ignoreDependencies field', async () => {
@@ -243,21 +276,21 @@ test('interactively update should ignore dependencies from the ignoreDependencie
})
const storeDir = path.resolve('pnpm-store')
await add.handler({
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
dir: process.cwd(),
linkWorkspacePackages: true,
save: false,
storeDir,
}, [
'is-negative@1.0.0',
'is-positive@2.0.0',
'micromatch@3.0.0',
])
await add.handler(
{
...DEFAULT_OPTIONS,
cacheDir: path.resolve('cache'),
dir: process.cwd(),
linkWorkspacePackages: true,
save: false,
storeDir,
},
['is-negative@1.0.0', 'is-positive@2.0.0', 'micromatch@3.0.0']
)
prompt.mockResolvedValue({
updateDependencies: ['micromatch'],
updateDependencies: [{ value: 'micromatch', name: 'anything' }],
})
prompt.mockClear()
@@ -270,21 +303,38 @@ test('interactively update should ignore dependencies from the ignoreDependencie
storeDir,
})
expect(prompt.mock.calls[0][0].choices).toStrictEqual([
{
message: chalk`micromatch 3.0.0 3.{yellowBright.bold 1.10} `,
name: 'micromatch',
},
])
expect(prompt).toBeCalledWith(expect.objectContaining({
footer: '\nEnter to start updating. Ctrl-c to cancel.',
message: 'Choose which packages to update ' +
expect(prompt.mock.calls[0][0].choices).toStrictEqual(
[
{
choices: [
{
disabled: true,
hint: '',
name: 'Package Current Target URL ',
value: '',
},
{
name: chalk`micromatch 3.0.0 3.{yellowBright.bold 1.10} `,
value: 'micromatch',
},
],
name: 'dependencies',
},
]
)
expect(prompt).toBeCalledWith(
expect.objectContaining({
footer: '\nEnter to start updating. Ctrl-c to cancel.',
message:
'Choose which packages to update ' +
`(Press ${chalk.cyan('<space>')} to select, ` +
`${chalk.cyan('<a>')} to toggle all, ` +
`${chalk.cyan('<i>')} to invert selection)`,
name: 'updateDependencies',
type: 'multiselect',
}))
name: 'updateDependencies',
type: 'multiselect',
})
)
{
const lockfile = await project.readLockfile()

View File

@@ -32,6 +32,7 @@ export interface OutdatedPackage {
latestManifest?: PackageManifest
packageName: string
wanted: string
workspace?: string
}
export async function outdated (
@@ -118,6 +119,7 @@ export async function outdated (
latestManifest: undefined,
packageName,
wanted,
workspace: opts.manifest.name,
})
}
return
@@ -137,6 +139,8 @@ export async function outdated (
latestManifest,
packageName,
wanted,
workspace: opts.manifest.name,
})
return
}
@@ -149,6 +153,8 @@ export async function outdated (
latestManifest,
packageName,
wanted,
workspace: opts.manifest.name,
})
}
})

View File

@@ -150,6 +150,7 @@ test('outdated()', async () => {
latestManifest: undefined,
packageName: 'from-github',
wanted: 'github.com/blabla/from-github/d5f8d5500f7faf593d32e134c1b0043ff69151b3',
workspace: 'wanted-shrinkwrap',
},
{
alias: 'from-github-2',
@@ -158,6 +159,7 @@ test('outdated()', async () => {
latestManifest: undefined,
packageName: 'from-github-2',
wanted: 'github.com/blabla/from-github-2/d5f8d5500f7faf593d32e134c1b0043ff69151b3',
workspace: 'wanted-shrinkwrap',
},
{
alias: 'is-negative',
@@ -169,6 +171,7 @@ test('outdated()', async () => {
},
packageName: 'is-negative',
wanted: '1.1.0',
workspace: 'wanted-shrinkwrap',
},
{
alias: 'is-positive',
@@ -180,6 +183,7 @@ test('outdated()', async () => {
},
packageName: 'is-positive',
wanted: '3.1.0',
workspace: 'wanted-shrinkwrap',
},
])
})
@@ -236,6 +240,7 @@ test('outdated() should return deprecated package even if its current version is
},
packageName: 'deprecated-pkg',
wanted: '1.0.0',
workspace: 'wanted-shrinkwrap',
},
])
})
@@ -355,6 +360,7 @@ test('using a matcher', async () => {
},
packageName: 'is-negative',
wanted: '1.1.0',
workspace: 'wanted-shrinkwrap',
},
])
})
@@ -427,6 +433,7 @@ test('outdated() aliased dependency', async () => {
},
packageName: 'is-positive',
wanted: '3.1.0',
workspace: 'wanted-shrinkwrap',
},
])
})
@@ -666,6 +673,7 @@ test('should ignore dependencies as expected', async () => {
},
packageName: 'is-positive',
wanted: '3.1.0',
workspace: 'wanted-shrinkwrap',
},
])
})