Skip to content

Commit b841b87

Browse files
committed
feat: suggest the closest command instead of dumping help
1 parent 07e7981 commit b841b87

13 files changed

Lines changed: 389 additions & 63 deletions

File tree

‎packages/nuxi/src/main.ts‎

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ import { runMain as _runMain, defineCommand } from 'citty'
1010
import{provider}from'std-env'
1111

1212
import{cwdArgs}from'../../nuxt-cli/src/commands/_shared'
13-
import{isNuxiCommand}from'../../nuxt-cli/src/commands/_utils'
13+
import{isNuxiCommand,nuxiCommands}from'../../nuxt-cli/src/commands/_utils'
1414
import{setupGlobalConsole}from'../../nuxt-cli/src/utils/console'
1515
import{checkEngines}from'../../nuxt-cli/src/utils/engines'
1616
import{debug,logger}from'../../nuxt-cli/src/utils/logger'
17+
import{findInPath,withLocalBinPath}from'../../nuxt-cli/src/utils/path-env'
1718
import{description,name,version}from'../package.json'
1819

1920
// globalThis.crypto support for Node.js 18
@@ -76,25 +77,40 @@ const _main = defineCommand({
7677

7778
// allow running arbitrary commands if there's a locally registered binary with `nuxt-` prefix
7879
constcwd=resolve(ctx.args.cwd)
79-
try{
80-
const{ x }=awaitimport('tinyexec')
81-
// `tinyexec` will resolve command from local binaries
82-
awaitx(`nuxt-${ctx.args.command}`,ctx.rawArgs.slice(1),{
83-
nodeOptions: {stdio: 'inherit', cwd },
84-
throwOnError: true,
85-
})
80+
constenv=withLocalBinPath(cwd)
81+
// Resolved before spawning rather than after failing: Windows runs a bare
82+
// name through `cmd.exe`, which reports its own error instead of `ENOENT`,
83+
// so a missing binary would otherwise look like one that ran and failed.
84+
constbinary=findInPath(`nuxt-${ctx.args.command}`,env)
85+
if(!binary){
86+
returnreportUnknownCommand(ctx.args.command)
8687
}
87-
catch(err){
88-
// TODO: use windows err code as well
89-
if(errinstanceofError&&'code'inerr&&err.code==='ENOENT'){
90-
return
91-
}
92-
}
93-
process.exit()
88+
const{ x }=awaitimport('tinyexec')
89+
// The resolved path is spawned rather than the bare name: `tinyexec` would
90+
// otherwise search `node_modules/.bin` relative to this process's directory,
91+
// which is not the directory the command was asked to run in.
92+
constresult=awaitx(binary,ctx.rawArgs.slice(1),{
93+
nodeOptions: {stdio: 'inherit', cwd, env },
94+
nodePath: false,
95+
throwOnError: false,
96+
})
97+
process.exit(result.exitCode??0)
9498
}
9599
},
96100
})
97101

102+
asyncfunctionreportUnknownCommand(command: string): Promise<void>{
103+
const{ suggestCommand }=awaitimport('../../nuxt-cli/src/utils/suggest-command')
104+
constsuggestion=awaitsuggestCommand(command,nuxiCommands.filter(name=>!name.startsWith('_')))
105+
if(!suggestion){
106+
return
107+
}
108+
109+
logger.error(`Unknown command ${styleText('cyan',command)}. Did you mean ${styleText('cyan',`nuxt ${suggestion}`)}?`)
110+
logger.info(`Run ${styleText('cyan','nuxt --help')} to see all commands.`)
111+
process.exit(1)
112+
}
113+
98114
exportconstmain=_mainasCommandDef<any>
99115

100116
/**

‎packages/nuxt-cli/src/commands/_utils.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const nuxiCommands = [
77
'analyze',
88
'build',
99
'cleanup',
10+
'completion',
1011
'_dev',
1112
'dev',
1213
'devtools',

‎packages/nuxt-cli/src/dev/binaries.ts‎

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { Buffer } from 'node:buffer'
22
import{execFileSync}from'node:child_process'
33
import{chmodSync,existsSync,mkdirSync,mkdtempSync,renameSync,rmSync,writeFileSync}from'node:fs'
44
import{homedir}from'node:os'
5-
import{delimiter}from'node:path'
65

76
importprocessfrom'node:process'
87

@@ -13,6 +12,7 @@ import { readUser, updateUser } from 'rc9'
1312
import{restoreRawMode,withDirectStdout}from'../utils/console'
1413
import{debug,logger}from'../utils/logger'
1514
import{logNetworkError}from'../utils/network'
15+
import{findInPath}from'../utils/path-env'
1616

1717
interfaceConsentOptions{
1818
/** Key under `tools` in the user `.nuxtrc` used to persist acceptance. */
@@ -98,21 +98,6 @@ export function getCacheDir(...segments: string[]): string {
9898
returndir
9999
}
100100

