Skip to content

Commit

Permalink
feat: interactive package management (#202)
Browse files Browse the repository at this point in the history
Co-authored-by: Anthony Fu <[email protected]>
  • Loading branch information
gearonixx and antfu authored Jul 13, 2024
1 parent f8edb48 commit 7787a59
Show file tree
Hide file tree
Showing 6 changed files with 261 additions and 10 deletions.
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ ni -g eslint
# this uses default agent, regardless your current working directory
```

```bash
ni -i

# interactively select the dependency to install
# search for packages by name
```

<br>

### `nr` - run
Expand Down Expand Up @@ -139,6 +146,20 @@ nun webpack
# bun remove webpack
```

```bash
nun

# interactively select
# the dependency to remove
```

```bash
nun -m

# interactive select,
# but with multiple dependencies
```

```bash
nun -g silent

Expand Down
98 changes: 97 additions & 1 deletion src/commands/ni.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,100 @@
import process from 'node:process'
import type { Choice } from '@posva/prompts'
import prompts from '@posva/prompts'
import { Fzf } from 'fzf'
import c from 'kleur'
import { parseNi } from '../parse'
import { runCli } from '../runner'
import { exclude } from '../utils'
import { fetchNpmPackages } from '../fetch'

runCli(parseNi)
runCli(async (agent, args, ctx) => {
const isInteractive = args[0] === '-i'

if (isInteractive) {
let fetchPattern: string

if (args[1] && !args[1].startsWith('-')) {
fetchPattern = args[1]
}
else {
const { pattern } = await prompts({
type: 'text',
name: 'pattern',
message: 'search for package',
})

fetchPattern = pattern
}

if (!fetchPattern) {
process.exitCode = 1
return
}

const packages = await fetchNpmPackages(fetchPattern)

if (!packages.length) {
console.error('No results found')
process.exitCode = 1
return
}

const fzf = new Fzf(packages, {
selector: (item: Choice) => item.title,
casing: 'case-insensitive',
})

const { dependency } = await prompts({
type: 'autocomplete',
name: 'dependency',
choices: packages,
instructions: false,
message: 'choose a package to install',
limit: 15,
async suggest(input: string, choices: Choice[]) {
const results = fzf.find(input)
return results.map(r => choices.find((c: any) => c.value === r.item.value))
},
})

if (!dependency) {
process.exitCode = 1
return
}

args = exclude(args, '-d', '-p', '-i')

/**
* yarn and bun do not support
* the installation of peers programmatically
*/
const canInstallPeers = ['npm', 'pnpm'].includes(agent)

const { mode } = await prompts({
type: 'select',
name: 'mode',
message: `install ${c.yellow(dependency.name)} as`,
choices: [
{
title: 'prod',
value: '',
selected: true,
},
{
title: 'dev',
value: '-D',
},
{
title: `peer`,
value: '--save-peer',
disabled: !canInstallPeers,
},
],
})

args.push(dependency.name, mode)
}

return parseNi(agent, args, ctx)
})
7 changes: 1 addition & 6 deletions src/commands/nr.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import process from 'node:process'
import type { Choice } from '@posva/prompts'
import prompts from '@posva/prompts'
import c from 'kleur'
import { Fzf } from 'fzf'
import { dump, load } from '../storage'
import { parseNr } from '../parse'
import { getPackageJSON } from '../fs'
import { runCli } from '../runner'
import { limitText } from '../utils'

runCli(async (agent, args, ctx) => {
const storage = await load()
Expand Down Expand Up @@ -44,11 +44,6 @@ runCli(async (agent, args, ctx) => {

const terminalColumns = process.stdout?.columns || 80

function limitText(text: string, maxWidth: number) {
if (text.length <= maxWidth)
return text
return `${text.slice(0, maxWidth)}${c.dim('…')}`
}
const choices: Choice[] = raw
.map(({ key, description }) => ({
title: key,
Expand Down
73 changes: 72 additions & 1 deletion src/commands/nun.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,75 @@
import process from 'node:process'
import type { Choice, PromptType } from '@posva/prompts'
import prompts from '@posva/prompts'
import { Fzf } from 'fzf'
import { parseNun } from '../parse'
import { runCli } from '../runner'
import { getPackageJSON } from '../fs'
import { exclude } from '../utils'

runCli(parseNun)
runCli(async (agent, args, ctx) => {
const isInteractive = !args.length && !ctx?.programmatic

if (isInteractive || args[0] === '-m') {
const pkg = getPackageJSON(ctx)

const allDependencies = { ...pkg.dependencies, ...pkg.devDependencies }

const raw = Object.entries(allDependencies) as [string, string][]

if (!raw.length) {
console.error('No dependencies found')
return
}

const fzf = new Fzf(raw, {
selector: ([dep, version]) => `${dep} ${version}`,
casing: 'case-insensitive',
})

const choices: Choice[] = raw.map(([dependency, version]) => ({
title: dependency,
value: dependency,
description: version,
}))

const isMultiple = args[0] === '-m'

const type: PromptType = isMultiple
? 'autocompleteMultiselect'
: 'autocomplete'

if (isMultiple)
args = exclude(args, '-m')

try {
const { depsToRemove } = await prompts({
type,
name: 'depsToRemove',
choices,
instructions: false,
message: `remove ${isMultiple ? 'dependencies' : 'dependency'}`,
async suggest(input: string, choices: Choice[]) {
const results = fzf.find(input)
return results.map(r => choices.find(c => c.value === r.item[0]))
},
})

if (!depsToRemove) {
process.exitCode = 1
return
}

const isSingleDependency = typeof depsToRemove === 'string'

if (isSingleDependency)
args.push(depsToRemove)
else args.push(...depsToRemove)
}
catch {
process.exit(1)
}
}

return parseNun(agent, args, ctx)
})
46 changes: 46 additions & 0 deletions src/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import process from 'node:process'
import type { Choice } from '@posva/prompts'
import c from 'kleur'
import { formatPackageWithUrl } from './utils'

export interface NpmPackage {
name: string
description: string
version: string
keywords: string[]
date: string
links: {
npm: string
homepage: string
repository: string
}
}

interface NpmRegistryResponse {
objects: { package: NpmPackage }[]
}

export async function fetchNpmPackages(pattern: string): Promise<Choice[]> {
const registryLink = (pattern: string) =>
`https://registry.npmjs.com/-/v1/search?text=${pattern}&size=35`

const terminalColumns = process.stdout?.columns || 80

try {
const result = await fetch(registryLink(pattern))
.then(res => res.json()) as NpmRegistryResponse

return result.objects.map(({ package: pkg }) => ({
title: formatPackageWithUrl(
`${pkg.name.padEnd(30, ' ')} ${c.blue(`v${pkg.version}`)}`,
pkg.links.repository ?? pkg.links.npm,
terminalColumns,
),
value: pkg,
}))
}
catch {
console.error('Error when fetching npm registry')
process.exit(1)
}
}
26 changes: 24 additions & 2 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { existsSync, promises as fs } from 'node:fs'
import type { Buffer } from 'node:buffer'
import process from 'node:process'
import which from 'which'
import c from 'kleur'
import terminalLink from 'terminal-link'

export const CLI_TEMP_DIR = join(os.tmpdir(), 'antfu-ni')

Expand All @@ -15,8 +17,8 @@ export function remove<T>(arr: T[], v: T) {
return arr
}

export function exclude<T>(arr: T[], v: T) {
return arr.slice().filter(item => item !== v)
export function exclude<T>(arr: T[], ...v: T[]) {
return arr.slice().filter(item => !v.includes(item))
}

export function cmdExists(cmd: string) {
Expand Down Expand Up @@ -91,3 +93,23 @@ export async function writeFileSafe(

return false
}

export function limitText(text: string, maxWidth: number) {
if (text.length <= maxWidth)
return text
return `${text.slice(0, maxWidth)}${c.dim('…')}`
}

export function formatPackageWithUrl(pkg: string, url?: string, limits = 80) {
return url
? terminalLink(
pkg,
url,
{
fallback: (_, url) => (pkg.length + url.length > limits)
? pkg
: pkg + c.dim(` - ${url}`),
},
)
: pkg
}

0 comments on commit 7787a59

Please sign in to comment.