mirror of
https://github.com/pnpm/pnpm.git
synced 2026-03-06 08:18:16 -05:00
feat: friendlier error message when command not found (#6887)
--------- Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
6
.changeset/chatty-houses-build.md
Normal file
6
.changeset/chatty-houses-build.md
Normal 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 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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"?')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user