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) as Promise<CommandDef>
44

5-
export const commands = {
5+
const commandLoaders = {
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
} as const
25+
26+
export const commands = Object.assign(Object.create(null), commandLoaders) as typeof commandLoaders

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.command in commands)) {
72+
if (ctx.args.command && !Object.hasOwn(commands, ctx.args.command)) {
7273
const cwd = resolve(ctx.args.cwd)
7374
try {
7475
const { x } = await import('tinyexec')
75-
// `tinyexec` will resolve command from local binaries
76-
await x(`nuxt-${ctx.args.command}`, ctx.rawArgs.slice(1), {
77-
nodeOptions: { stdio: 'inherit', cwd },
78-
throwOnError: true,
76+
const result = await x(`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 (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
8488
return
8589
}
90+
throw err
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 (!(name in commands)) {
49+
if (!Object.hasOwn(commands, name)) {
5050
throw new Error(`Invalid command ${name}`)
5151
}
5252

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import process from '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+
export function withPrependedPath(env: NodeJS.ProcessEnv, dirs: string[]): NodeJS.ProcessEnv {
12+
const result: NodeJS.ProcessEnv = { ...env }
13+
const key = Object.keys(result).find(name => name.toLowerCase() === 'path') ?? 'PATH'
14+
const current = result[key]
15+
result[key] = [...dirs, ...(current ? [current] : [])].join(delimiter)
16+
return result
17+
}
18+
19+
export function withLocalBinPath(cwd: string, env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
20+
return withPrependedPath(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
const res = await x(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+
const res = await x(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+
const binDir = join(fixtureDir, 'node_modules/.bin')
155+
const bin = join(binDir, 'nuxt-exit-test')
156+
await mkdir(binDir, { recursive: true })
157+
await writeFile(bin, '#!/bin/sh\nexit 42\n')
158+
await chmod(bin, 0o755)
159+
160+
try {
161+
const res = await x(nuxi, ['exit-test'], {
162+
nodeOptions: { stdio: 'pipe', cwd: fixtureDir },
163+
})
164+
expect(res.exitCode).toBe(42)
165+
}
166+
finally {
167+
await rm(bin, { force: true })
168+
}
169+
})
170+
145171
const testsToRun = Object.entries(tests).filter(([_, value]) => value !== 'todo')
146172
it.each(testsToRun)(`%s`, { timeout: isWindows ? 200000 : 50000 }, (_, test) => (test as () => 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 } = await import('../../src/run')
71+
72+
await expect(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+
const env = 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+
const env = 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)