feat: friendlier error message when command not found (#6887)
--------- Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
1 parent
35abeae9fa
commit
c5fbdb55c2
5 files changed
+169
-9
No files matched your search
@@ -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"?')
|
||||||
})
|
})
|
||||||
Reference in new issue
Block a user