diff --git a/packages/cli/bin/nuxt-cli.js b/packages/cli/bin/nuxt-cli.js index 63fe65902b..cc6f24825f 100755 --- a/packages/cli/bin/nuxt-cli.js +++ b/packages/cli/bin/nuxt-cli.js @@ -3,5 +3,5 @@ require('../dist/cli.js').run() .catch((error) => { require('consola').fatal(error) - process.exit(2) + require('exit')(2) }) diff --git a/packages/cli/package.json b/packages/cli/package.json index 39b2ce46fa..53f004d43b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -18,6 +18,7 @@ "consola": "^2.4.0", "esm": "^3.2.1", "execa": "^1.0.0", + "exit": "^0.1.2", "minimist": "^1.2.0", "opener": "1.5.1", "pretty-bytes": "^5.1.0", diff --git a/packages/cli/src/command.js b/packages/cli/src/command.js index e93d7a3957..ec28dea766 100644 --- a/packages/cli/src/command.js +++ b/packages/cli/src/command.js @@ -1,8 +1,9 @@ import minimist from 'minimist' import { name, version } from '../package.json' -import { loadNuxtConfig } from './utils' -import { indent, foldLines, startSpaces, optionSpaces, colorize } from './utils/formatting' +import { loadNuxtConfig, forceExit } from './utils' +import { indent, foldLines, colorize } from './utils/formatting' +import { startSpaces, optionSpaces, forceExitTimeout } from './utils/constants' import * as imports from './imports' export default class NuxtCommand { @@ -12,6 +13,9 @@ export default class NuxtCommand { } this.cmd = cmd + // If the cmd is a server then dont forcibly exit when the cmd has finished + this.isServer = cmd.isServer !== undefined ? cmd.isServer : Boolean(this.cmd.options.hostname) + this._argv = Array.from(argv) this._parsedArgv = null // Lazy evaluate } @@ -42,7 +46,14 @@ export default class NuxtCommand { return Promise.resolve() } - return Promise.resolve(this.cmd.run(this)) + const runResolve = Promise.resolve(this.cmd.run(this)) + + // TODO: For v3 set timeout to 0 when force-exit === true + if (!this.isServer || this.argv['force-exit']) { + runResolve.then(() => forceExit(this.cmd.name, forceExitTimeout)) + } + + return runResolve } showVersion() { diff --git a/packages/cli/src/list.js b/packages/cli/src/list.js index 7bd9aa2524..7255f192bc 100644 --- a/packages/cli/src/list.js +++ b/packages/cli/src/list.js @@ -1,5 +1,6 @@ import chalk from 'chalk' -import { indent, foldLines, startSpaces, optionSpaces, colorize } from './utils/formatting' +import { indent, foldLines, colorize } from './utils/formatting' +import { startSpaces, optionSpaces } from './utils/constants' import getCommand from './commands' export default async function listCommands() { diff --git a/packages/cli/src/options/common.js b/packages/cli/src/options/common.js index 3b4014f5de..f5f6fa25d3 100644 --- a/packages/cli/src/options/common.js +++ b/packages/cli/src/options/common.js @@ -28,6 +28,12 @@ export default { } } }, + // TODO: Change this to default: false in Nuxt v3 (see related todo's) + 'force-exit': { + type: 'boolean', + default: true, + description: 'Do not force Nuxt.js to exit after the command has finished (this option has no effect on commands which start a server)' + }, version: { alias: 'v', type: 'boolean', diff --git a/packages/cli/src/setup.js b/packages/cli/src/setup.js index 15a2f67537..fdcd8145c8 100644 --- a/packages/cli/src/setup.js +++ b/packages/cli/src/setup.js @@ -1,6 +1,6 @@ import consola from 'consola' -import chalk from 'chalk' -import boxen from 'boxen' +import exit from 'exit' +import { fatalBox } from './utils/formatting' let _setup = false @@ -26,17 +26,9 @@ export default function setup({ dev }) { consola.addReporter({ log(logObj) { if (logObj.type === 'fatal') { - process.stderr.write(boxen([ - chalk.red('✖ Nuxt Fatal Error'), - '', - chalk.white(String(logObj.args[0])) - ].join('\n'), { - borderColor: 'red', - borderStyle: 'round', - padding: 1, - margin: 1 - }) + '\n') - process.exit(1) + const errorMessage = String(logObj.args[0]) + process.stderr.write(fatalBox(errorMessage)) + exit(1) } } }) diff --git a/packages/cli/src/utils/constants.js b/packages/cli/src/utils/constants.js new file mode 100644 index 0000000000..4bd9089e8c --- /dev/null +++ b/packages/cli/src/utils/constants.js @@ -0,0 +1,8 @@ +export const forceExitTimeout = 5 + +export const startSpaces = 2 +export const optionSpaces = 2 + +// 80% of terminal column width +// this is a fn because console width can have changed since startup +export const maxCharsPerLine = () => (process.stdout.columns || 100) * 80 / 100 diff --git a/packages/cli/src/utils/formatting.js b/packages/cli/src/utils/formatting.js index 78b1c82cdc..b22f5bcba1 100644 --- a/packages/cli/src/utils/formatting.js +++ b/packages/cli/src/utils/formatting.js @@ -1,11 +1,7 @@ import wrapAnsi from 'wrap-ansi' import chalk from 'chalk' - -export const startSpaces = 2 -export const optionSpaces = 2 - -// 80% of terminal column width -export const maxCharsPerLine = (process.stdout.columns || 100) * 80 / 100 +import boxen from 'boxen' +import { maxCharsPerLine } from './constants' export function indent(count, chr = ' ') { return chr.repeat(count) @@ -25,8 +21,8 @@ export function indentLines(string, spaces, firstLineSpaces) { return s } -export function foldLines(string, spaces, firstLineSpaces, maxCharsPerLine) { - return indentLines(wrapAnsi(string, maxCharsPerLine, { trim: false }), spaces, firstLineSpaces) +export function foldLines(string, spaces, firstLineSpaces, charsPerLine = maxCharsPerLine()) { + return indentLines(wrapAnsi(string, charsPerLine, { trim: false }), spaces, firstLineSpaces) } export function colorize(text) { @@ -36,3 +32,38 @@ export function colorize(text) { .replace(/ (-[-\w,]+)/g, m => chalk.bold(m)) .replace(/`(.+)`/g, (_, m) => chalk.bold.cyan(m)) } + +export function box(message, title, options) { + return boxen([ + title || chalk.white('Nuxt Message'), + '', + chalk.white(foldLines(message, 0, 0, maxCharsPerLine())) + ].join('\n'), Object.assign({ + borderColor: 'white', + borderStyle: 'round', + padding: 1, + margin: 1 + }, options)) + '\n' +} + +export function successBox(message, title) { + return box(message, title || chalk.green('✔ Nuxt Success'), { + borderColor: 'green' + }) +} + +export function warningBox(message, title) { + return box(message, title || chalk.yellow('⚠ Nuxt Warning'), { + borderColor: 'yellow' + }) +} + +export function errorBox(message, title) { + return box(message, title || chalk.red('✖ Nuxt Error'), { + borderColor: 'red' + }) +} + +export function fatalBox(message, title) { + return errorBox(message, title || chalk.red('✖ Nuxt Fatal Error')) +} diff --git a/packages/cli/src/utils/index.js b/packages/cli/src/utils/index.js index 55dd734e92..a0ea81357e 100644 --- a/packages/cli/src/utils/index.js +++ b/packages/cli/src/utils/index.js @@ -2,12 +2,13 @@ import path from 'path' import { existsSync } from 'fs' import consola from 'consola' import esm from 'esm' +import exit from 'exit' import defaultsDeep from 'lodash/defaultsDeep' import { defaultNuxtConfigFile, getDefaultNuxtConfig } from '@nuxt/config' -import boxen from 'boxen' import chalk from 'chalk' import prettyBytes from 'pretty-bytes' import env from 'std-env' +import { successBox, warningBox } from './formatting' export const requireModule = process.env.NUXT_TS ? require : esm(module, { cache: false, @@ -87,37 +88,30 @@ export function showBanner(nuxt) { return } - const lines = [] + const titleLines = [] + const messageLines = [] // Name and version - lines.push(`${chalk.green.bold('Nuxt.js')} ${nuxt.constructor.version}`) + titleLines.push(`${chalk.green.bold('Nuxt.js')} ${nuxt.constructor.version}`) // Running mode - lines.push(`Running in ${nuxt.options.dev ? chalk.bold.blue('development') : chalk.bold.green('production')} mode (${chalk.bold(nuxt.options.mode)})`) + titleLines.push(`Running in ${nuxt.options.dev ? chalk.bold.blue('development') : chalk.bold.green('production')} mode (${chalk.bold(nuxt.options.mode)})`) // https://nodejs.org/api/process.html#process_process_memoryusage const { heapUsed, rss } = process.memoryUsage() - lines.push(`Memory usage: ${chalk.bold(prettyBytes(heapUsed))} (RSS: ${prettyBytes(rss)})`) + titleLines.push(`Memory usage: ${chalk.bold(prettyBytes(heapUsed))} (RSS: ${prettyBytes(rss)})`) // Listeners - lines.push('') for (const listener of nuxt.server.listeners) { - lines.push(chalk.bold('Listening on: ') + chalk.underline.blue(listener.url)) + messageLines.push(chalk.bold('Listening on: ') + chalk.underline.blue(listener.url)) } // Add custom badge messages if (nuxt.options.cli.badgeMessages.length) { - lines.push('', ...nuxt.options.cli.badgeMessages) + messageLines.push('', ...nuxt.options.cli.badgeMessages) } - const box = boxen(lines.join('\n'), { - borderColor: 'green', - borderStyle: 'round', - padding: 1, - margin: 1 - }) - - process.stdout.write(box + '\n') + process.stdout.write(successBox(messageLines.join('\n'), titleLines.join('\n'))) } export function formatPath(filePath) { @@ -144,3 +138,23 @@ export function normalizeArg(arg, defaultValue) { } return arg } + +export function forceExit(cmdName, timeout) { + if (timeout) { + const exitTimeout = setTimeout(() => { + const msg = `The command 'nuxt ${cmdName}' finished but did not exit after ${timeout}s +This is most likely not caused by a bug in Nuxt.js\ +Make sure to cleanup all timers and listeners you or your plugins/modules start. +Nuxt.js will now force exit + +${chalk.bold('DeprecationWarning: Starting with Nuxt version 3 this will be a fatal error')}` + + // TODO: Change this to a fatal error in v3 + process.stderr.write(warningBox(msg)) + exit(0) + }, timeout * 1000) + exitTimeout.unref() + } else { + exit(0) + } +} diff --git a/packages/cli/test/unit/__snapshots__/command.test.js.snap b/packages/cli/test/unit/__snapshots__/command.test.js.snap index f6d1dd00cf..90e827e411 100644 --- a/packages/cli/test/unit/__snapshots__/command.test.js.snap +++ b/packages/cli/test/unit/__snapshots__/command.test.js.snap @@ -1,22 +1,38 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`cli/command builds help text 1`] = ` -" Usage: nuxt this is how you do it [options] +" Usage: nuxt this is how you do it + [options] - a very long description that should not wrap to the next line because is not longer than the terminal width + a very long description that should wrap + to the next line because is not longer + than the terminal width 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, -v Display the Nuxt version + --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 + --no-force-exit Do not force Nuxt.js + to exit after the command has finished + (this option has no effect on commands + which start a server) + --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 + --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 - --foo very long option that is not longer than the terminal width and should not wrap to the next line + --foo very long option that + is longer than the terminal width and + should wrap to the next line " `; diff --git a/packages/cli/test/unit/build.test.js b/packages/cli/test/unit/build.test.js index c0ee730752..5c546524ea 100644 --- a/packages/cli/test/unit/build.test.js +++ b/packages/cli/test/unit/build.test.js @@ -1,3 +1,4 @@ +import * as utils from '../../src/utils/' import { mockGetNuxt, mockGetBuilder, mockGetGenerator, NuxtCommand } from '../utils' describe('build', () => { @@ -6,6 +7,7 @@ describe('build', () => { beforeAll(async () => { build = await import('../../src/commands/build').then(m => m.default) jest.spyOn(process, 'exit').mockImplementation(code => code) + jest.spyOn(utils, 'forceExit').mockImplementation(() => {}) }) afterEach(() => jest.resetAllMocks()) diff --git a/packages/cli/test/unit/cli.test.js b/packages/cli/test/unit/cli.test.js index 3690aeec91..5a7dfa7372 100644 --- a/packages/cli/test/unit/cli.test.js +++ b/packages/cli/test/unit/cli.test.js @@ -1,9 +1,15 @@ import { run } from '../../src' import getCommand from '../../src/commands' +import * as utils from '../../src/utils/' jest.mock('../../src/commands') describe('cli', () => { + beforeAll(() => { + // TODO: Below spyOn can be removed in v3 when force-exit is default false + jest.spyOn(utils, 'forceExit').mockImplementation(() => {}) + }) + afterEach(() => jest.resetAllMocks()) test('calls expected method', async () => { diff --git a/packages/cli/test/unit/command.test.js b/packages/cli/test/unit/command.test.js index cba54c97f3..5aa7e821e5 100644 --- a/packages/cli/test/unit/command.test.js +++ b/packages/cli/test/unit/command.test.js @@ -1,5 +1,7 @@ import Command from '../../src/command' import { common, server } from '../../src/options' +import * as utils from '../../src/utils/' +import * as constants from '../../src/utils/constants' import { consola } from '../utils' jest.mock('@nuxt/core') @@ -19,7 +21,7 @@ describe('cli/command', () => { const minimistOptions = cmd._getMinimistOptions() expect(minimistOptions.string.length).toBe(5) - expect(minimistOptions.boolean.length).toBe(4) + expect(minimistOptions.boolean.length).toBe(5) expect(minimistOptions.alias.c).toBe('config-file') expect(minimistOptions.default.c).toBe(common['config-file'].default) }) @@ -38,6 +40,8 @@ describe('cli/command', () => { }) test('prints version automatically', async () => { + jest.spyOn(utils, 'forceExit').mockImplementation(() => {}) + const cmd = new Command({}, ['--version']) cmd.showVersion = jest.fn() await cmd.run() @@ -46,6 +50,8 @@ describe('cli/command', () => { }) test('prints help automatically', async () => { + jest.spyOn(utils, 'forceExit').mockImplementation(() => {}) + const cmd = new Command({ options: allOptions }, ['-h']) cmd.showHelp = jest.fn() await cmd.run() @@ -88,16 +94,18 @@ describe('cli/command', () => { }) test('builds help text', () => { + jest.spyOn(constants, 'maxCharsPerLine').mockReturnValue(40) + const cmd = new Command({ - description: 'a very long description that should not wrap to the next line because is not longer ' + + description: 'a very long description that should wrap to the next line because is not longer ' + 'than the terminal width', usage: 'this is how you do it', options: { ...allOptions, foo: { type: 'boolean', - description: 'very long option that is not longer than the terminal width and ' + - 'should not wrap to the next line' + description: 'very long option that is longer than the terminal width and ' + + 'should wrap to the next line' } } }) diff --git a/packages/cli/test/unit/dev.test.js b/packages/cli/test/unit/dev.test.js index da52ebc2bd..db5ad0d37e 100644 --- a/packages/cli/test/unit/dev.test.js +++ b/packages/cli/test/unit/dev.test.js @@ -1,3 +1,4 @@ +import * as utils from '../../src/utils/' import { consola, mockNuxt, mockBuilder, mockGetNuxtConfig, NuxtCommand } from '../utils' describe('dev', () => { @@ -5,6 +6,8 @@ describe('dev', () => { beforeAll(async () => { dev = await import('../../src/commands/dev').then(m => m.default) + // TODO: Below spyOn can be removed in v3 when force-exit is default false + jest.spyOn(utils, 'forceExit').mockImplementation(() => {}) }) afterEach(() => jest.clearAllMocks()) diff --git a/packages/cli/test/unit/generate.test.js b/packages/cli/test/unit/generate.test.js index b41373871e..1e2f5a40e2 100644 --- a/packages/cli/test/unit/generate.test.js +++ b/packages/cli/test/unit/generate.test.js @@ -1,3 +1,4 @@ +import * as utils from '../../src/utils/' import { mockGetNuxt, mockGetGenerator, NuxtCommand } from '../utils' describe('generate', () => { @@ -6,6 +7,7 @@ describe('generate', () => { beforeAll(async () => { generate = await import('../../src/commands/generate').then(m => m.default) jest.spyOn(process, 'exit').mockImplementation(code => code) + jest.spyOn(utils, 'forceExit').mockImplementation(() => {}) }) afterEach(() => jest.resetAllMocks()) diff --git a/packages/cli/test/unit/start.test.js b/packages/cli/test/unit/start.test.js index 507125b77f..e1b3bf13bf 100644 --- a/packages/cli/test/unit/start.test.js +++ b/packages/cli/test/unit/start.test.js @@ -1,4 +1,5 @@ import fs from 'fs-extra' +import * as utils from '../../src/utils/' import { consola, mockGetNuxtStart, mockGetNuxtConfig, NuxtCommand } from '../utils' describe('start', () => { @@ -6,6 +7,8 @@ describe('start', () => { beforeAll(async () => { start = await import('../../src/commands/start').then(m => m.default) + // TODO: Below spyOn can be removed in v3 when force-exit is default false + jest.spyOn(utils, 'forceExit').mockImplementation(() => {}) }) afterEach(() => {