From 7787a598c9810cf0247c4dad4a0b9bdbe8496d3e Mon Sep 17 00:00:00 2001 From: Uzhanin Egor <74734213+gearonix@users.noreply.github.com> Date: Sat, 13 Jul 2024 10:29:02 +0300 Subject: [PATCH] feat: interactive package management (#202) Co-authored-by: Anthony Fu --- README.md | 21 ++++++++++ src/commands/ni.ts | 98 ++++++++++++++++++++++++++++++++++++++++++++- src/commands/nr.ts | 7 +--- src/commands/nun.ts | 73 ++++++++++++++++++++++++++++++++- src/fetch.ts | 46 +++++++++++++++++++++ src/utils.ts | 26 +++++++++++- 6 files changed, 261 insertions(+), 10 deletions(-) create mode 100644 src/fetch.ts diff --git a/README.md b/README.md index d8ad4082..ef1f8e20 100644 --- a/README.md +++ b/README.md @@ -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 +``` +
### `nr` - run @@ -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 diff --git a/src/commands/ni.ts b/src/commands/ni.ts index 2483fd4b..0822c19e 100644 --- a/src/commands/ni.ts +++ b/src/commands/ni.ts @@ -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) +}) diff --git a/src/commands/nr.ts b/src/commands/nr.ts index a62d65c3..da0fc8ae 100644 --- a/src/commands/nr.ts +++ b/src/commands/nr.ts @@ -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() @@ -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, diff --git a/src/commands/nun.ts b/src/commands/nun.ts index 204b7cb4..9399ee60 100644 --- a/src/commands/nun.ts +++ b/src/commands/nun.ts @@ -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) +}) diff --git a/src/fetch.ts b/src/fetch.ts new file mode 100644 index 00000000..29d67a31 --- /dev/null +++ b/src/fetch.ts @@ -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 { + 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) + } +} diff --git a/src/utils.ts b/src/utils.ts index e0ab4e19..cd309c87 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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') @@ -15,8 +17,8 @@ export function remove(arr: T[], v: T) { return arr } -export function exclude(arr: T[], v: T) { - return arr.slice().filter(item => item !== v) +export function exclude(arr: T[], ...v: T[]) { + return arr.slice().filter(item => !v.includes(item)) } export function cmdExists(cmd: string) { @@ -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 +}