Skip to content

Commit abfd7eb

Browse files
committed
fix(cli): preserve local command failures
1 parent f9e9813 commit abfd7eb

7 files changed

Lines changed: 94 additions & 11 deletions

File tree

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { CommandDef } from 'citty'
22

33
const_rDefault=(r: any)=>(r.default||r)asPromise<CommandDef>
44

5-
exportconstcommands={
5+
constcommandLoaders={
66
'add': ()=>import('./add').then(_rDefault),
77
'add-template': ()=>import('./add-template').then(_rDefault),
88
'analyze': ()=>import('./analyze').then(_rDefault),
@@ -22,3 +22,5 @@ export const commands = {
2222
'typecheck': ()=>import('./typecheck').then(_rDefault),
2323
'upgrade': ()=>import('./upgrade').then(_rDefault),
2424
}asconst
25+
26+
exportconstcommands=Object.assign(Object.create(null),commandLoaders)astypeofcommandLoaders

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +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'
2021
import{resolveProjectDir}from'./utils/paths'
2122
import{templateNames}from'./utils/templates/names'
2223
import{scheduleUpdateNudge}from'./utils/update-lazy'
@@ -68,23 +69,26 @@ const _main = defineCommand({
6869
}
6970

7071
// allow running arbitrary commands if there's a locally registered binary with `nuxt-` prefix
71-
if(ctx.args.command&&!(ctx.args.commandincommands)){
72+
if(ctx.args.command&&!Object.hasOwn(commands,ctx.args.command)){
7273
constcwd=resolve(ctx.args.cwd)
7374
try{
7475
const{ x }=awaitimport('tinyexec')
75-
// `tinyexec` will resolve command from local binaries
76-
awaitx(`nuxt-${ctx.args.command}`,ctx.rawArgs.slice(1),{
77-
nodeOptions: {stdio: 'inherit', cwd },
78-
throwOnError: true,
76+
constresult=awaitx(`nuxt-${ctx.args.command}`,ctx.rawArgs.slice(1),{
77+
nodeOptions: {
78+
stdio: 'inherit',
79+
cwd,
80+
env: withLocalBinPath(cwd),
81+
},
82+
throwOnError: false,
7983
})
84+
process.exit(result.exitCode??1)
8085
}
8186
catch(err){
82-
// TODO: use windows err code as well
8387
if(errinstanceofError&&'code'inerr&&err.code==='ENOENT'){
8488
return
8589
}
90+
throwerr
8691
}
87-
process.exit()
8892
}
8993
},
9094
})

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export async function runCommand(
4646
argv: string[]=process.argv.slice(2),
4747
data: {overrides?: Record<string,any>}={},
4848
): Promise<{result: unknown}>{
49-
if(!(nameincommands)){
49+
if(!Object.hasOwn(commands,name)){
5050
thrownewError(`Invalid command ${name}`)
5151
}
5252

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
importprocessfrom'node:process'
2+
import{delimiter,resolve}from'pathe'
3+
4+
/**
5+
* Return a copy of `env` with `dirs` prepended to its `PATH`.
6+
*
7+
* On Windows the variable may be named `Path`, and adding a second `PATH` key
8+
* would leave the child with two conflicting entries, so the existing key is
9+
* reused when present.
10+
*/
11+
exportfunctionwithPrependedPath(env: NodeJS.ProcessEnv,dirs: string[]): NodeJS.ProcessEnv{
12+
constresult: NodeJS.ProcessEnv={ ...env}
13+
constkey=Object.keys(result).find(name=>name.toLowerCase()==='path')??'PATH'
14+
constcurrent=result[key]
15+
result[key]=[...dirs, ...(current ? [current] : [])].join(delimiter)
16+
returnresult
17+
}
18+
19+
exportfunctionwithLocalBinPath(cwd: string,env: NodeJS.ProcessEnv=process.env): NodeJS.ProcessEnv{
20+
returnwithPrependedPath(env,[resolve(cwd,'node_modules/.bin')])
21+
}

‎packages/nuxt-cli/test/e2e/commands.spec.ts‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { commands } from '../../src/commands'
33

44
import{existsSync}from'node:fs'
55

6-
import{rm}from'node:fs/promises'
6+
import{chmod,mkdir,rm,writeFile}from'node:fs/promises'
77
import{join}from'node:path'
88
import{fileURLToPath}from'node:url'
99
import{getPort}from'get-port-please'
@@ -133,7 +133,7 @@ describe('commands', () => {
133133
expect(res.stderr).toBe('[error] No command specified.\n')
134134
})
135135

136-
// TODO: FIXME - windows currently throws 'nuxt-foo' is not recognized as an internal or external command, operable program or batch file.
136+
// TODO: on Windows tinyexec falls back to `cmd.exe`, which reports the missing binary itself rather than surfacing ENOENT
137137
it.skipIf(isWindows)('throws error if wrong command is provided',async()=>{
138138
constres=awaitx(nuxi,['foo'],{
139139
nodeOptions: {stdio: 'pipe',cwd: fixtureDir},
@@ -142,6 +142,32 @@ describe('commands', () => {
142142
expect(res.stderr).toBe('[error] Unknown command foo\n')
143143
})
144144

145+
it.skipIf(isWindows)('rejects command names inherited from Object.prototype',async()=>{
146+
constres=awaitx(nuxi,['toString'],{
147+
nodeOptions: {stdio: 'pipe',cwd: fixtureDir},
148+
})
149+
expect(res.exitCode).toBe(1)
150+
expect(res.stderr).toBe('[error] Unknown command toString\n')
151+
})
152+
153+
it.skipIf(isWindows)('forwards the exit status of a local command',async()=>{
154+
constbinDir=join(fixtureDir,'node_modules/.bin')
155+
constbin=join(binDir,'nuxt-exit-test')
156+
awaitmkdir(binDir,{recursive: true})
157+
awaitwriteFile(bin,'#!/bin/sh\nexit 42\n')
158+
awaitchmod(bin,0o755)
159+
160+
try{
161+
constres=awaitx(nuxi,['exit-test'],{
162+
nodeOptions: {stdio: 'pipe',cwd: fixtureDir},
163+
})
164+
expect(res.exitCode).toBe(42)
165+
}
166+
finally{
167+
awaitrm(bin,{force: true})
168+
}
169+
})
170+
145171
consttestsToRun=Object.entries(tests).filter(([_,value])=>value!=='todo')
146172
it.each(testsToRun)(`%s`,{timeout: isWindows ? 200000 : 50000},(_,test)=>(testas()=>Promise<void>)())
147173

‎packages/nuxt-cli/test/unit/run.spec.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,10 @@ describe('runCommand', () => {
6565

6666
expect(argv).toEqual(['--clear','--cwd','.'])
6767
})
68+
69+
it('should reject inherited command properties',async()=>{
70+
const{ runCommand }=awaitimport('../../src/run')
71+
72+
awaitexpect(runCommand('toString',[])).rejects.toThrow('Invalid command toString')
73+
})
6874
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import{delimiter}from'pathe'
2+
import{describe,expect,it}from'vitest'
3+
4+
import{withLocalBinPath,withPrependedPath}from'../../../src/utils/path-env'
5+
6+
describe('withPrependedPath',()=>{
7+
it('reuses an existing path key regardless of case',()=>{
8+
constenv=withPrependedPath({Path: '/usr/bin'},['/local/bin'])
9+
10+
expect(env).toEqual({Path: `/local/bin${delimiter}/usr/bin`})
11+
})
12+
13+
it('creates a path when none is set',()=>{
14+
expect(withPrependedPath({},['/local/bin'])).toEqual({PATH: '/local/bin'})
15+
})
16+
})
17+
18+
describe('withLocalBinPath',()=>{
19+
it('prepends the local bin directory of the given directory',()=>{
20+
constenv=withLocalBinPath('/project',{PATH: '/usr/bin'})
21+
22+
expect(env.PATH).toBe(`/project/node_modules/.bin${delimiter}/usr/bin`)
23+
})
24+
})

0 commit comments

Comments
 (0)