diff --git a/.gitignore b/.gitignore index 6ce7699d..8171a73c 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,5 @@ _storage.json # System files .DS_Store + +.vscode diff --git a/src/agents.ts b/src/agents.ts index 852b012c..143c666a 100644 --- a/src/agents.ts +++ b/src/agents.ts @@ -4,6 +4,19 @@ const npmRun = (agent: string) => (args: string[]) => { else return `${agent} run ${args[0]}` } +const npm = { + 'agent': 'npm {0}', + 'run': npmRun('npm'), + 'install': 'npm i {0}', + 'frozen': 'npm ci', + 'global': 'npm i -g {0}', + 'add': 'npm i {0}', + 'upgrade': 'npm update {0}', + 'upgrade-interactive': null, + 'execute': 'npx {0}', + 'uninstall': 'npm uninstall {0}', + 'global_uninstall': 'npm uninstall -g {0}', +} const yarn = { 'agent': 'yarn {0}', 'run': 'yarn run {0}', @@ -43,21 +56,22 @@ const bun = { 'uninstall': 'bun remove {0}', 'global_uninstall': 'bun remove -g {0}', } +const deno = { + 'agent': 'deno {0}', + 'run': 'deno task {0}', + 'install': null, + 'frozen': null, + 'global': null, + 'add': null, + 'upgrade': null, + 'upgrade-interactive': null, + 'execute': 'deno run npm:{0}', + 'uninstall': null, + 'global_uninstall': null, +} export const AGENTS = { - 'npm': { - 'agent': 'npm {0}', - 'run': npmRun('npm'), - 'install': 'npm i {0}', - 'frozen': 'npm ci', - 'global': 'npm i -g {0}', - 'add': 'npm i {0}', - 'upgrade': 'npm update {0}', - 'upgrade-interactive': null, - 'execute': 'npx {0}', - 'uninstall': 'npm uninstall {0}', - 'global_uninstall': 'npm uninstall -g {0}', - }, + 'npm': npm, 'yarn': yarn, 'yarn@berry': { ...yarn, @@ -76,6 +90,7 @@ export const AGENTS = { run: npmRun('pnpm'), }, 'bun': bun, + 'deno': deno, } export type Agent = keyof typeof AGENTS @@ -99,4 +114,5 @@ export const INSTALL_PAGE: Record = { 'yarn': 'https://classic.yarnpkg.com/en/docs/install', 'yarn@berry': 'https://yarnpkg.com/getting-started/install', 'npm': 'https://docs.npmjs.com/cli/v8/configuring-npm/install', + 'deno': 'https://deno.land/manual/getting_started/installation', } diff --git a/src/commands/nr.ts b/src/commands/nr.ts index 6ee66d71..ace6e163 100644 --- a/src/commands/nr.ts +++ b/src/commands/nr.ts @@ -4,7 +4,7 @@ import c from 'kleur' import { Fzf } from 'fzf' import { dump, load } from '../storage' import { parseNr } from '../parse' -import { getPackageJSON } from '../fs' +import { getConfig } from '../fs' import { runCli } from '../runner' runCli(async (agent, args, ctx) => { @@ -19,11 +19,11 @@ runCli(async (agent, args, ctx) => { } if (args.length === 0) { + const pkg = getConfig(agent, ctx?.cwd) + const scripts = (agent === 'deno' ? pkg.tasks : pkg.scripts) || {} + // support https://www.npmjs.com/package/npm-scripts-info conventions - const pkg = getPackageJSON(ctx?.cwd) - const scripts = pkg.scripts || {} const scriptsInfo = pkg['scripts-info'] || {} - const names = Object.entries(scripts) as [string, string][] if (!names.length) diff --git a/src/detect.ts b/src/detect.ts index 4d923b28..d89a2d24 100644 --- a/src/detect.ts +++ b/src/detect.ts @@ -40,7 +40,7 @@ export async function detect({ autoInstall, cwd }: DetectOptions = {}) { console.warn('[ni] Unknown packageManager:', pkg.packageManager) } } - catch {} + catch { } } // detect based on lock diff --git a/src/fs.ts b/src/fs.ts index 63fac14b..8135149b 100644 --- a/src/fs.ts +++ b/src/fs.ts @@ -1,18 +1,33 @@ -import { resolve } from 'path' -import fs from 'fs' +import fs from 'node:fs' +import { resolve } from 'node:path' +import type { Agent } from './agents' -export function getPackageJSON(cwd = process.cwd()): any { - const path = resolve(cwd, 'package.json') +const getJSONConfigFile = (cwd: string, filename: string) => { + const path = resolve(cwd, filename) if (fs.existsSync(path)) { try { const raw = fs.readFileSync(path, 'utf-8') - const data = JSON.parse(raw) + const data = JSON.parse(raw) as Record return data } catch (e) { - console.warn('Failed to parse package.json') + console.warn(`Failed to parse ${filename}`) process.exit(0) } } } + +export function getPackageJSON(cwd = process.cwd()) { + return getJSONConfigFile(cwd, 'package.json') || {} +} + +export function getDenoJSON(cwd = process.cwd()) { + return getJSONConfigFile(cwd, 'deno.json') || getJSONConfigFile(cwd, 'deno.jsonc') || {} +} + +export function getConfig(agent: Agent, cwd = process.cwd()) { + if (agent === 'deno') + return getDenoJSON(cwd) + return getPackageJSON(cwd) +} diff --git a/src/parse.ts b/src/parse.ts index d2115021..e50b3011 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -1,7 +1,8 @@ +import os from 'node:os' import type { Agent, Command } from './agents' import { AGENTS } from './agents' import { exclude } from './utils' -import type { Runner } from './runner' +import type { CommandWithPrompt, Runner } from './runner' export class UnsupportedCommand extends Error { constructor({ agent, command }: { agent: Agent; command: Command }) { @@ -29,25 +30,56 @@ export function getCommand( } export const parseNi = ((agent, args, ctx) => { + if (args.includes('--types')) { + args = exclude(args, '--types') + args = args.map((i) => { + if (i.startsWith('-')) + return i + if (i.startsWith('@')) + i = i.slice(1).replace('/', '__') + + return `@types/${i}` + }, + ) + args.unshift('-D') + } + // bun use `-d` instead of `-D`, #90 if (agent === 'bun') args = args.map(i => i === '-D' ? '-d' : i) - if (args.includes('-g')) - return getCommand(agent, 'global', exclude(args, '-g')) + let before_actions: (CommandWithPrompt & { tag: 'clean_node_modules' })[] = [] + if (args.includes('--reinstall') || args.includes('-R')) { + args = exclude(args, '--reinstall') + args = exclude(args, '-R') + + const node_modules = `${ctx?.cwd || '.'}/node_modules` + const command = os.platform() === 'win32' ? `rmdir /s /q ${node_modules}` : `rm -rf ${node_modules}` + + before_actions = [ + { command, tag: 'clean_node_modules', prompt: `Remove ${node_modules} folder?` }, + ] + } + + if (args.includes('-g')) { + if (before_actions.some(action => action.tag === 'clean_node_modules')) + console.warn('`--reinstall` / `-R` is not supported with `-g`') + + return [getCommand(agent, 'global', exclude(args, '-g'))] + } if (args.includes('--frozen-if-present')) { args = exclude(args, '--frozen-if-present') - return getCommand(agent, ctx?.hasLock ? 'frozen' : 'install', args) + return [...before_actions, getCommand(agent, ctx?.hasLock ? 'frozen' : 'install', args)] } if (args.includes('--frozen')) - return getCommand(agent, 'frozen', exclude(args, '--frozen')) + return [...before_actions, getCommand(agent, 'frozen', exclude(args, '--frozen'))] if (args.length === 0 || args.every(i => i.startsWith('-'))) - return getCommand(agent, 'install', args) + return [...before_actions, getCommand(agent, 'install', args)] - return getCommand(agent, 'add', args) + return [...before_actions, getCommand(agent, 'add', args)] }) export const parseNr = ((agent, args) => { diff --git a/src/runner.ts b/src/runner.ts index 36745e75..68285d05 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -19,7 +19,14 @@ export interface RunnerContext { cwd?: string } -export type Runner = (agent: Agent, args: string[], ctx?: RunnerContext) => Promise | string | undefined +export interface CommandWithPrompt { command: string; tag?: string; prompt?: string } +export type RunnerReturn = string | string[] | (string | CommandWithPrompt)[] | undefined + +function isObjCommand(cmd: string | CommandWithPrompt): cmd is CommandWithPrompt { + return typeof cmd === 'object' +} + +export type Runner = (agent: Agent, args: string[], ctx?: RunnerContext) => Promise | RunnerReturn export async function runCli(fn: Runner, options: DetectOptions = {}) { const args = process.argv.slice(2).filter(Boolean) @@ -40,7 +47,7 @@ export async function run(fn: Runner, args: string[], options: DetectOptions = { remove(args, DEBUG_SIGN) let cwd = process.cwd() - let command + let commands: Awaited if (args.length === 1 && (args[0] === '--version' || args[0] === '-v')) { console.log(`@antfu/ni v${version}`) @@ -68,7 +75,7 @@ export async function run(fn: Runner, args: string[], options: DetectOptions = { const isGlobal = args.includes('-g') if (isGlobal) { - command = await fn(await getGlobalAgent(), args) + commands = await fn(await getGlobalAgent(), args) } else { let agent = await detect({ ...options, cwd }) || await getDefaultAgent() @@ -82,23 +89,34 @@ export async function run(fn: Runner, args: string[], options: DetectOptions = { if (!agent) return } - command = await fn(agent as Agent, args, { + commands = await fn(agent as Agent, args, { hasLock: Boolean(agent), cwd, }) } - if (!command) + if (!commands) return const voltaPrefix = getVoltaPrefix() - if (voltaPrefix) - command = voltaPrefix.concat(' ').concat(command) + const mappedCommands = ( + Array.isArray(commands) + ? commands.map(c => (isObjCommand(c) ? c : { command: c })) + : ([{ command: commands }] as CommandWithPrompt[]) + ).map(c => (voltaPrefix ? { ...c, command: voltaPrefix.concat(' ').concat(c.command) } : c)) if (debug) { - console.log(command) + console.log(commands) return } - await execaCommand(command, { stdio: 'inherit', encoding: 'utf-8', cwd }) + for (const { command, prompt } of mappedCommands) { + if (prompt) { + const { confirm } = await prompts({ name: 'confirm', type: 'confirm', message: prompt }) + if (!confirm) + return + } + + await execaCommand(command, { stdio: 'inherit', encoding: 'utf-8', cwd }) + } } diff --git a/test/na/deno.spec.ts b/test/na/deno.spec.ts new file mode 100644 index 00000000..b9168661 --- /dev/null +++ b/test/na/deno.spec.ts @@ -0,0 +1,15 @@ +import { expect, test } from 'vitest' +import { parseNa } from '../../src/commands' + +const agent = 'deno' +const _ = (arg: string, expected: string) => () => { + expect( + parseNa(agent, arg.split(' ').filter(Boolean)), + ).toBe( + expected, + ) +} + +test('empty', _('', 'deno')) +test('foo', _('foo', 'deno foo')) +test('run test', _('run test', 'deno run test')) diff --git a/test/ni/_base.ts b/test/ni/_base.ts new file mode 100644 index 00000000..1c175cf9 --- /dev/null +++ b/test/ni/_base.ts @@ -0,0 +1,19 @@ +import { expect } from 'vitest' +import type { RunnerReturn } from '../../src/commands' +import { parseNi } from '../../src/commands' +import type { Agent } from '../../src/agents' + +export const parseNaTest = (agent: Agent) => { + return (arg: string, expected: RunnerReturn) => () => { + expect( + parseNi(agent, arg.split(' ').filter(Boolean)), + ).toEqual(expected) + } +} + +const platformRmDir = process.platform === 'win32' ? 'rmdir /s /q' : 'rm -rf' +export const promptRemoveOfNodeModules = { + prompt: 'Remove ./node_modules folder?', + command: `${platformRmDir} ./node_modules`, + tag: 'clean_node_modules', +} diff --git a/test/ni/bun.spec.ts b/test/ni/bun.spec.ts index e5cb9611..b0a1fdb6 100644 --- a/test/ni/bun.spec.ts +++ b/test/ni/bun.spec.ts @@ -1,23 +1,22 @@ -import { expect, test } from 'vitest' -import { parseNi } from '../../src/commands' +import { test } from 'vitest' +import { parseNaTest, promptRemoveOfNodeModules } from './_base' -const agent = 'bun' -const _ = (arg: string, expected: string) => () => { - expect( - parseNi(agent, arg.split(' ').filter(Boolean)), - ).toBe( - expected, - ) -} +const _ = parseNaTest('bun') -test('empty', _('', 'bun install')) +test('empty', _('', ['bun install'])) -test('single add', _('axios', 'bun add axios')) +test('empty reinstall', _('--reinstall', [promptRemoveOfNodeModules, 'bun install'])) -test('add dev', _('vite -D', 'bun add vite -d')) +test('single add', _('axios', ['bun add axios'])) -test('multiple', _('eslint @types/node', 'bun add eslint @types/node')) +test('-D', _('vite -D', 'bun add vite -d')) -test('global', _('eslint -g', 'bun add -g eslint')) +test('add dev', _('vite -D', ['bun add vite -d'])) -test('frozen', _('--frozen', 'bun install --no-save')) +test('multiple', _('eslint @types/node', ['bun add eslint @types/node'])) + +test('add types', _('--types node react @foo/bar', 'bun add -d @types/node @types/react @types/foo__bar')) + +test('global', _('eslint -g', ['bun add -g eslint'])) + +test('frozen', _('--frozen', ['bun install --no-save'])) diff --git a/test/ni/npm.spec.ts b/test/ni/npm.spec.ts index 1eace9cc..d56a8c62 100644 --- a/test/ni/npm.spec.ts +++ b/test/ni/npm.spec.ts @@ -1,23 +1,20 @@ -import { expect, test } from 'vitest' -import { parseNi } from '../../src/commands' +import { test } from 'vitest' +import { parseNaTest, promptRemoveOfNodeModules } from './_base' -const agent = 'npm' -const _ = (arg: string, expected: string) => () => { - expect( - parseNi(agent, arg.split(' ').filter(Boolean)), - ).toBe( - expected, - ) -} +const _ = parseNaTest('npm') -test('empty', _('', 'npm i')) +test('empty', _('', ['npm i'])) -test('single add', _('axios', 'npm i axios')) +test('empty reinstall', _('--reinstall', [promptRemoveOfNodeModules, 'npm i'])) -test('multiple', _('eslint @types/node', 'npm i eslint @types/node')) +test('single add', _('axios', ['npm i axios'])) -test('-D', _('eslint @types/node -D', 'npm i eslint @types/node -D')) +test('multiple', _('eslint @types/node', ['npm i eslint @types/node'])) -test('global', _('eslint -g', 'npm i -g eslint')) +test('-D', _('eslint @types/node -D', ['npm i eslint @types/node -D'])) -test('frozen', _('--frozen', 'npm ci')) +test('add types', _('--types node react @foo/bar', 'npm i -D @types/node @types/react @types/foo__bar')) + +test('global', _('eslint -g', ['npm i -g eslint'])) + +test('frozen', _('--frozen', ['npm ci'])) diff --git a/test/ni/pnpm.spec.ts b/test/ni/pnpm.spec.ts index 7795e414..788c5194 100644 --- a/test/ni/pnpm.spec.ts +++ b/test/ni/pnpm.spec.ts @@ -1,26 +1,23 @@ -import { expect, test } from 'vitest' -import { parseNi } from '../../src/commands' +import { test } from 'vitest' +import { parseNaTest, promptRemoveOfNodeModules } from './_base' -const agent = 'pnpm' -const _ = (arg: string, expected: string) => () => { - expect( - parseNi(agent, arg.split(' ').filter(Boolean)), - ).toBe( - expected, - ) -} +const _ = parseNaTest('pnpm') -test('empty', _('', 'pnpm i')) +test('empty', _('', ['pnpm i'])) -test('single add', _('axios', 'pnpm add axios')) +test('empty reinstall', _('--reinstall', [promptRemoveOfNodeModules, 'pnpm i'])) -test('multiple', _('eslint @types/node', 'pnpm add eslint @types/node')) +test('single add', _('axios', ['pnpm add axios'])) -test('-D', _('-D eslint @types/node', 'pnpm add -D eslint @types/node')) +test('multiple', _('eslint @types/node', ['pnpm add eslint @types/node'])) -test('global', _('eslint -g', 'pnpm add -g eslint')) +test('-D', _('-D eslint @types/node', ['pnpm add -D eslint @types/node'])) -test('frozen', _('--frozen', 'pnpm i --frozen-lockfile')) +test('add types', _('--types node react @foo/bar', 'pnpm add -D @types/node @types/react @types/foo__bar')) -test('forward1', _('--anything', 'pnpm i --anything')) -test('forward2', _('-a', 'pnpm i -a')) +test('global', _('eslint -g', ['pnpm add -g eslint'])) + +test('frozen', _('--frozen', ['pnpm i --frozen-lockfile'])) + +test('forward1', _('--anything', ['pnpm i --anything'])) +test('forward2', _('-a', ['pnpm i -a'])) diff --git a/test/ni/yarn.spec.ts b/test/ni/yarn.spec.ts index 0ba8ce33..e7c81454 100644 --- a/test/ni/yarn.spec.ts +++ b/test/ni/yarn.spec.ts @@ -1,23 +1,20 @@ -import { expect, test } from 'vitest' -import { parseNi } from '../../src/commands' +import { test } from 'vitest' +import { parseNaTest, promptRemoveOfNodeModules } from './_base' -const agent = 'yarn' -const _ = (arg: string, expected: string) => () => { - expect( - parseNi(agent, arg.split(' ').filter(Boolean)), - ).toBe( - expected, - ) -} +const _ = parseNaTest('yarn') -test('empty', _('', 'yarn install')) +test('empty', _('', ['yarn install'])) -test('single add', _('axios', 'yarn add axios')) +test('empty reinstall', _('--reinstall', [promptRemoveOfNodeModules, 'yarn install'])) -test('multiple', _('eslint @types/node', 'yarn add eslint @types/node')) +test('single add', _('axios', ['yarn add axios'])) -test('-D', _('eslint @types/node -D', 'yarn add eslint @types/node -D')) +test('multiple', _('eslint @types/node', ['yarn add eslint @types/node'])) -test('global', _('eslint ni -g', 'yarn global add eslint ni')) +test('-D', _('eslint @types/node -D', ['yarn add eslint @types/node -D'])) -test('frozen', _('--frozen', 'yarn install --frozen-lockfile')) +test('add types', _('--types node react @foo/bar', 'yarn add -D @types/node @types/react @types/foo__bar')) + +test('global', _('eslint ni -g', ['yarn global add eslint ni'])) + +test('frozen', _('--frozen', ['yarn install --frozen-lockfile'])) diff --git a/test/ni/yarn@berry.spec.ts b/test/ni/yarn@berry.spec.ts index 661e08a2..e303632b 100644 --- a/test/ni/yarn@berry.spec.ts +++ b/test/ni/yarn@berry.spec.ts @@ -1,23 +1,20 @@ -import { expect, test } from 'vitest' -import { parseNi } from '../../src/commands' +import { test } from 'vitest' +import { parseNaTest, promptRemoveOfNodeModules } from './_base' -const agent = 'yarn@berry' -const _ = (arg: string, expected: string) => () => { - expect( - parseNi(agent, arg.split(' ').filter(Boolean)), - ).toBe( - expected, - ) -} +const _ = parseNaTest('yarn@berry') -test('empty', _('', 'yarn install')) +test('empty', _('', ['yarn install'])) -test('single add', _('axios', 'yarn add axios')) +test('empty reinstall', _('--reinstall', [promptRemoveOfNodeModules, 'yarn install'])) -test('multiple', _('eslint @types/node', 'yarn add eslint @types/node')) +test('single add', _('axios', ['yarn add axios'])) -test('-D', _('eslint @types/node -D', 'yarn add eslint @types/node -D')) +test('multiple', _('eslint @types/node', ['yarn add eslint @types/node'])) -test('global', _('eslint ni -g', 'npm i -g eslint ni')) +test('-D', _('eslint @types/node -D', ['yarn add eslint @types/node -D'])) -test('frozen', _('--frozen', 'yarn install --immutable')) +test('add types', _('--types node react @foo/bar', 'yarn add -D @types/node @types/react @types/foo__bar')) + +test('global', _('eslint ni -g', ['npm i -g eslint ni'])) + +test('frozen', _('--frozen', ['yarn install --immutable'])) diff --git a/test/nr/deno.spec.ts b/test/nr/deno.spec.ts new file mode 100644 index 00000000..bfee389e --- /dev/null +++ b/test/nr/deno.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from 'vitest' +import { parseNr } from '../../src/commands' + +const agent = 'deno' +const _ = (arg: string, expected: string) => () => { + expect( + parseNr(agent, arg.split(' ').filter(Boolean)), + ).toBe( + expected, + ) +} + +test('empty', _('', 'deno task start')) + +test('script', _('dev', 'deno task dev')) + +test('script with arguments', _('build --watch -o', 'deno task build --watch -o')) + +test('colon', _('build:dev', 'deno task build:dev')) diff --git a/test/nx/deno.spec.ts b/test/nx/deno.spec.ts new file mode 100644 index 00000000..030f58c8 --- /dev/null +++ b/test/nx/deno.spec.ts @@ -0,0 +1,14 @@ +import { expect, test } from 'vitest' +import { parseNx } from '../../src/commands' + +const agent = 'deno' +const _ = (arg: string, expected: string) => () => { + expect( + parseNx(agent, arg.split(' ').filter(Boolean)), + ).toBe( + expected, + ) +} + +test('single uninstall', _('esbuild', 'deno run npm:esbuild')) +test('multiple', _('esbuild --version', 'deno run npm:esbuild --version'))