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

---------

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

No files matched your search

+6
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
@@ -1,12 +1,46 @@
import { type PackageScripts } from '@pnpm/types' import { type PackageScripts } from '@pnpm/types'
import didYouMean, { ReturnTypeEnums } from 'didyoumean2' 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) { export function buildCommandNotFoundHint (scriptName: string, scripts?: PackageScripts | undefined) {
let hint = `Command "${scriptName}" not found.` let hint = `Command "${scriptName}" not found.`
const nearestCommand = scripts && didYouMean(scriptName, Object.keys(scripts), { const nearestCommand = getNearestScript(scriptName, scripts)
returnType: ReturnTypeEnums.FIRST_CLOSEST_MATCH,
})
if (nearestCommand) { if (nearestCommand) {
hint += ` Did you mean "pnpm run ${nearestCommand}"?` hint += ` Did you mean "pnpm run ${nearestCommand}"?`
@@ -14,3 +48,14 @@ export function buildCommandNotFoundHint (scriptName: string, scripts?: PackageS
return hint 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,
})
}
@@ -21,7 +21,7 @@ import {
import { PnpmError } from '@pnpm/error' import { PnpmError } from '@pnpm/error'
import which from 'which' import which from 'which'
import writeJsonFile from 'write-json-file' import writeJsonFile from 'write-json-file'
import { buildCommandNotFoundHint } from './buildCommandNotFoundHint' import { getNearestProgram, getNearestScript } from './buildCommandNotFoundHint'
export const shorthands = { export const shorthands = {
parallel: runShorthands.parallel, parallel: runShorthands.parallel,
@@ -133,7 +133,8 @@ export async function handler (
shellMode?: boolean shellMode?: boolean
resumeFrom?: string resumeFrom?: string
reportSummary?: boolean 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[] params: string[]
) { ) {
// For backward compatibility // For backward compatibility
@@ -212,7 +213,13 @@ export async function handler (
result[prefix].duration = getExecutionDuration(startTime) result[prefix].duration = getExecutionDuration(startTime)
} catch (err: any) { // eslint-disable-line } catch (err: any) { // eslint-disable-line
if (await isErrorCommandNotFound(params[0], err)) { 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') { } else if (!opts.recursive && typeof err.exitCode === 'number') {
exitCode = err.exitCode exitCode = err.exitCode
return return
@@ -254,6 +261,46 @@ export async function handler (
return { exitCode } 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 { interface CommandError extends Error {
originalMessage: string originalMessage: string
shortMessage: string shortMessage: string
@@ -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') if (opts.argv == null) throw new Error('Could not fallback because opts.argv.original was not passed to the script runner')
return exec({ return exec({
selectedProjectsGraph: {}, selectedProjectsGraph: {},
implicitlyFellbackFromRun: true,
...opts, ...opts,
}, opts.argv.original.slice(1)) }, opts.argv.original.slice(1))
} }
@@ -812,7 +812,7 @@ test('pnpm recursive exec report summary with --bail', async () => {
expect(executionStatus[path.resolve('project-4')].status).toBe('queued') 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({ prepare({
scripts: { scripts: {
build: 'echo hello', build: 'echo hello',
@@ -820,7 +820,7 @@ test('pnpm exec command not found', async () => {
}) })
const { selectedProjectsGraph } = await readProjects(process.cwd(), []) const { selectedProjectsGraph } = await readProjects(process.cwd(), [])
let error!: Error & { hint: string } let error!: Error & { hint?: string }
try { try {
await exec.handler({ await exec.handler({
...DEFAULT_OPTS, ...DEFAULT_OPTS,
@@ -828,9 +828,70 @@ test('pnpm exec command not found', async () => {
recursive: false, recursive: false,
bail: true, bail: true,
selectedProjectsGraph, selectedProjectsGraph,
implicitlyFellbackFromRun: true,
}, ['buil']) }, ['buil'])
} catch (err: any) { // eslint-disable-line } catch (err: any) { // eslint-disable-line
error = err 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"?')
}) })