Skip to content

Commit 36119fe

Browse files
committed
feat: offer to rerun with correct spelling of commands/args
1 parent 7a07a7c commit 36119fe

4 files changed

Lines changed: 99 additions & 10 deletions

File tree

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

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ import { provider } from 'std-env'
1111
import{description,name,version}from'../package.json'
1212
import{commands}from'./commands'
1313
import{cwdArgs,globalCwdArgs}from'./commands/_shared'
14-
import{runCommand,setCurrentCommand}from'./run'
14+
import{LONG_RUNNING_COMMANDS,runCommand,setCurrentCommand}from'./run'
1515
import{setupGlobalConsole}from'./utils/console'
1616
import{debug,logger}from'./utils/logger'
1717
import{setupProxySupport}from'./utils/network'
1818
import{findInPath,withLocalBinPath}from'./utils/path-env'
1919
import{templateNames}from'./utils/templates/names'
20-
import{findUnknownFlags,suggestFlags}from'./utils/unknown-args'
20+
import{findUnknownFlags,replaceFlag,suggestFlags}from'./utils/unknown-args'
2121
import{scheduleUpdateNudge}from'./utils/update-lazy'
2222

2323
// Node.js only reads `NODE_USE_ENV_PROXY` during bootstrap, so this cannot make
@@ -78,7 +78,7 @@ const _main = defineCommand({
7878
// so a missing binary would otherwise look like one that ran and failed.
7979
constbinary=findInPath(`nuxt-${ctx.args.command}`,env)
8080
if(!binary){
81-
returnreportUnknownCommand(ctx.args.command)
81+
returnreportUnknownCommand(ctx.args.command,ctx.rawArgs)
8282
}
8383
const{ x }=awaitimport('tinyexec')
8484
// The resolved path is spawned rather than the bare name: `tinyexec` would
@@ -120,8 +120,33 @@ async function warnUnknownFlags(command: string, rawArgs: string[]): Promise<voi
120120
return
121121
}
122122

123-
for(const{ flag, suggestion }ofawaitsuggestFlags(unknown)){
124-
logger.warn(`Unknown option ${styleText('cyan',flag)}.${suggestion ? ` Did you mean ${styleText('cyan',suggestion)}?` : ''}`)
123+
constsuggestions=awaitsuggestFlags(unknown)
124+
const{ isInteractive }=awaitimport('./utils/stdout')
125+
if(!isInteractive()){
126+
for(const{ flag, suggestion }ofsuggestions){
127+
logger.warn(`Unknown option ${styleText('cyan',flag)}.${suggestion ? ` Did you mean ${styleText('cyan',suggestion)}?` : ''}`)
128+
}
129+
return
130+
}
131+
132+
const{ confirm, isCancel }=awaitimport('@clack/prompts')
133+
const{ restoreRawMode }=awaitimport('./utils/console')
134+
for(const{ flag, suggestion }ofsuggestions){
135+
if(!suggestion){
136+
logger.warn(`Unknown option ${styleText('cyan',flag)}.`)
137+
continue
138+
}
139+
// A negated unknown flag is matched against its bare name, so the offered
140+
// replacement has to restore the negation the user asked for.
141+
constreplacement=flag.startsWith('--no-')&&!suggestion.startsWith('--no-')
142+
? `--no-${suggestion.slice(2)}`
143+
: suggestion
144+
logger.warn(`Unknown option ${styleText('cyan',flag)}.`)
145+
constanswer=awaitconfirm({message: `Use ${styleText('cyan',replacement)} instead?`,initialValue: true})
146+
restoreRawMode()
147+
if(!isCancel(answer)&&answer){
148+
replaceFlag(rawArgs,flag,replacement)
149+
}
125150
}
126151
}
127152

@@ -133,17 +158,43 @@ function resolveLazy<T>(value: T | (() => T | Promise<T>) | undefined): Promise<
133158
* Report a command that neither the CLI nor a local `nuxt-` binary provides.
134159
*
135160
* With a confident suggestion this is the whole error, since a full help dump
136-
* buries the one line the user needs. Otherwise nothing is printed and citty
137-
* falls back to showing usage.
161+
* buries the one line the user needs; interactively, the suggestion is offered
162+
* to run directly. Otherwise nothing is printed and citty falls back to
163+
* showing usage.
138164
*/
139-
asyncfunctionreportUnknownCommand(command: string): Promise<void>{
165+
asyncfunctionreportUnknownCommand(command: string,rawArgs: string[]): Promise<void>{
140166
const{ suggestCommand }=awaitimport('./utils/suggest-command')
141167
constnames=Object.keys(commands).filter(name=>!name.startsWith('_'))
142168
constsuggestion=awaitsuggestCommand(command,names)
143169
if(!suggestion){
144170
return
145171
}
146172

173+
const{ isInteractive }=awaitimport('./utils/stdout')
174+
if(isInteractive()){
175+
logger.warn(`Unknown command ${styleText('cyan',command)}.`)
176+
const{ confirm, isCancel }=awaitimport('@clack/prompts')
177+
constanswer=awaitconfirm({message: `Run ${styleText('cyan',`nuxt ${suggestion}`)} instead?`,initialValue: true})
178+
const{ restoreRawMode }=awaitimport('./utils/console')
179+
restoreRawMode()
180+
181+
if(!isCancel(answer)&&answer){
182+
constindex=rawArgs.indexOf(command)
183+
constargv=index===-1 ? rawArgs : rawArgs.toSpliced(index,1)
184+
setCurrentCommand(suggestion)
185+
awaitrunCommand(suggestion,argv).catch((err)=>{
186+
console.error(err.message)
187+
process.exit(1)
188+
})
189+
if(LONG_RUNNING_COMMANDS.has(suggestion)){
190+
// Keep the process serving; exiting here, or returning to citty (which
191+
// would fail to resolve the unknown subcommand), would tear it down.
192+
awaitnewPromise(()=>{})
193+
}
194+
process.exit(0)
195+
}
196+
}
197+
147198
logger.error(`Unknown command ${styleText('cyan',command)}. Did you mean ${styleText('cyan',`nuxt ${suggestion}`)}?`)
148199
logger.info(`Run ${styleText('cyan','nuxt --help')} to see all commands.`)
149200
process.exit(1)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ globalThis.__nuxt_cli__ = globalThis.__nuxt_cli__ || {
2020
}
2121

2222
// Commands that keep serving after their `run` resolves, so an alive process is expected.
23-
constLONG_RUNNING_COMMANDS=newSet(['dev','_dev','analyze','test'])
23+
exportconstLONG_RUNNING_COMMANDS: Set<string>=newSet(['dev','_dev','analyze','test'])
2424

2525
letcurrentCommand: string|undefined
2626

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,24 @@ export async function suggestFlags({ flags, known }: UnknownFlags): Promise<Arra
5858
}))
5959
}
6060

