feat: friendlier error message when command not found (#6887)

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
Khải
2023-08-04 07:18:34 +07:00
committed by GitHub
parent 35abeae9fa
commit c5fbdb55c2
5 changed files with 169 additions and 9 deletions

View File

@@ -0,0 +1,6 @@
---
"@pnpm/plugin-commands-script-runners": patch
"pnpm": patch
---
Make the error message when user attempting to run a command that does not exist friendlier

View File

@@ -1,12 +1,46 @@
import { type PackageScripts } from '@pnpm/types'
import didYouMean, { ReturnTypeEnums } from 'didyoumean2'
import { readdirSync } from 'fs'
import path from 'path'
export function getNearestProgram ({
dir,
modulesDir,
programName,
workspaceDir,
}: {
dir: string
modulesDir: string
programName: string
workspaceDir: string | undefined
}) {
try {
const binDir = path.join(dir, modulesDir, '.bin')
const programList = readProgramsFromDir(binDir)
if (workspaceDir && workspaceDir !== dir) {
const workspaceBinDir = path.join(workspaceDir, modulesDir, '.bin')
programList.push(...readProgramsFromDir(workspaceBinDir))
}
return getNearest(programName, programList)
} catch {
return null
}
}
function readProgramsFromDir (binDir: string): string[] {
const files = readdirSync(binDir)
if (process.platform !== 'win32') return files
const executableExtensions = ['.cmd', '.bat', '.ps1', '.exe', '.com']
return files.map((fullName) => {
const { name, ext } = path.parse(fullName)
return executableExtensions.includes(ext.toLowerCase()) ? name : fullName
})
}
export function buildCommandNotFoundHint (scriptName: string, scripts?: PackageScripts | undefined) {
let hint = `Command "${scriptName}" not found.`
const nearestCommand = scripts && didYouMean(scriptName, Object.keys(scripts), {
returnType: ReturnTypeEnums.FIRST_CLOSEST_MATCH,
})
const nearestCommand = getNearestScript(scriptName, scripts)
if (nearestCommand) {
hint += ` Did you mean "pnpm run ${nearestCommand}"?`
@@ -14,3 +48,14 @@ export function buildCommandNotFoundHint (scriptName: string, scripts?: PackageS
return hint
}
export function getNearestScript (scriptName: string, scripts?: PackageScripts | undefined) {
return getNearest(scriptName, Object.keys(scripts ?? []))
}
export function getNearest (name: string, list: readonly string[]): string | null {
if (list == null || list.length === 0) return null
return didYouMean(name, list, {
returnType: ReturnTypeEnums.FIRST_CLOSEST_MATCH,
})
}

View File

@@ -21,7 +21,7 @@ import {
import { PnpmError } from '@pnpm/error'
import which from 'which'
import writeJsonFile from 'write-json-file'
import { buildCommandNotFoundHint } from './buildCommandNotFoundHint'
import { getNearestProgram, getNearestScript } from './buildCommandNotFoundHint'
export const shorthands = {
parallel: runShorthands.parallel,
@@ -133,7 +133,8 @@ export async function handler (
shellMode?: boolean
resumeFrom?: string
reportSummary?: boolean
} & Pick<Config, 'extraBinPaths' | 'extraEnv' | 'lockfileDir' | 'dir' | 'userAgent' | 'recursive' | 'workspaceDir'>,
implicitlyFellbackFromRun?: boolean
} & Pick<Config, 'extraBinPaths' | 'extraEnv' | 'lockfileDir' | 'modulesDir' | 'dir' | 'userAgent' | 'recursive' | 'workspaceDir'>,
params: string[]
) {
// For backward compatibility
@@ -212,7 +213,13 @@ export async function handler (
result[prefix].duration = getExecutionDuration(startTime)
} catch (err: any) { // eslint-disable-line
if (await isErrorCommandNotFound(params[0], err)) {
err.hint = buildCommandNotFoundHint(params[0], (await readProjectManifestOnly(opts.dir)).scripts)
err.message = `Command "${params[0]}" not found`
err.hint = await createExecCommandNotFoundHint(params[0], {
implicitlyFellbackFromRun: opts.implicitlyFellbackFromRun ?? false,
dir: opts.dir,
workspaceDir: opts.workspaceDir,
modulesDir: opts.modulesDir ?? 'node_modules',
})
} else if (!opts.recursive && typeof err.exitCode === 'number') {
exitCode = err.exitCode
return
@@ -254,6 +261,46 @@ export async function handler (
return { exitCode }
}
async function createExecCommandNotFoundHint (
programName: string,
opts: {
dir: string
implicitlyFellbackFromRun: boolean
workspaceDir?: string
modulesDir: string
}
): Promise<string | undefined> {
if (opts.implicitlyFellbackFromRun) {
let nearestScript: string | null | undefined
try {
nearestScript = getNearestScript(programName, (await readProjectManifestOnly(opts.dir)).scripts)
} catch (_err) {}
if (nearestScript) {
return `Did you mean "pnpm ${nearestScript}"?`
}
const nearestProgram = getNearestProgram({
programName,
dir: opts.dir,
workspaceDir: opts.workspaceDir,
modulesDir: opts.modulesDir,
})
if (nearestProgram) {
return `Did you mean "pnpm ${nearestProgram}"?`
}
return undefined
}
const nearestProgram = getNearestProgram({
programName,
dir: opts.dir,
workspaceDir: opts.workspaceDir,
modulesDir: opts.modulesDir,
})
if (nearestProgram) {
return `Did you mean "pnpm exec ${nearestProgram}"?`
}
return undefined
}
interface CommandError extends Error {
originalMessage: string
shortMessage: string

View File

@@ -186,6 +186,7 @@ export async function handler (
if (opts.argv == null) throw new Error('Could not fallback because opts.argv.original was not passed to the script runner')
return exec({
selectedProjectsGraph: {},
implicitlyFellbackFromRun: true,
...opts,
}, opts.argv.original.slice(1))
}

View File

@@ -812,7 +812,7 @@ test('pnpm recursive exec report summary with --bail', async () => {
expect(executionStatus[path.resolve('project-4')].status).toBe('queued')
})
test('pnpm exec command not found', async () => {
test('pnpm exec command not found (implicit fallback)', async () => {
prepare({
scripts: {
build: 'echo hello',
@@ -820,7 +820,7 @@ test('pnpm exec command not found', async () => {
})
const { selectedProjectsGraph } = await readProjects(process.cwd(), [])
let error!: Error & { hint: string }
let error!: Error & { hint?: string }
try {
await exec.handler({
...DEFAULT_OPTS,
@@ -828,9 +828,70 @@ test('pnpm exec command not found', async () => {
recursive: false,
bail: true,
selectedProjectsGraph,
implicitlyFellbackFromRun: true,
}, ['buil'])
} catch (err: any) { // eslint-disable-line
error = err
}
expect(error?.hint).toBe('Command "buil" not found. Did you mean "pnpm run build"?')
expect(error?.message).toBe('Command "buil" not found')
expect(error?.hint).toBe('Did you mean "pnpm build"?')
})
test('pnpm exec command not found (explicit call, without near name packages)', async () => {
prepare({
scripts: {
cwsay: 'echo hello',
},
})
const { selectedProjectsGraph } = await readProjects(process.cwd(), [])
let error!: Error & { hint?: string }
try {
await exec.handler({
...DEFAULT_OPTS,
dir: process.cwd(),
recursive: false,
bail: true,
selectedProjectsGraph,
implicitlyFellbackFromRun: false,
}, ['cwsay'])
} catch (err: any) { // eslint-disable-line
error = err
}
expect(error?.message).toBe('Command "cwsay" not found')
expect(error?.hint).toBeFalsy()
})
test('pnpm exec command not found (explicit call, with a near name package)', async () => {
prepare({
dependencies: {
cowsay: '1.5.0',
},
})
const { selectedProjectsGraph } = await readProjects(process.cwd(), [])
await execa(pnpmBin, [
'install',
'--registry',
REGISTRY_URL,
'--store-dir',
path.resolve(DEFAULT_OPTS.storeDir),
])
let error!: Error & { hint?: string }
try {
await exec.handler({
...DEFAULT_OPTS,
dir: process.cwd(),
recursive: false,
bail: true,
selectedProjectsGraph,
implicitlyFellbackFromRun: false,
}, ['cwsay'])
} catch (err: any) { // eslint-disable-line
error = err
}
expect(error?.message).toBe('Command "cwsay" not found')
expect(error?.hint).toBe('Did you mean "pnpm exec cowsay"?')
})