Skip to content

Commit

Permalink
Improve interactive mode (#1141)
Browse files Browse the repository at this point in the history
  • Loading branch information
raineorshine authored Jun 23, 2022
1 parent a64c50b commit 1d9ed15
Show file tree
Hide file tree
Showing 6 changed files with 314 additions and 123 deletions.
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,23 @@ ncu "/^(?!react-).*$/" # windows
-h, --help display help for command
```

## Interactive Mode

Choose exactly which upgrades to make in interactive mode:

```sh
ncu --interactive
ncu -i
```

Select which upgrades you want:

![ncu --interactive](https://user-images.githubusercontent.com/750276/175337598-cdbb2c46-64f8-44f5-b54e-4ad74d7b52b4.png)

Combine with `--format group` for a truly _luxe_ experience:

![ncu --interactive --format group](https://user-images.githubusercontent.com/750276/175336533-539261e4-5cf1-458f-9fbb-a7be2b477ebb.png)

## Doctor Mode

Usage: `ncu --doctor [-u] [options]`
Expand Down Expand Up @@ -266,7 +283,7 @@ npm run test
Saving partially upgraded package.json
```
## Configuration Files
## Config File
Use a `.ncurc.{json,yml,js}` file to specify configuration information.
You can specify file name and path using `--configFileName` and `--configFilePath`
Expand Down
51 changes: 28 additions & 23 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
"pacote": "^13.6.0",
"parse-github-url": "^1.0.2",
"progress": "^2.0.3",
"prompts": "^2.4.2",
"prompts": "https://github.com/raineorshine/prompts#ncu",
"rc-config-loader": "^4.1.0",
"remote-git-tags": "^3.0.0",
"rimraf": "^3.0.2",
Expand All @@ -99,7 +99,7 @@
"@types/pacote": "^11.1.4",
"@types/parse-github-url": "^1.0.0",
"@types/progress": "^2.0.5",
"@types/prompts": "^2.4.0",
"@types/prompts": "^2.0.14",
"@types/remote-git-tags": "^4.0.0",
"@types/rimraf": "^3.0.2",
"@types/semver": "^7.3.10",
Expand Down Expand Up @@ -139,10 +139,12 @@
],
"lockfile-lint": {
"allowed-schemes": [
"https:"
"https:",
"git+https:"
],
"allowed-hosts": [
"npm"
"npm",
"github.com"
],
"empty-hostname": false,
"type": "npm ",
Expand Down
4 changes: 3 additions & 1 deletion src/lib/runLocal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ async function runLocal(
? await getOwnerPerDependency(current, filteredUpgraded, options)
: undefined

if (!options.json || options.deep) {
// do not print upgrades for interactive mode
// interactive mode handles its own output
if (!options.interactive && (!options.json || options.deep)) {
printUpgrades(options, {
current,
upgraded: filteredUpgraded,
Expand Down
170 changes: 145 additions & 25 deletions src/lib/upgradePackageData.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import prompts from 'prompts'
import Chalk from 'chalk'
import prompts, { PromptObject } from 'prompts'
import { satisfies } from 'semver'
import { colorizeDiff } from '../version-util'
import { print } from '../logging'
import { print, printUpgrades, toDependencyTable } from '../logging'
import keyValueBy from '../lib/keyValueBy'
import { partChanged } from '../version-util'
import { Index } from '../types/IndexType'
import { Options } from '../types/Options'
import { Version } from '../types/Version'
Expand Down Expand Up @@ -32,38 +34,156 @@ async function upgradePackageData(
newVersions: Index<Version>,
options: Options = {},
) {
const chalk = options.color ? new Chalk.Instance({ level: 1 }) : Chalk

let newPkgData = pkgData

// interactive mode needs a newline before prompts
if (options.interactive) {
print(options, '')
}

// eslint-disable-next-line fp/no-loops
for (const dependency in newDependencies) {
if (!options.minimal || !satisfies(newVersions[dependency], oldDependencies[dependency])) {
if (options.interactive) {
const to = colorizeDiff(oldDependencies[dependency], newDependencies[dependency] || '')
const response = await prompts({
type: 'confirm',
name: 'value',
message: `Do you want to upgrade: ${dependency} ${oldDependencies[dependency]}${to}?`,
initial: true,
onState: state => {
if (state.aborted) {
process.nextTick(() => process.exit(1))
}
let newDependenciesFiltered = keyValueBy(newDependencies, (dep, version) =>
!options.minimal || !satisfies(newVersions[dep], oldDependencies[dep]) ? { [dep]: version } : null,
)

if (options.interactive) {
// use toDependencyTable to create choices that are properly padded to align vertically
const table = toDependencyTable({
from: oldDependencies,
to: newDependencies,
format: options.format,
})

const formattedLines = keyValueBy(table.toString().split('\n'), line => {
const dep = line.trim().split(' ')[0]
return {
[dep]: line.trim(),
}
})

let depsSelected: string[] = []

if (options.format?.includes('group')) {
depsSelected = []

const groups = keyValueBy<string, Index<string>>(newDependenciesFiltered, (dep, to, accum) => {
const from = oldDependencies[dep]
const partUpgraded = partChanged(from, to)
return {
...accum,
[partUpgraded]: {
...accum[partUpgraded],
[dep]: to,
},
})
if (!response.value) {
// continue loop to next dependency and skip updating newPkgData
continue
}
}
const expression = `"${dependency}"\\s*:\\s*"${escapeRegexp(`${oldDependencies[dependency]}"`)}`
const regExp = new RegExp(expression, 'g')
newPkgData = newPkgData.replace(regExp, `"${dependency}": "${newDependencies[dependency]}"`)
})

const choicesPatch = Object.keys(groups.patch || {}).map(dep => ({
title: formattedLines[dep],
value: dep,
selected: true,
}))

const choicesMinor = Object.keys(groups.minor || {}).map(dep => ({
title: formattedLines[dep],
value: dep,
selected: true,
}))

const choicesMajor = Object.keys(groups.major || {}).map(dep => ({
title: formattedLines[dep],
value: dep,
selected: false,
}))

const choicesNonsemver = Object.keys(groups.nonsemver || {}).map(dep => ({
title: formattedLines[dep],
value: dep,
selected: false,
}))

const response = await prompts({
choices: [
...(choicesPatch.length > 0
? [{ title: '\n' + chalk.green(chalk.bold('Patch') + ' Backwards-compatible bug fixes'), heading: true }]
: []),
...choicesPatch,
...(choicesMinor.length > 0
? [{ title: '\n' + chalk.cyan(chalk.bold('Minor') + ' Backwards-compatible features'), heading: true }]
: []),
...choicesMinor,
...(choicesMajor.length > 0
? [{ title: '\n' + chalk.red(chalk.bold('Major') + ' Potentially breaking API changes'), heading: true }]
: []),
...choicesMajor,
...(choicesNonsemver.length > 0
? [{ title: '\n' + chalk.magenta(chalk.bold('Non-Semver') + ' Versions less than 1.0.0'), heading: true }]
: []),
...choicesNonsemver,
{ title: ' ', heading: true },
],
hint: `
↑/↓: Select a package
Space: Toggle selection
a: Select all
Enter: Upgrade`,
instructions: false,
message: 'Choose which packages to update',
name: 'value',
optionsPerPage: 50,
type: 'multiselect',
onState: (state: any) => {
if (state.aborted) {
process.nextTick(() => process.exit(1))
}
},
} as PromptObject) // coerce to PromptObject until optionsPerPage is added to @types/prompts

depsSelected = response.value
} else {
const choices = Object.keys(newDependenciesFiltered).map(dep => ({
title: formattedLines[dep],
value: dep,
selected: true,
}))

const response = await prompts({
choices,
hint: 'Space to deselect. Enter to upgrade.',
instructions: false,
message: 'Choose which packages to update',
name: 'value',
optionsPerPage: 50,
type: 'multiselect',
onState: (state: any) => {
if (state.aborted) {
process.nextTick(() => process.exit(1))
}
},
} as PromptObject) // coerce to PromptObject until optionsPerPage is added to @types/prompts

depsSelected = response.value
}

newDependenciesFiltered = keyValueBy(depsSelected, (dep: string) => ({ [dep]: newDependencies[dep] }))

// in interactive mode, do not group upgrades afterwards since the prompts are grouped
printUpgrades(
{ ...options, format: (options.format || []).filter(formatType => formatType !== 'group') },
{
current: oldDependencies,
upgraded: newDependenciesFiltered,
total: Object.keys(newDependencies).length,
},
)
}

// eslint-disable-next-line fp/no-loops
for (const dependency in newDependenciesFiltered) {
const expression = `"${dependency}"\\s*:\\s*"${escapeRegexp(`${oldDependencies[dependency]}"`)}`
const regExp = new RegExp(expression, 'g')
newPkgData = newPkgData.replace(regExp, `"${dependency}": "${newDependencies[dependency]}"`)
}

return newPkgData
Expand Down
Loading

0 comments on commit 1d9ed15

Please sign in to comment.