61+
/**
62+
* Rewrite every use of `--flag` (bare or `=value`) to `replacement`, in place:
63+
* citty re-reads the same array when it resolves the subcommand, so an in-place
64+
* edit is what makes the correction reach the command that runs.
65+
*/
66+
exportfunctionreplaceFlag(rawArgs: string[],flag: string,replacement: string): void{
67+
constseparator=rawArgs.indexOf('--')
68+
constend=separator===-1 ? rawArgs.length : separator
69+
for(leti=0;i<end;i++){
70+
if(rawArgs[i]===flag){
71+
rawArgs[i]=replacement
72+
}
73+
elseif(rawArgs[i]!.startsWith(`${flag}=`)){
74+
rawArgs[i]=`${replacement}${rawArgs[i]!.slice(flag.length)}`
75+
}
76+
}
77+
}
78+
6179
/**
6280
* Dotted flags are declared either whole (`https.cert`) or as the object that
6381
* holds them (`https`), and a boolean may be negated with a `no-` prefix.

‎packages/nuxt-cli/test/unit/unknown-args.spec.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'
44

55
import{commands}from'../../src/commands'
66
import{cwdArgs}from'../../src/commands/_shared'
7-
import{findUnknownFlags,suggestFlags}from'../../src/utils/unknown-args'
7+
import{findUnknownFlags,replaceFlag,suggestFlags}from'../../src/utils/unknown-args'
88

99
constargsDef={
1010
'cwd': {type: 'string'},
@@ -65,6 +65,26 @@ describe('suggestFlags', () => {
6565
})
6666
})
6767

68+
describe('replaceFlag',()=>{
69+
it('should replace bare and `=value` forms in place',()=>{
70+
constrawArgs=['info','--cdw','.','--cdw=app','--json']
71+
replaceFlag(rawArgs,'--cdw','--cwd')
72+
expect(rawArgs).toEqual(['info','--cwd','.','--cwd=app','--json'])
73+
})
74+
75+
it('should leave everything after `--` alone',()=>{
76+
constrawArgs=['dev','--prot=3000','--','--prot=4000']
77+
replaceFlag(rawArgs,'--prot','--port')
78+
expect(rawArgs).toEqual(['dev','--port=3000','--','--prot=4000'])
79+
})
80+
81+
it('should not touch flags that merely share a prefix',()=>{
82+
constrawArgs=['dev','--ports=3000']
83+
replaceFlag(rawArgs,'--port','--p')
84+
expect(rawArgs).toEqual(['dev','--ports=3000'])
85+
})
86+
})
87+
6888
describe('declared command arguments',()=>{
6989
it('should never be reported as unknown',async()=>{
7090
constreported: Record<string,string[]>={}

0 commit comments

Comments
 (0)