Skip to content

Commit

Permalink
feat!: parseNi etc. now return ResolvedCommand object instead of …
Browse files Browse the repository at this point in the history
…string, migrate to `tinyexec` (#231)

Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com>
Co-authored-by: Anthony Fu <github@antfu.me>
  • Loading branch information
3 people authored Aug 27, 2024
1 parent 4066f51 commit 3d32313
Show file tree
Hide file tree
Showing 42 changed files with 207 additions and 167 deletions.
5 changes: 4 additions & 1 deletion build.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { defineBuildConfig } from 'unbuild'
import { globSync } from 'tinyglobby'

export default defineBuildConfig({
entries: globSync(['src/commands/*.ts'], { expandDirectories: false }).map(i => ({
entries: globSync(
['src/commands/*.ts'],
{ expandDirectories: false },
).map(i => ({
input: i.slice(0, -3),
name: basename(i).slice(0, -3),
})),
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@
},
"devDependencies": {
"@antfu/eslint-config": "^2.27.2",
"@jsdevtools/ez-spawn": "^3.0.4",
"@posva/prompts": "^2.4.4",
"@types/fs-extra": "^11.0.4",
"@types/ini": "^4.1.1",
Expand All @@ -62,11 +61,12 @@
"fzf": "^0.5.2",
"ini": "^4.1.3",
"lint-staged": "^15.2.9",
"package-manager-detector": "^0.1.2",
"package-manager-detector": "^0.2.0",
"picocolors": "^1.0.1",
"simple-git-hooks": "^2.11.1",
"taze": "^0.16.6",
"terminal-link": "^3.0.0",
"tinyexec": "^0.3.0",
"tinyglobby": "^0.2.5",
"tsx": "^4.18.0",
"typescript": "^5.5.4",
Expand Down
15 changes: 5 additions & 10 deletions pnpm-lock.yaml

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

6 changes: 0 additions & 6 deletions src/agents.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
import ini from 'ini'
import type { Agent } from './agents'
import type { Agent } from 'package-manager-detector'
import { detect } from './detect'

const customRcPath = process.env.NI_CONFIG_FILE
Expand Down
41 changes: 30 additions & 11 deletions src/detect.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,48 @@
import process from 'node:process'
import { async as ezspawn } from '@jsdevtools/ez-spawn'
import { detect as detectPM } from 'package-manager-detector'
import { x } from 'tinyexec'
import terminalLink from 'terminal-link'
import prompts from '@posva/prompts'
import { INSTALL_PAGE } from './agents'
import { INSTALL_PAGE } from 'package-manager-detector/constants'
import { cmdExists } from './utils'

export interface DetectOptions {
autoInstall?: boolean
programmatic?: boolean
cwd?: string
/**
* Should use Volta when present
*
* @see https://volta.sh/
* @default true
*/
detectVolta?: boolean
}

export async function detect({ autoInstall, programmatic, cwd }: DetectOptions = {}) {
const pmDetection = await detectPM({
const {
name,
agent,
version,
} = await detectPM({
cwd,
onUnknown: (packageManager) => {
if (!programmatic) {
console.warn('[ni] Unknown packageManager:', packageManager)
}
return undefined
},
})

const agent = pmDetection?.agent ?? null
const version = pmDetection?.version ?? null
}) || {}

// auto install
if (agent && !cmdExists(agent.split('@')[0]) && !programmatic) {
if (name && !cmdExists(name) && !programmatic) {
if (!autoInstall) {
console.warn(`[ni] Detected ${agent} but it doesn't seem to be installed.\n`)
console.warn(`[ni] Detected ${name} but it doesn't seem to be installed.\n`)

if (process.env.CI)
process.exit(1)

const link = terminalLink(agent, INSTALL_PAGE[agent])
const link = terminalLink(name, INSTALL_PAGE[name])
const { tryInstall } = await prompts({
name: 'tryInstall',
type: 'confirm',
Expand All @@ -43,7 +52,17 @@ export async function detect({ autoInstall, programmatic, cwd }: DetectOptions =
process.exit(1)
}

await ezspawn(`npm i -g ${agent.split('@')[0]}${version ? `@${version}` : ''}`, { stdio: 'inherit', cwd })
await x(
'npm',
['i', '-g', `${name}${version ? `@${version}` : ''}`],
{
nodeOptions: {
stdio: 'inherit',
cwd,
},
throwOnError: true,
},
)
}

return agent
Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export * from './agents'
export * from 'package-manager-detector/constants'
export * from 'package-manager-detector/commands'

export * from './config'
export * from './detect'
export * from './parse'
Expand Down
42 changes: 24 additions & 18 deletions src/parse.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Agent, Command } from './agents'
import { AGENTS, COMMANDS } from './agents'
import type { Agent, Command, ResolvedCommand } from 'package-manager-detector'
import { exclude } from './utils'
import type { Runner } from './runner'
import { COMMANDS, constructCommand } from '.'

export class UnsupportedCommand extends Error {
constructor({ agent, command }: { agent: Agent, command: Command }) {
Expand All @@ -13,23 +13,13 @@ export function getCommand(
agent: Agent,
command: Command,
args: string[] = [],
) {
if (!AGENTS.includes(agent))
): ResolvedCommand {
if (!COMMANDS[agent])
throw new Error(`Unsupported agent "${agent}"`)

const c = COMMANDS[agent][command]

if (typeof c === 'function')
return c(args)

if (!c)
if (!COMMANDS[agent][command])
throw new UnsupportedCommand({ agent, command })

const quote = (arg: string) => (!arg.startsWith('--') && arg.includes(' '))
? JSON.stringify(arg)
: arg

return c.replace('{0}', args.map(quote).join(' ')).trim()
return constructCommand(COMMANDS[agent][command], args)!
}

export const parseNi = <Runner>((agent, args, ctx) => {
Expand Down Expand Up @@ -58,12 +48,20 @@ export const parseNr = <Runner>((agent, args) => {
if (args.length === 0)
args.push('start')

let hasIfPresent = false
if (args.includes('--if-present')) {
args = exclude(args, '--if-present')
args[0] = `--if-present ${args[0]}`
hasIfPresent = true
}

return getCommand(agent, 'run', args)
const cmd = getCommand(agent, 'run', args)
if (!cmd)
return cmd

if (hasIfPresent)
cmd.args.splice(1, 0, '--if-present')

return cmd
})

export const parseNu = <Runner>((agent, args) => {
Expand All @@ -86,3 +84,11 @@ export const parseNlx = <Runner>((agent, args) => {
export const parseNa = <Runner>((agent, args) => {
return getCommand(agent, 'agent', args)
})

export function serializeCommand(command?: ResolvedCommand) {
if (!command)
return undefined
if (command.args.length === 0)
return command.command
return `${command.command} ${command.args.map(i => i.includes(' ') ? `"${i}"` : i).join(' ')}`
}
58 changes: 43 additions & 15 deletions src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@
import { resolve } from 'node:path'
import process from 'node:process'
import prompts from '@posva/prompts'
import type { Options as EzSpawnOptions } from '@jsdevtools/ez-spawn'
import { async as ezspawn } from '@jsdevtools/ez-spawn'
import type { Options as TinyExecOptions } from 'tinyexec'
import { x } from 'tinyexec'
import c from 'picocolors'
import type { Agent, ResolvedCommand } from 'package-manager-detector'
import { AGENTS } from 'package-manager-detector'
import { version } from '../package.json'
import type { Agent } from './agents'
import { AGENTS } from './agents'
import { getDefaultAgent, getGlobalAgent } from './config'
import type { DetectOptions } from './detect'
import { detect } from './detect'
import { getVoltaPrefix, remove } from './utils'
import { cmdExists, remove } from './utils'
import { UnsupportedCommand, getCommand } from './parse'

const DEBUG_SIGN = '?'
Expand All @@ -22,7 +22,7 @@ export interface RunnerContext {
cwd?: string
}

export type Runner = (agent: Agent, args: string[], ctx?: RunnerContext) => Promise<string | undefined> | string | undefined
export type Runner = (agent: Agent, args: string[], ctx?: RunnerContext) => Promise<ResolvedCommand | undefined> | ResolvedCommand | undefined

export async function runCli(fn: Runner, options: DetectOptions & { args?: string[] } = {}) {
const {
Expand Down Expand Up @@ -74,6 +74,10 @@ export async function getCliCommand(
}

export async function run(fn: Runner, args: string[], options: DetectOptions = {}) {
const {
detectVolta = true,
} = options

const debug = args.includes(DEBUG_SIGN)
if (debug)
remove(args, DEBUG_SIGN)
Expand All @@ -85,13 +89,26 @@ export async function run(fn: Runner, args: string[], options: DetectOptions = {
}

if (args.length === 1 && (args[0]?.toLowerCase() === '-v' || args[0] === '--version')) {
const getCmd = (a: Agent) => AGENTS.includes(a) ? getCommand(a, 'agent', ['-v']) : `${a} -v`
const getV = (a: string, o: EzSpawnOptions = {}) => ezspawn(getCmd(a as Agent), o).then(e => e.stdout).then(e => e.startsWith('v') ? e : `v${e}`)
const getCmd = (a: Agent) => AGENTS.includes(a)
? getCommand(a, 'agent', ['-v'])
: { command: a, args: ['-v'] }
const xVersionOptions = {
nodeOptions: {
cwd,
},
throwOnError: true,
} satisfies Partial<TinyExecOptions>
const getV = (a: string) => {
const { command, args } = getCmd(a as Agent)
return x(command, args, xVersionOptions)
.then(e => e.stdout)
.then(e => e.startsWith('v') ? e : `v${e}`)
}
const globalAgentPromise = getGlobalAgent()
const globalAgentVersionPromise = globalAgentPromise.then(getV)
const agentPromise = detect({ ...options, cwd }).then(a => a || '')
const agentVersionPromise = agentPromise.then(a => a && getV(a, { cwd }))
const nodeVersionPromise = getV('node', { cwd })
const agentVersionPromise = agentPromise.then(a => a && getV(a))
const nodeVersionPromise = getV('node')

console.log(`@antfu/ni ${c.cyan(`v${version}`)}`)
console.log(`node ${c.green(await nodeVersionPromise)}`)
Expand Down Expand Up @@ -126,19 +143,30 @@ export async function run(fn: Runner, args: string[], options: DetectOptions = {
return
}

let command = await getCliCommand(fn, args, options, cwd)
const command = await getCliCommand(fn, args, options, cwd)

if (!command)
return

const voltaPrefix = getVoltaPrefix()
if (voltaPrefix)
command = voltaPrefix.concat(' ').concat(command)
if (detectVolta && cmdExists('volta')) {
command.args = ['run', command.command, ...command.args]
command.command = 'volta'
}

if (debug) {
console.log(command)
return
}

await ezspawn(command, { stdio: 'inherit', cwd })
await x(
command.command,
command.args,
{
nodeOptions: {
stdio: 'inherit',
cwd,
},
throwOnError: true,
},
)
}
7 changes: 0 additions & 7 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,6 @@ export function cmdExists(cmd: string) {
return which.sync(cmd, { nothrow: true }) !== null
}

export function getVoltaPrefix(): string {
// https://blog.volta.sh/2020/11/25/command-spotlight-volta-run/
const VOLTA_PREFIX = 'volta run'
const hasVoltaCommand = cmdExists('volta')
return hasVoltaCommand ? VOLTA_PREFIX : ''
}

interface TempFile {
path: string
fd: fs.FileHandle
Expand Down
6 changes: 3 additions & 3 deletions test/na/bun.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { expect, it } from 'vitest'
import { parseNa } from '../../src/commands'
import { parseNa, serializeCommand } from '../../src/commands'

const agent = 'bun'
function _(arg: string, expected: string) {
return () => {
return async () => {
expect(
parseNa(agent, arg.split(' ').filter(Boolean)),
serializeCommand(await parseNa(agent, arg.split(' ').filter(Boolean))),
).toBe(
expected,
)
Expand Down
Loading

0 comments on commit 3d32313

Please sign in to comment.