101-
exportfunctionfindInPath(name: string): string|undefined{
102-
constextensions=process.platform==='win32' ? ['.exe','.cmd','.bat'] : ['']
103-
for(constdirof(process.env.PATH||'').split(delimiter)){
104-
if(!dir){
105-
continue
106-
}
107-
for(constextensionofextensions){
108-
constcandidate=join(dir,name+extension)
109-
if(existsSync(candidate)){
110-
returncandidate
111-
}
112-
}
113-
}
114-
}
115-
116101
asyncfunctiondownloadBinary(url: string,destination: string,options: {archive?: boolean,name?: string}={}): Promise<string|undefined>{
117102
constlabel=options.name||url
118103
try{

‎packages/nuxt-cli/src/dev/cert.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import { join } from 'pathe'
1111

1212
import{ActionableError}from'../utils/errors'
1313
import{debug,logger}from'../utils/logger'
14-
import{findInPath,getCacheDir,resolveTool}from'./binaries'
14+
import{findInPath}from'../utils/path-env'
15+
import{getCacheDir,resolveTool}from'./binaries'
1516

1617
exportinterfaceHTTPSOptions{
1718
cert?: string

‎packages/nuxt-cli/src/main.ts‎

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { setupGlobalConsole } from './utils/console'
1717
import{checkEngines}from'./utils/engines'
1818
import{debug,logger}from'./utils/logger'
1919
import{setupProxySupport}from'./utils/network'
20-
import{withLocalBinPath}from'./utils/path-env'
20+
import{findInPath,withLocalBinPath}from'./utils/path-env'
2121
import{resolveProjectDir}from'./utils/paths'
2222
import{templateNames}from'./utils/templates/names'
2323
import{findUnknownFlags,suggestFlags}from'./utils/unknown-args'
@@ -76,24 +76,24 @@ const _main = defineCommand({
7676
// allow running arbitrary commands if there's a locally registered binary with `nuxt-` prefix
7777
if(ctx.args.command&&!Object.hasOwn(commands,ctx.args.command)){
7878
constcwd=resolve(ctx.args.cwd)
79-
try{
80-
const{ x }=awaitimport('tinyexec')
81-
constresult=awaitx(`nuxt-${ctx.args.command}`,ctx.rawArgs.slice(1),{
82-
nodeOptions: {
83-
stdio: 'inherit',
84-
cwd,
85-
env: withLocalBinPath(cwd),
86-
},
87-
throwOnError: false,
88-
})
89-
process.exit(result.exitCode??1)
90-
}
91-
catch(err){
92-
if(errinstanceofError&&'code'inerr&&err.code==='ENOENT'){
93-
return
94-
}
95-
throwerr
79+
constenv=withLocalBinPath(cwd)
80+
// Resolved before spawning rather than after failing: Windows runs a bare
81+
// name through `cmd.exe`, which reports its own error instead of `ENOENT`,
82+
// so a missing binary would otherwise look like one that ran and failed.
83+
constbinary=findInPath(`nuxt-${ctx.args.command}`,env)
84+
if(!binary){
85+
returnreportUnknownCommand(ctx.args.command)
9686
}
87+
const{ x }=awaitimport('tinyexec')
88+
// The resolved path is spawned rather than the bare name: `tinyexec` would
89+
// otherwise search `node_modules/.bin` relative to this process's directory,
90+
// which is not the directory the command was asked to run in.
91+
constresult=awaitx(binary,ctx.rawArgs.slice(1),{
92+
nodeOptions: {stdio: 'inherit', cwd, env },
93+
nodePath: false,
94+
throwOnError: false,
95+
})
96+
process.exit(result.exitCode??1)
9797
}
9898
},
9999
})
@@ -133,4 +133,24 @@ function resolveLazy<T>(value: T | (() => T | Promise<T>) | undefined): Promise<
133133
returnPromise.resolve(typeofvalue==='function' ? (valueas()=>T|Promise<T>)() : value)
134134
}
135135

136+
/**
137+
* Report a command that neither the CLI nor a local `nuxt-` binary provides.
138+
*
139+
* With a confident suggestion this is the whole error, since a full help dump
140+
* buries the one line the user needs. Otherwise nothing is printed and citty
141+
* falls back to showing usage.
142+
*/
143+
asyncfunctionreportUnknownCommand(command: string): Promise<void>{
144+
const{ suggestCommand }=awaitimport('./utils/suggest-command')
145+
constnames=Object.keys(commands).filter(name=>!name.startsWith('_'))
146+
constsuggestion=awaitsuggestCommand(command,names)
147+
if(!suggestion){
148+
return
149+
}
150+
151+
logger.error(`Unknown command ${styleText('cyan',command)}. Did you mean ${styleText('cyan',`nuxt ${suggestion}`)}?`)
152+
logger.info(`Run ${styleText('cyan','nuxt --help')} to see all commands.`)
153+
process.exit(1)
154+
}
155+
136156
exportconstmain=_mainasCommandDef<any>

‎packages/nuxt-cli/src/utils/path-env.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import{existsSync}from'node:fs'
12
importprocessfrom'node:process'
2-
import{delimiter,resolve}from'pathe'
3+
4+
import{delimiter,join,resolve}from'pathe'
35

46
/**
57
* Return a copy of `env` with `dirs` prepended to its `PATH`.
@@ -10,7 +12,7 @@ import { delimiter, resolve } from 'pathe'
1012
*/
1113
exportfunctionwithPrependedPath(env: NodeJS.ProcessEnv,dirs: string[]): NodeJS.ProcessEnv{
1214
constresult: NodeJS.ProcessEnv={ ...env}
13-
constkey=Object.keys(result).find(name=>name.toLowerCase()==='path')??'PATH'
15+
constkey=pathKey(result)
1416
constcurrent=result[key]
1517
result[key]=[...dirs, ...(current ? [current] : [])].join(delimiter)
1618
returnresult
@@ -19,3 +21,32 @@ export function withPrependedPath(env: NodeJS.ProcessEnv, dirs: string[]): NodeJ
1921
exportfunctionwithLocalBinPath(cwd: string,env: NodeJS.ProcessEnv=process.env): NodeJS.ProcessEnv{
2022
returnwithPrependedPath(env,[resolve(cwd,'node_modules/.bin')])
2123
}
24+
25+
functionpathKey(env: NodeJS.ProcessEnv): string{
26+
returnObject.keys(env).find(name=>name.toLowerCase()==='path')??'PATH'
27+
}
28+
29+
/**
30+
* The path `name` resolves to on `env`'s `PATH`, or `undefined` if it is not there.
31+
*
32+
* Callers cannot rely on a failed spawn to tell them a command is missing: on
33+
* Windows a bare name is run through `cmd.exe`, which reports its own "not
34+
* recognized" error rather than `ENOENT`. Which extensions make a file executable
35+
* is a Windows concept, so `PATHEXT` is consulted there and nowhere else.
36+
*/
37+
exportfunctionfindInPath(name: string,env: NodeJS.ProcessEnv=process.env): string|undefined{
38+
constextensions=process.platform==='win32'
39+
? (env.PATHEXT||'.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean)
40+
: ['']
41+
for(constdirof(env[pathKey(env)]||'').split(delimiter)){
42+
if(!dir){
43+
continue
44+
}
45+
for(constextensionofextensions){
46+
constcandidate=join(dir,name+extension)
47+
if(existsSync(candidate)){
48+
returncandidate
49+
}
50+
}
51+
}
52+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import{commandPolicy,suggestClosest}from'./suggest'
2+
3+
/** Best guess at the command a user meant to type. */
4+
exportfunctionsuggestCommand(input: string,commands: string[]): Promise<string|undefined>{
5+
returnsuggestClosest(input,commands,commandPolicy)
6+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
exportinterfaceSuggestionPolicy{
2+
/** Minimum `fuzzysort` score for a subsequence match to be believed. */
3+
threshold: number
4+
/** How far ahead of the runner up a subsequence match has to be. */
5+
margin?: number
6+
/** Maximum number of edits between the input and a candidate. */
7+
tolerance?: (input: string)=>number
8+
/** Whether a unique prefix of exactly one candidate is accepted outright. */
9+
prefix?: boolean
10+
/** Whether candidates tied on edit distance are rejected rather than picked between. */
11+
requireUnique?: boolean
12+
}
13+
14+
/**
15+
* A wrong command suggestion replaces the help output the user would otherwise
16+
* have read, so it has to be right; a wrong flag suggestion sits next to the
17+
* flag they typed, so a lower bar is worth the extra hits.
18+
*/
19+
exportconstcommandPolicy: SuggestionPolicy={
20+
threshold: 0.6,
21+
margin: 0.1,
22+
tolerance: input=>input.length<=4 ? 1 : 2,
23+
prefix: true,
24+
requireUnique: true,
25+
}
26+
27+
exportconstflagPolicy: SuggestionPolicy={
28+
threshold: 0.3,
29+
tolerance: input=>input.length<=4 ? 1 : 2,
30+
}
31+
32+
/**
33+
* Best guess at the candidate an input was meant to be, or `undefined` when
34+
* nothing is close enough to be worth printing.
35+
*
36+
* `fuzzysort` matches prefixes and subsequences well but scores transpositions
37+
* at zero (`biuld` against `build`, `dotnev` against `dotenv`), which is the
38+
* most common typo of all, so edit distance covers what it rejects.
39+
*/
40+
exportasyncfunctionsuggestClosest(input: string,candidates: string[],policy: SuggestionPolicy): Promise<string|undefined>{
41+
constquery=input.toLowerCase()
42+
if(!query||candidates.includes(input)){
43+
returnundefined
44+
}
45+
46+
constlowerCased=candidates.map(candidate=>candidate.toLowerCase())
47+
48+
if(policy.prefix){
49+
constprefixMatches=candidates.filter((_,index)=>lowerCased[index]!.startsWith(query))
50+
if(prefixMatches.length===1){
51+
returnprefixMatches[0]
52+
}
53+
}
54+
55+
const{default: fuzzysort}=awaitimport('fuzzysort')
56+
const[best,runnerUp]=fuzzysort.go(query,candidates,{threshold: policy.threshold})
57+
if(best&&(!runnerUp||best.score-runnerUp.score>=(policy.margin??0))){
58+
returnbest.target
59+
}
60+
61+
consttolerance=policy.tolerance?.(query)??0
62+
if(tolerance<=0){
63+
returnundefined
64+
}
65+
66+
letclosest: string|undefined
67+
letclosestDistance=Number.POSITIVE_INFINITY
68+
lettied=false
69+
for(const[index,candidate]oflowerCased.entries()){
70+
constdistance=editDistance(query,candidate)
71+
if(distance<closestDistance){
72+
closestDistance=distance
73+
closest=candidates[index]
74+
tied=false
75+
}
76+
elseif(distance===closestDistance){
77+
tied=true
78+
}
79+
}
80+
81+
if(closestDistance>tolerance||(tied&&policy.requireUnique)){
82+
returnundefined
83+
}
84+
returnclosest
85+
}
86+
87+
/** Damerau-Levenshtein distance, so a single transposition counts as one edit. */
88+
functioneditDistance(a: string,b: string): number{
89+
constrows=Array.from({length: a.length+1},()=>newUint16Array(b.length+1))
90+
for(leti=0;i<=a.length;i++){
91+
rows[i]![0]=i
92+
}
93+
for(letj=0;j<=b.length;j++){
94+
rows[0]![j]=j
95+
}
96+
for(leti=1;i<=a.length;i++){
97+
for(letj=1;j<=b.length;j++){
98+
constcost=a[i-1]===b[j-1] ? 0 : 1
99+
rows[i]![j]=Math.min(
100+
rows[i-1]![j]!+1,
101+
rows[i]![j-1]!+1,
102+
rows[i-1]![j-1]!+cost,
103+
)
104+
if(i>1&&j>1&&a[i-1]===b[j-2]&&a[i-2]===b[j-1]){
105+
rows[i]![j]=Math.min(rows[i]![j]!,rows[i-2]![j-2]!+cost)
106+
}
107+
}
108+
}
109+
returnrows[a.length]![b.length]!
110+
}

‎packages/nuxt-cli/src/utils/unknown-args.ts‎

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
importtype{ArgsDef}from'citty'
22

3+
import{flagPolicy,suggestClosest}from'./suggest'
4+
35
/** Always accepted by citty, so never reported as unknown. */
46
constBUILTIN_FLAGS=['help','version']
57

6-
/** Low enough to catch a wrong case (`--loglevel`) or a dropped letter, high enough not to invent a match. */
7-
constSUGGESTION_THRESHOLD=0.3
8-
98
constLEADING_DASHES_RE=/^--/
109
constNEGATION_RE=/^no-/
1110

@@ -53,11 +52,10 @@ export function findUnknownFlags(argsDef: ArgsDef, rawArgs: string[]): UnknownFl
5352
* separately so the fuzzy matcher is only loaded once something is wrong.
5453
*/
5554
exportasyncfunctionsuggestFlags({ flags, known }: UnknownFlags): Promise<Array<{flag: string,suggestion?: string}>>{
56-
const{default: fuzzysort}=awaitimport('fuzzysort')
57-
returnflags.map((flag)=>{
58-
const[match]=fuzzysort.go(flag.replace(NEGATION_RE,''),known,{limit: 1,threshold: SUGGESTION_THRESHOLD})
59-
return{flag: `--${flag}`,suggestion: match&&`--${match.target}`}
60-
})
55+
returnPromise.all(flags.map(async(flag)=>{
56+
constmatch=awaitsuggestClosest(flag.replace(NEGATION_RE,''),known,flagPolicy)
57+
return{flag: `--${flag}`,suggestion: match&&`--${match}`}
58+
}))
6159
}
6260

6361
/**

0 commit comments

Comments
 (0)