From 2866c7ba3ded82b891dede2d2433fb413510f641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Sun, 14 May 2023 20:22:26 -0400 Subject: [PATCH] feat: expanded missing command error, including 'did you mean' (#6496) close #6492 Co-authored-by: Zoltan Kochan --- .changeset/pink-tips-rule.md | 7 ++++++ __typings__/local.d.ts | 2 +- cli/cli-utils/src/readProjectManifest.ts | 2 +- .../package.json | 3 +++ .../src/buildCommandNotFoundHint.ts | 16 ++++++++++++ .../src/exec.ts | 24 ++++++++++++++++-- .../plugin-commands-script-runners/src/run.ts | 6 ++++- .../test/exec.e2e.ts | 23 +++++++++++++++++ .../test/index.ts | 19 ++++++++++++++ .../plugin-commands-installation/package.json | 2 +- .../src/nodeExecPath.ts | 2 +- pnpm-lock.yaml | 25 ++++++++++++++++--- 12 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 .changeset/pink-tips-rule.md create mode 100644 exec/plugin-commands-script-runners/src/buildCommandNotFoundHint.ts diff --git a/.changeset/pink-tips-rule.md b/.changeset/pink-tips-rule.md new file mode 100644 index 00000000000..8e41b041643 --- /dev/null +++ b/.changeset/pink-tips-rule.md @@ -0,0 +1,7 @@ +--- +"@pnpm/plugin-commands-script-runners": minor +"@pnpm/cli-utils": patch +"pnpm": patch +--- + +Expanded missing command error, including 'did you mean' [#6492](https://github.com/pnpm/pnpm/issues/6492). diff --git a/__typings__/local.d.ts b/__typings__/local.d.ts index 2ac66fa0a99..1949aa0a0d8 100644 --- a/__typings__/local.d.ts +++ b/__typings__/local.d.ts @@ -39,7 +39,7 @@ declare module '@pnpm/npm-package-arg' { export = anything } -declare module '@zkochan/which' { +declare module '@pnpm/which' { const anything: any export = anything } diff --git a/cli/cli-utils/src/readProjectManifest.ts b/cli/cli-utils/src/readProjectManifest.ts index 0e4e60f738f..2f35db73197 100644 --- a/cli/cli-utils/src/readProjectManifest.ts +++ b/cli/cli-utils/src/readProjectManifest.ts @@ -23,7 +23,7 @@ export async function readProjectManifestOnly ( opts: { engineStrict?: boolean nodeVersion?: string - } + } = {} ): Promise { const manifest = await utils.readProjectManifestOnly(projectDir) packageIsInstallable(projectDir, manifest as any, opts) // eslint-disable-line @typescript-eslint/no-explicit-any diff --git a/exec/plugin-commands-script-runners/package.json b/exec/plugin-commands-script-runners/package.json index 28b6f737e66..9cd4cbd0092 100644 --- a/exec/plugin-commands-script-runners/package.json +++ b/exec/plugin-commands-script-runners/package.json @@ -40,6 +40,7 @@ "@pnpm/registry-mock": "3.8.0", "@types/is-windows": "^1.0.0", "@types/ramda": "0.28.20", + "@types/which": "^2.0.2", "is-windows": "^1.0.2", "write-yaml-file": "^4.2.0" }, @@ -58,6 +59,7 @@ "@pnpm/store-path": "workspace:*", "@pnpm/types": "workspace:*", "@zkochan/rimraf": "^2.1.2", + "didyoumean2": "^5.0.0", "execa": "npm:safe-execa@0.1.2", "p-limit": "^3.1.0", "path-exists": "^4.0.0", @@ -65,6 +67,7 @@ "ramda": "npm:@pnpm/ramda@0.28.1", "realpath-missing": "^1.1.0", "render-help": "^1.0.3", + "which": "npm:@pnpm/which@^3.0.1", "write-json-file": "^4.3.0" }, "peerDependencies": { diff --git a/exec/plugin-commands-script-runners/src/buildCommandNotFoundHint.ts b/exec/plugin-commands-script-runners/src/buildCommandNotFoundHint.ts new file mode 100644 index 00000000000..fa4e53526d3 --- /dev/null +++ b/exec/plugin-commands-script-runners/src/buildCommandNotFoundHint.ts @@ -0,0 +1,16 @@ +import { type PackageScripts } from '@pnpm/types' +import didYouMean, { ReturnTypeEnums } from 'didyoumean2' + +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, + }) + + if (nearestCommand) { + hint += ` Did you mean "pnpm run ${nearestCommand}"?` + } + + return hint +} diff --git a/exec/plugin-commands-script-runners/src/exec.ts b/exec/plugin-commands-script-runners/src/exec.ts index 3a3096c7a3d..a061325e3f7 100644 --- a/exec/plugin-commands-script-runners/src/exec.ts +++ b/exec/plugin-commands-script-runners/src/exec.ts @@ -1,5 +1,5 @@ import path from 'path' -import { docsUrl, type RecursiveSummary, throwOnCommandFail } from '@pnpm/cli-utils' +import { docsUrl, type RecursiveSummary, throwOnCommandFail, readProjectManifestOnly } from '@pnpm/cli-utils' import { type Config, types } from '@pnpm/config' import { makeNodeRequireOption } from '@pnpm/lifecycle' import { logger } from '@pnpm/logger' @@ -19,7 +19,9 @@ import { shorthands as runShorthands, } from './run' import { PnpmError } from '@pnpm/error' +import which from 'which' import writeJsonFile from 'write-json-file' +import { buildCommandNotFoundHint } from './buildCommandNotFoundHint' export const shorthands = { parallel: runShorthands.parallel, @@ -208,7 +210,9 @@ export async function handler ( result[prefix].status = 'passed' result[prefix].duration = getExecutionDuration(startTime) } catch (err: any) { // eslint-disable-line - if (!opts.recursive && typeof err.exitCode === 'number') { + if (await isErrorCommandNotFound(params[0], err)) { + err.hint = buildCommandNotFoundHint(params[0], (await readProjectManifestOnly(opts.dir)).scripts) + } else if (!opts.recursive && typeof err.exitCode === 'number') { exitCode = err.exitCode return } @@ -248,3 +252,19 @@ export async function handler ( throwOnCommandFail('pnpm recursive exec', result) return { exitCode } } + +interface CommandError extends Error { + originalMessage: string + shortMessage: string +} + +async function isErrorCommandNotFound (command: string, error: CommandError) { + // Mac/Linux + if (error.originalMessage === `spawn ${command} ENOENT`) { + return true + } + + // Windows + return error.shortMessage === `Command failed with exit code 1: ${command}` && + !(await which(command, { nothrow: true })) +} diff --git a/exec/plugin-commands-script-runners/src/run.ts b/exec/plugin-commands-script-runners/src/run.ts index 4fa34a1c164..9d362860040 100644 --- a/exec/plugin-commands-script-runners/src/run.ts +++ b/exec/plugin-commands-script-runners/src/run.ts @@ -21,6 +21,7 @@ import renderHelp from 'render-help' import { runRecursive, type RecursiveRunOpts, getSpecifiedScripts as getSpecifiedScriptWithoutStartCommand } from './runRecursive' import { existsInDir } from './existsInDir' import { handler as exec } from './exec' +import { buildCommandNotFoundHint } from './buildCommandNotFoundHint' export const IF_PRESENT_OPTION = { 'if-present': Boolean, @@ -197,7 +198,10 @@ so you may run "pnpm -w run ${scriptName}"`, }) } } - throw new PnpmError('NO_SCRIPT', `Missing script: ${scriptName}`) + + throw new PnpmError('NO_SCRIPT', `Missing script: ${scriptName}`, { + hint: buildCommandNotFoundHint(scriptName, manifest.scripts), + }) } const lifecycleOpts: RunLifecycleHookOptions = { depPath: dir, diff --git a/exec/plugin-commands-script-runners/test/exec.e2e.ts b/exec/plugin-commands-script-runners/test/exec.e2e.ts index 6515e49ec77..eaa404fc37b 100644 --- a/exec/plugin-commands-script-runners/test/exec.e2e.ts +++ b/exec/plugin-commands-script-runners/test/exec.e2e.ts @@ -811,3 +811,26 @@ test('pnpm recursive exec report summary with --bail', async () => { expect(executionStatus[path.resolve('project-3')].status).toBe('running') expect(executionStatus[path.resolve('project-4')].status).toBe('queued') }) + +test('pnpm exec command not found', async () => { + prepare({ + scripts: { + build: '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, + }, ['buil']) + } catch (err: any) { // eslint-disable-line + error = err + } + expect(error?.hint).toBe('Command "buil" not found. Did you mean "pnpm run build"?') +}) diff --git a/exec/plugin-commands-script-runners/test/index.ts b/exec/plugin-commands-script-runners/test/index.ts index 51edf76f5da..d63aa52d84e 100644 --- a/exec/plugin-commands-script-runners/test/index.ts +++ b/exec/plugin-commands-script-runners/test/index.ts @@ -585,3 +585,22 @@ test('pnpm run with RegExp script selector with flag should throw error', async } expect(err.message).toBe('RegExp flags are not supported in script command selector') }) + +test('pnpm run with slightly incorrect command suggests correct one', async () => { + prepare({ + scripts: { + build: 'echo 0', + }, + }) + + await expect(run.handler({ + dir: process.cwd(), + extraBinPaths: [], + extraEnv: {}, + rawConfig: {}, + workspaceConcurrency: 1, + }, ['buil'])).rejects.toEqual(expect.objectContaining({ + code: 'ERR_PNPM_NO_SCRIPT', + hint: 'Command "buil" not found. Did you mean "pnpm run build"?', + })) +}) diff --git a/pkg-manager/plugin-commands-installation/package.json b/pkg-manager/plugin-commands-installation/package.json index 3430ad40c8a..59ab123b89c 100644 --- a/pkg-manager/plugin-commands-installation/package.json +++ b/pkg-manager/plugin-commands-installation/package.json @@ -89,7 +89,7 @@ "@yarnpkg/parsers": "3.0.0-rc.27", "@zkochan/rimraf": "^2.1.2", "@zkochan/table": "^1.0.1", - "@zkochan/which": "^2.0.3", + "@pnpm/which": "^3.0.1", "chalk": "^4.1.2", "ci-info": "^3.8.0", "enquirer": "^2.3.6", diff --git a/pkg-manager/plugin-commands-installation/src/nodeExecPath.ts b/pkg-manager/plugin-commands-installation/src/nodeExecPath.ts index 11b53030efe..a58d5c43d04 100644 --- a/pkg-manager/plugin-commands-installation/src/nodeExecPath.ts +++ b/pkg-manager/plugin-commands-installation/src/nodeExecPath.ts @@ -1,5 +1,5 @@ import { promises as fs } from 'fs' -import which from '@zkochan/which' +import which from '@pnpm/which' export async function getNodeExecPath () { try { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e072c645b0..43a1dc1905d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1193,6 +1193,9 @@ importers: '@zkochan/rimraf': specifier: ^2.1.2 version: 2.1.2 + didyoumean2: + specifier: ^5.0.0 + version: 5.0.0 execa: specifier: npm:safe-execa@0.1.2 version: /safe-execa@0.1.2 @@ -1214,6 +1217,9 @@ importers: render-help: specifier: ^1.0.3 version: 1.0.3 + which: + specifier: npm:@pnpm/which@^3.0.1 + version: /@pnpm/which@3.0.1 write-json-file: specifier: ^4.3.0 version: 4.3.0 @@ -1236,6 +1242,9 @@ importers: '@types/ramda': specifier: 0.28.20 version: 0.28.20 + '@types/which': + specifier: ^2.0.2 + version: 2.0.2 is-windows: specifier: ^1.0.2 version: 1.0.2 @@ -3568,6 +3577,9 @@ importers: '@pnpm/types': specifier: workspace:* version: link:../../packages/types + '@pnpm/which': + specifier: ^3.0.1 + version: 3.0.1 '@pnpm/workspace.pkgs-graph': specifier: workspace:* version: link:../../workspace/pkgs-graph @@ -3586,9 +3598,6 @@ importers: '@zkochan/table': specifier: ^1.0.1 version: 1.0.1 - '@zkochan/which': - specifier: ^2.0.3 - version: 2.0.3 chalk: specifier: ^4.1.2 version: 4.1.2 @@ -8678,6 +8687,14 @@ packages: semver-utils: 1.1.4 dev: true + /@pnpm/which@3.0.1: + resolution: {integrity: sha512-4ivtS12Oni9axgGefaq+gTPD+7N0VPCFdxFH8izCaWfnxLQblX3iVxba+25ZoagStlzUs8sQg8OMKlCVhyGWTw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + dependencies: + isexe: 2.0.0 + dev: false + /@pnpm/write-project-manifest@4.1.2: resolution: {integrity: sha512-/C0j7SsE9tGoj++f0dwePIV7zNZHcX8TcYL6pXNvZZCq4HsOMCBsIlcU9oMI/AGe+KMDfHFQSayWPO9QUuGE5w==} engines: {node: '>=14.6'} @@ -17671,10 +17688,12 @@ packages: time: /@pnpm/node-fetch@1.0.0: '2023-04-19T11:13:43.487Z' /@pnpm/ramda@0.28.1: '2022-08-03T13:56:59.597Z' + /@pnpm/which@3.0.1: '2023-05-14T22:08:27.551Z' /@types/byline@4.2.33: '2021-07-06T18:22:06.440Z' /@types/table@6.0.0: '2020-09-17T17:56:44.787Z' /@zkochan/hosted-git-info@4.0.2: '2021-09-05T21:33:51.709Z' /@zkochan/js-yaml@0.0.6: '2022-05-10T14:42:39.813Z' + /@zkochan/table@1.0.1: '2023-03-19T21:47:05.638Z' /fuse-native@2.2.6: '2020-06-03T19:26:36.838Z' /node-gyp@9.3.1: '2022-12-19T22:43:10.187Z' /safe-execa@0.1.2: '2022-07-18T01:09:17.517Z'