Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: interactive package management #202

Merged
merged 10 commits into from
Jul 13, 2024
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
}