feat(cli): improvements and external commands (#4314)

This commit is contained in:
Jonas Galvez 2018-12-20 09:15:48 -02:00 committed by Pooya Parsa
parent 8b366fd216
commit 0145551c3a
19 changed files with 235 additions and 304 deletions

View File

@ -1,3 +1,7 @@
#!/usr/bin/env node
require('../dist/cli.js').run()
.catch((error) => {
require('consola').fatal(error)
process.exit(1)
})

View File

@ -17,6 +17,7 @@
"chalk": "^2.4.1",
"consola": "^2.3.0",
"esm": "^3.0.84",
"execa": "^1.0.0",
"minimist": "^1.2.0",
"pretty-bytes": "^5.1.0",
"std-env": "^2.2.1",

View File

@ -1,66 +1,72 @@
import parseArgs from 'minimist'
import minimist from 'minimist'
import { name, version } from '../package.json'
import { loadNuxtConfig } from './utils'
import { indent, foldLines, startSpaces, optionSpaces, colorize } from './utils/formatting'
import * as commands from './commands'
import * as imports from './imports'
export default class NuxtCommand {
constructor(cmd = { name: '', usage: '', description: '', options: {} }) {
constructor(cmd = { name: '', usage: '', description: '' }, argv = process.argv.slice(2)) {
if (!cmd.options) {
cmd.options = {}
}
this.cmd = cmd
this._argv = Array.from(argv)
this._parsedArgv = null // Lazy evaluate
}
static async load(name) {
if (name in commands) {
const cmd = await commands[name]() // eslint-disable-line import/namespace
.then(m => m.default)
return NuxtCommand.from(cmd)
} else {
// TODO dynamic module loading
throw new Error('Command ' + name + ' could not be loaded!')
}
static run(cmd, argv) {
return NuxtCommand.from(cmd, argv).run()
}
static from(options) {
if (options instanceof NuxtCommand) {
return options
static from(cmd, argv) {
if (cmd instanceof NuxtCommand) {
return cmd
}
return new NuxtCommand(options)
return new NuxtCommand(cmd, argv)
}
run() {
return this.cmd.run(this)
if (this.argv.help) {
this.showHelp()
return Promise.resolve()
}
if (this.argv.version) {
this.showVersion()
return Promise.resolve()
}
if (typeof this.cmd.run !== 'function') {
return Promise.resolve()
}
return Promise.resolve(this.cmd.run(this))
}
showVersion() {
process.stdout.write(`${name} v${version}\n`)
process.exit(0)
}
showHelp() {
process.stdout.write(this._getHelp())
process.exit(0)
}
getArgv(args) {
get argv() {
if (!this._parsedArgv) {
const minimistOptions = this._getMinimistOptions()
const argv = parseArgs(args || process.argv.slice(2), minimistOptions)
if (argv.version) {
this.showVersion()
} else if (argv.help) {
this.showHelp()
this._parsedArgv = minimist(this._argv, minimistOptions)
}
return this._parsedArgv
}
return argv
}
async getNuxtConfig(argv, extraOptions) {
const config = await loadNuxtConfig(argv)
async getNuxtConfig(extraOptions) {
const config = await loadNuxtConfig(this.argv)
const options = Object.assign(config, extraOptions || {})
for (const name of Object.keys(this.cmd.options)) {
this.cmd.options[name].prepare && this.cmd.options[name].prepare(this, options, argv)
this.cmd.options[name].prepare && this.cmd.options[name].prepare(this, options, this.argv)
}
return options

View File

@ -1,4 +1,3 @@
import consola from 'consola'
import { common } from '../options'
export default {
@ -50,37 +49,17 @@ export default {
}
},
async run(cmd) {
const argv = cmd.getArgv()
const config = await cmd.getNuxtConfig({ dev: false })
const nuxt = await cmd.getNuxt(config)
// Create production build when calling `nuxt build` (dev: false)
const nuxt = await cmd.getNuxt(
await cmd.getNuxtConfig(argv, { dev: false })
)
let builderOrGenerator
if (nuxt.options.mode !== 'spa' || argv.generate === false) {
if (nuxt.options.mode !== 'spa' || cmd.argv.generate === false) {
// Build only
builderOrGenerator = (await cmd.getBuilder(nuxt)).build()
const builder = await cmd.getBuilder(nuxt)
await builder.build()
} else {
// Build + Generate for static deployment
builderOrGenerator = (await cmd.getGenerator(nuxt)).generate({
build: true
})
const generator = await cmd.getGenerator(nuxt)
await generator.generate({ build: true })
}
return builderOrGenerator
.then(() => {
// In analyze mode wait for plugin
// emitting assets and opening browser
if (
nuxt.options.build.analyze === true ||
typeof nuxt.options.build.analyze === 'object'
) {
return
}
process.exit(0)
})
.catch(err => consola.fatal(err))
}
}

View File

@ -13,7 +13,7 @@ export default {
},
async run(cmd) {
const argv = cmd.getArgv()
const argv = cmd.argv
await this.startDev(cmd, argv)
},
@ -26,10 +26,7 @@ export default {
},
async _startDev(cmd, argv) {
// Load config
const config = await cmd.getNuxtConfig(argv, { dev: true })
// Initialize nuxt instance
const config = await cmd.getNuxtConfig({ dev: true })
const nuxt = await cmd.getNuxt(config)
// Setup hooks

View File

@ -1,4 +1,3 @@
import consola from 'consola'
import { common } from '../options'
import { normalizeArg } from '../utils'
@ -36,19 +35,13 @@ export default {
}
},
async run(cmd) {
const argv = cmd.getArgv()
const config = await cmd.getNuxtConfig({ dev: false })
const nuxt = await cmd.getNuxt(config)
const generator = await cmd.getGenerator(nuxt)
const generator = await cmd.getGenerator(
await cmd.getNuxt(
await cmd.getNuxtConfig(argv, { dev: false })
)
)
return generator.generate({
await generator.generate({
init: true,
build: argv.build
}).then(() => {
process.exit(0)
}).catch(err => consola.fatal(err))
build: cmd.argv.build
})
}
}

View File

@ -1,20 +1,25 @@
import consola from 'consola'
import NuxtCommand from '../command'
import getCommand from '../commands'
import listCommands from '../list'
import { common } from '../options'
import NuxtCommand from '../command'
export default {
name: 'help',
description: 'Shows help for <command>',
usage: 'help <command>',
options: {
help: common.help,
version: common.version
},
async run(cmd) {
const argv = cmd.getArgv()._
const name = argv[0] || null
const name = cmd._argv[0]
if (!name) {
return listCommands().then(() => process.exit(0))
return listCommands()
}
const command = await NuxtCommand.load(name)
const command = await getCommand(name)
if (command) {
command.showHelp()
NuxtCommand.from(command).showHelp()
} else {
consola.info(`Unknown command: ${name}`)
}

View File

@ -1,5 +1,14 @@
export const start = () => import('./start')
export const dev = () => import('./dev')
export const build = () => import('./build')
export const generate = () => import('./generate')
export const help = () => import('./help')
const commands = {
start: () => import('./start'),
dev: () => import('./dev'),
build: () => import('./build'),
generate: () => import('./generate'),
help: () => import('./help')
}
export default function getCommand(name) {
if (!commands[name]) {
return Promise.resolve(null)
}
return commands[name]().then(m => m.default)
}

View File

@ -10,16 +10,11 @@ export default {
...server
},
async run(cmd) {
const argv = cmd.getArgv()
// Create production build when calling `nuxt build`
const nuxt = await cmd.getNuxt(
await cmd.getNuxtConfig(argv, { dev: false, _start: true })
)
const config = await cmd.getNuxtConfig({ dev: false, _start: true })
const nuxt = await cmd.getNuxt(config)
// Listen and show ready banner
return nuxt.server.listen().then(() => {
await nuxt.server.listen()
showBanner(nuxt)
})
}
}

View File

@ -1,13 +1,13 @@
import chalk from 'chalk'
import NuxtCommand from './command'
import { indent, foldLines, startSpaces, optionSpaces, colorize } from './utils/formatting'
import getCommand from './commands'
export default async function listCommands() {
const commandsOrder = ['dev', 'build', 'generate', 'start', 'help']
// Load all commands
const _commands = await Promise.all(
commandsOrder.map(cmd => NuxtCommand.load(cmd))
commandsOrder.map(cmd => getCommand(cmd))
)
let maxLength = 0

View File

@ -28,6 +28,7 @@ export default {
}
},
version: {
alias: 'v',
type: 'boolean',
description: 'Display the Nuxt version'
},

View File

@ -1,31 +1,42 @@
import consola from 'consola'
import fs from 'fs'
import execa from 'execa'
import NuxtCommand from './command'
import * as commands from './commands'
import setup from './setup'
import listCommands from './list'
import getCommand from './commands'
export default function run() {
const defaultCommand = 'dev'
let cmd = process.argv[2]
export default async function run(_argv) {
// Read from process.argv
const argv = _argv ? Array.from(_argv) : process.argv.slice(2)
if (commands[cmd]) { // eslint-disable-line import/namespace
process.argv.splice(2, 1)
// Check for internal command
let cmd = await getCommand(argv[0])
// Matching `nuxt` or `nuxt [dir]` or `nuxt -*` for `nuxt dev` shortcut
if (!cmd && (!argv[0] || argv[0][0] === '-' || fs.existsSync(argv[0]))) {
argv.unshift('dev')
cmd = await getCommand('dev')
}
// Setup env
setup({ dev: argv[0] === 'dev' })
// Try internal command
if (cmd) {
return NuxtCommand.run(cmd, argv.slice(1))
}
// Try external command
try {
await execa(`nuxt-${argv[0]}`, argv.slice(1), {
stdout: process.stdout,
stderr: process.stderr,
stdin: process.stdin
})
} catch (error) {
if (error.code === 'ENOENT') {
throw String(`Command not found: nuxt-${argv[0]}`)
} else {
if (process.argv.includes('--help') || process.argv.includes('-h')) {
listCommands().then(() => process.exit(0))
return
throw String(`Failed to run command \`nuxt-${argv[0]}\`:\n${error}`)
}
cmd = defaultCommand
}
// Setup runtime
setup({
dev: cmd === 'dev'
})
return NuxtCommand.load(cmd)
.then(command => command.run())
.catch((error) => {
consola.fatal(error)
})
}

View File

@ -9,7 +9,7 @@ import chalk from 'chalk'
import prettyBytes from 'pretty-bytes'
import env from 'std-env'
const _require = esm(module, {
export const requireModule = esm(module, {
cache: false,
cjs: {
cache: true,
@ -35,7 +35,7 @@ export async function loadNuxtConfig(argv) {
if (existsSync(nuxtConfigFile)) {
delete require.cache[nuxtConfigFile]
options = _require(nuxtConfigFile) || {}
options = requireModule(nuxtConfigFile) || {}
if (options.default) {
options = options.default
}
@ -120,6 +120,13 @@ export function showBanner(nuxt) {
process.stdout.write(box + '\n')
}
export function formatPath(filePath) {
if (!filePath) {
return
}
return filePath.replace(process.cwd() + path.sep, '')
}
/**
* Normalize string argument in command
*
@ -137,10 +144,3 @@ export function normalizeArg(arg, defaultValue) {
}
return arg
}
export function formatPath(filePath) {
if (!filePath) {
return
}
return filePath.replace(process.cwd() + path.sep, '')
}

View File

@ -11,7 +11,7 @@ exports[`cli/command builds help text 1`] = `
--universal, -u Launch in Universal mode (default)
--config-file, -c Path to Nuxt.js config file (default: nuxt.config.js)
--modern, -m Build/Start app for modern browsers, e.g. server, client and false
--version Display the Nuxt version
--version, -v Display the Nuxt version
--help, -h Display this message
--port, -p Port number on which to start the application
--hostname, -H Hostname on which to start the application
@ -20,23 +20,3 @@ exports[`cli/command builds help text 1`] = `
"
`;
exports[`cli/command loads command from name 1`] = `
" Usage: nuxt dev <dir> [options]
Start the application in development mode (e.g. hot-code reloading, error reporting)
Options:
--spa, -s Launch in SPA mode
--universal, -u Launch in Universal mode (default)
--config-file, -c Path to Nuxt.js config file (default: nuxt.config.js)
--modern, -m Build/Start app for modern browsers, e.g. server, client and false
--version Display the Nuxt version
--help, -h Display this message
--port, -p Port number on which to start the application
--hostname, -H Hostname on which to start the application
--unix-socket, -n Path to a UNIX socket
"
`;

View File

@ -1,4 +1,4 @@
import { consola, mockGetNuxt, mockGetBuilder, mockGetGenerator, NuxtCommand } from '../utils'
import { mockGetNuxt, mockGetBuilder, mockGetGenerator, NuxtCommand } from '../utils'
describe('build', () => {
let build
@ -8,7 +8,6 @@ describe('build', () => {
jest.spyOn(process, 'exit').mockImplementation(code => code)
})
afterAll(() => process.exit.mockRestore())
afterEach(() => jest.resetAllMocks())
test('has run function', () => {
@ -41,7 +40,6 @@ describe('build', () => {
await NuxtCommand.from(build).run()
expect(generate).toHaveBeenCalled()
expect(process.exit).toHaveBeenCalled()
})
test('build with devtools', async () => {
@ -50,12 +48,9 @@ describe('build', () => {
})
const builder = mockGetBuilder(Promise.resolve())
const cmd = NuxtCommand.from(build)
const args = ['build', '.', '--devtools']
const argv = cmd.getArgv(args)
argv._ = ['.']
const cmd = NuxtCommand.from(build, ['build', '.', '--devtools'])
const options = await cmd.getNuxtConfig(argv)
const options = await cmd.getNuxtConfig(cmd.argv)
await cmd.run()
@ -69,22 +64,12 @@ describe('build', () => {
})
mockGetBuilder(Promise.resolve())
const cmd = NuxtCommand.from(build)
const args = ['build', '.', '--m']
const cmd = NuxtCommand.from(build, ['build', '.', '--m'])
const options = await cmd.getNuxtConfig(cmd.getArgv(args))
const options = await cmd.getNuxtConfig()
await cmd.run()
expect(options.modern).toBe(true)
})
test('catches error', async () => {
mockGetNuxt({ mode: 'universal' })
mockGetBuilder(Promise.reject(new Error('Builder Error')))
await NuxtCommand.from(build).run()
expect(consola.fatal).toHaveBeenCalledWith(new Error('Builder Error'))
})
})

View File

@ -1,87 +1,41 @@
import { readdir } from 'fs'
import { resolve } from 'path'
import { promisify } from 'util'
import { consola } from '../utils'
import { run } from '../../src'
import * as commands from '../../src/commands'
const readDir = promisify(readdir)
import getCommand from '../../src/commands'
jest.mock('../../src/commands')
describe('cli', () => {
afterEach(() => jest.resetAllMocks())
test('exports for all commands defined', async () => {
const cmds = await readDir(resolve(__dirname, '..', '..', 'src', 'commands'))
for (let cmd of cmds) {
if (cmd === 'index.js') {
continue
}
cmd = cmd.substring(0, cmd.length - 3)
const cmdFn = commands[cmd] // eslint-disable-line import/namespace
expect(cmdFn).toBeDefined()
expect(typeof cmdFn).toBe('function')
}
})
test('calls expected method', async () => {
const argv = process.argv
process.argv = ['', '', 'dev']
const defaultExport = {
run: jest.fn().mockImplementation(() => Promise.resolve())
const mockedCommand = {
run: jest.fn().mockImplementation(() => Promise.resolve({}))
}
commands.dev.mockImplementationOnce(() => Promise.resolve({ default: defaultExport }))
getCommand.mockImplementationOnce(() => Promise.resolve(mockedCommand))
await run()
expect(defaultExport.run).toHaveBeenCalled()
process.argv = argv
})
test('unknown calls default method', async () => {
const argv = process.argv
process.argv = ['', '', 'test']
commands.dev.mockImplementationOnce(() => Promise.resolve())
await run()
expect(commands.dev).toHaveBeenCalled()
process.argv = argv
expect(mockedCommand.run).toHaveBeenCalled()
})
test('sets NODE_ENV=development for dev', async () => {
const nodeEnv = process.env.NODE_ENV
process.env.NODE_ENV = ''
commands.dev.mockImplementationOnce(() => Promise.resolve())
await run()
getCommand.mockImplementationOnce(() => Promise.resolve({}))
await run(['dev'])
expect(process.env.NODE_ENV).toBe('development')
process.env.NODE_ENV = nodeEnv
})
test('sets NODE_ENV=production for build', async () => {
const argv = process.argv
const nodeEnv = process.env.NODE_ENV
process.argv = ['', '', 'build']
process.env.NODE_ENV = ''
commands.build.mockImplementationOnce(() => Promise.resolve())
await run()
getCommand.mockImplementationOnce(() => Promise.resolve({}))
await run(['', '', 'build'])
expect(process.env.NODE_ENV).toBe('production')
process.argv = argv
process.env.NODE_ENV = nodeEnv
})
test('catches fatal error', async () => {
commands.dev.mockImplementationOnce(() => Promise.reject(new Error('Command Error')))
await run()
expect(consola.fatal).toHaveBeenCalledWith(new Error('Command Error'))
})
})

View File

@ -25,50 +25,38 @@ describe('cli/command', () => {
})
test('parses args', () => {
const cmd = new Command({ options: { ...common, ...server } })
const argv = ['-c', 'test-file', '-s', '-p', '3001']
const cmd = new Command({ options: { ...common, ...server } }, argv)
let args = ['-c', 'test-file', '-s', '-p', '3001']
let argv = cmd.getArgv(args)
expect(cmd.argv['config-file']).toBe(argv[1])
expect(cmd.argv.spa).toBe(true)
expect(cmd.argv.universal).toBe(false)
expect(cmd.argv.port).toBe('3001')
expect(argv['config-file']).toBe(args[1])
expect(argv.spa).toBe(true)
expect(argv.universal).toBe(false)
expect(argv.port).toBe('3001')
args = ['--no-build']
argv = cmd.getArgv(args)
expect(argv.build).toBe(false)
const cmd2 = new Command({ options: { ...common, ...server } }, ['--no-build'])
expect(cmd2.argv.build).toBe(false)
})
test('prints version automatically', () => {
const cmd = new Command()
test('prints version automatically', async () => {
const cmd = new Command({}, ['--version'])
cmd.showVersion = jest.fn()
const args = ['--version']
cmd.getArgv(args)
await cmd.run()
expect(cmd.showVersion).toHaveBeenCalledTimes(1)
})
test('prints help automatically', () => {
const cmd = new Command({ options: allOptions })
test('prints help automatically', async () => {
const cmd = new Command({ options: allOptions }, ['-h'])
cmd.showHelp = jest.fn()
const args = ['-h']
cmd.getArgv(args)
await cmd.run()
expect(cmd.showHelp).toHaveBeenCalledTimes(1)
})
test('returns nuxt config', async () => {
const cmd = new Command({ options: allOptions })
const cmd = new Command({ options: allOptions }, ['-c', 'test-file', '-a', '-p', '3001', '-q', '-H'])
const args = ['-c', 'test-file', '-a', '-p', '3001', '-q', '-H']
const argv = cmd.getArgv(args)
argv._ = ['.']
const options = await cmd.getNuxtConfig(argv, { testOption: true })
const options = await cmd.getNuxtConfig({ testOption: true })
expect(options.testOption).toBe(true)
expect(options.server.port).toBe(3001)
@ -117,36 +105,19 @@ describe('cli/command', () => {
expect(cmd._getHelp()).toMatchSnapshot()
})
test('loads command from name', async () => {
const cmd = await Command.load('dev')
expect(cmd._getHelp()).toMatchSnapshot()
})
test('show version prints to stdout and exits', () => {
jest.spyOn(process.stdout, 'write').mockImplementation(() => {})
jest.spyOn(process, 'exit').mockImplementationOnce(code => code)
const cmd = new Command()
cmd.showVersion()
expect(process.stdout.write).toHaveBeenCalled()
expect(process.exit).toHaveBeenCalled()
process.stdout.write.mockRestore()
process.exit.mockRestore()
})
test('show help prints to stdout and exits', () => {
jest.spyOn(process.stdout, 'write').mockImplementation(() => {})
jest.spyOn(process, 'exit').mockImplementationOnce(code => code)
const cmd = new Command()
cmd.showHelp()
expect(process.stdout.write).toHaveBeenCalled()
expect(process.exit).toHaveBeenCalled()
process.stdout.write.mockRestore()
process.exit.mockRestore()
})
})

View File

@ -1,5 +1,4 @@
import { consola, mockGetNuxt, mockGetGenerator, NuxtCommand } from '../utils'
import Command from '../../src/command'
import { mockGetNuxt, mockGetGenerator, NuxtCommand } from '../utils'
describe('generate', () => {
let generate
@ -9,7 +8,6 @@ describe('generate', () => {
jest.spyOn(process, 'exit').mockImplementation(code => code)
})
afterAll(() => process.exit.mockRestore())
afterEach(() => jest.resetAllMocks())
test('has run function', () => {
@ -28,34 +26,21 @@ describe('generate', () => {
test('doesnt build with no-build', async () => {
mockGetNuxt()
const getArgv = Command.prototype.getArgv
Command.prototype.getArgv = jest.fn().mockImplementationOnce(() => {
return {
'_': ['.'],
rootDir: '.',
'config-file': 'nuxt.config.js',
build: false
}
})
const generator = mockGetGenerator(Promise.resolve())
await NuxtCommand.from(generate).run()
await NuxtCommand.run(generate, ['generate', '.', '--no-build'])
expect(generator).toHaveBeenCalled()
expect(generator.mock.calls[0][0].build).toBe(false)
Command.prototype.getArgv = getArgv
})
test('build with devtools', async () => {
mockGetNuxt()
const generator = mockGetGenerator(Promise.resolve())
const cmd = NuxtCommand.from(generate)
const args = ['generate', '.', '--devtools']
const argv = cmd.getArgv(args)
argv._ = ['.']
const cmd = NuxtCommand.from(generate, ['generate', '.', '--devtools'])
const options = await cmd.getNuxtConfig(argv)
const options = await cmd.getNuxtConfig()
await cmd.run()
@ -68,22 +53,12 @@ describe('generate', () => {
mockGetNuxt()
mockGetGenerator(Promise.resolve())
const cmd = NuxtCommand.from(generate)
const args = ['generate', '.', '--m']
const cmd = NuxtCommand.from(generate, ['generate', '.', '--m'])
const options = await cmd.getNuxtConfig(cmd.getArgv(args))
const options = await cmd.getNuxtConfig()
await cmd.run()
expect(options.modern).toBe('client')
})
test('catches error', async () => {
mockGetNuxt()
mockGetGenerator(Promise.reject(new Error('Generator Error')))
await NuxtCommand.from(generate).run()
expect(consola.fatal).toHaveBeenCalledWith(new Error('Generator Error'))
})
})

View File

@ -0,0 +1,65 @@
import execa from 'execa'
import run from '../../src/run'
import getCommand from '../../src/commands'
import NuxtCommand from '../../src/command'
jest.mock('execa')
jest.mock('../../src/commands')
jest.mock('../../src/command')
describe('run', () => {
beforeEach(() => {
jest.resetAllMocks()
getCommand.mockImplementation(cmd => cmd === 'dev' ? ({ name: 'dev', run: jest.fn() }) : undefined)
})
afterAll(() => {
jest.clearAllMocks()
})
test('nuxt aliases to nuxt dev', async () => {
await run([])
expect(getCommand).toHaveBeenCalledWith('dev')
expect(NuxtCommand.run).toHaveBeenCalledWith(expect.anything(), [])
})
test('nuxt --foo aliases to nuxt dev --foo', async () => {
await run(['--foo'])
expect(getCommand).toHaveBeenCalledWith('dev')
expect(NuxtCommand.run).toHaveBeenCalledWith(expect.anything(), ['--foo'])
})
test('nuxt <dir> aliases to nuxt dev <dir>', async () => {
await run([__dirname])
expect(getCommand).toHaveBeenCalledWith('dev')
expect(NuxtCommand.run).toHaveBeenCalledWith(expect.anything(), [__dirname])
})
test('external commands', async () => {
await run(['custom', 'command', '--args'])
expect(execa).toHaveBeenCalledWith('nuxt-custom', ['command', '--args'], {
stdout: process.stdout,
stderr: process.stderr,
stdin: process.stdin
})
})
test('throws error if external command not found', async () => {
execa.mockImplementationOnce(() => {
const e = new Error()
e.code = 'ENOENT'
throw e
})
await expect(run(['custom', 'command', '--args']))
.rejects.toBe('Command not found: nuxt-custom')
})
test('throws error if external command failed', async () => {
execa.mockImplementationOnce(() => { throw new Error('boo') })
await expect(run(['custom', 'command', '--args']))
.rejects.toBe('Failed to run command `nuxt-custom`:\nError: boo')
})
})