API Docs for:
Show:

File: src/lib/interfaces/console.coffee

# =====================================
# Requires

# Standard Library
pathUtil = require('path')

# External
safefs = require('safefs')
safeps = require('safeps')
{TaskGroup} = require('taskgroup')
extendr = require('extendr')
promptly = require('promptly')

# Local
docpadUtil = require('../util')


# =====================================
# Classes

###*
# Console Interface
# @constructor
###
class ConsoleInterface


	###*
	# Constructor method. Setup the CLI
	# @private
	# @method constructor
	# @param {Object} opts
	# @param {Function} next
	###
	constructor: (opts,next) ->
		# Prepare
		consoleInterface = @
		@docpad = docpad = opts.docpad
		@commander = commander = require('commander')
		locale = docpad.getLocale()


		# -----------------------------
		# Global config

		commander
			.version(
				docpad.getVersionString()
			)
			.option(
				'-o, --out <outPath>'
				locale.consoleOptionOut
			)
			.option(
				'-c, --config <configPath>'
				locale.consoleOptionConfig
			)
			.option(
				'-e, --env <environment>'
				locale.consoleOptionEnv
			)
			.option(
				'-d, --debug [logLevel]'
				locale.consoleOptionDebug
				parseInt
			)
			.option(
				'-g, --global'
				locale.consoleOptionGlobal
			)
			.option(
				'-f, --force'
				locale.consoleOptionForce
			)
			.option(
				'--no-color'  # commander translates this to the `color` option for us
				locale.consoleOptionNoColor
			)
			.option(
				'-p, --port <port>'
				locale.consoleOptionPort
				parseInt
			)
			.option(
				'--cache'
				locale.consoleOptionCache
			)
			.option(
				'--silent'
				locale.consoleOptionSilent
			)
			.option(
				'--skeleton <skeleton>'
				locale.consoleOptionSkeleton
			)
			.option(
				'--profile'
				locale.consoleOptionProfile
			)
			.option(
				'--offline'
				locale.consoleOptionOffline
			)


		# -----------------------------
		# Commands

		# actions
		commander
			.command('action <actions>')
			.description(locale.consoleDescriptionRun)
			.action(consoleInterface.wrapAction(consoleInterface.action))

		# init
		commander
			.command('init')
			.description(locale.consoleDescriptionInit)
			.action(consoleInterface.wrapAction(consoleInterface.init))

		# run
		commander
			.command('run')
			.description(locale.consoleDescriptionRun)
			.action(consoleInterface.wrapAction(consoleInterface.run, {
				_stayAlive: true
			}))

		# server
		commander
			.command('server')
			.description(locale.consoleDescriptionServer)
			.action(consoleInterface.wrapAction(consoleInterface.server, {
				_stayAlive: true
			}))

		# render
		commander
			.command('render [path]')
			.description(locale.consoleDescriptionRender)
			.action(consoleInterface.wrapAction(consoleInterface.render, {
				# Disable anything unnecessary or that could cause extra output we don't want
				logLevel: 3  # 3:error, 2:critical, 1:alert, 0:emergency
				checkVersion: false
				welcome: false
				prompts: false
			}))

		# generate
		commander
			.command('generate')
			.description(locale.consoleDescriptionGenerate)
			.action(consoleInterface.wrapAction(consoleInterface.generate))

		# watch
		commander
			.command('watch')
			.description(locale.consoleDescriptionWatch)
			.action(consoleInterface.wrapAction(consoleInterface.watch, {
				_stayAlive: true
			}))

		# update
		commander
			.command('update')
			.description(locale.consoleDescriptionUpdate)
			.action(consoleInterface.wrapAction(consoleInterface.update))

		# upgrade
		commander
			.command('upgrade')
			.description(locale.consoleDescriptionUpgrade)
			.action(consoleInterface.wrapAction(consoleInterface.upgrade))

		# install
		commander
			.command('install [pluginName]')
			.description(locale.consoleDescriptionInstall)
			.action(consoleInterface.wrapAction(consoleInterface.install))

		# uninstall
		commander
			.command('uninstall <pluginName>')
			.description(locale.consoleDescriptionUninstall)
			.action(consoleInterface.wrapAction(consoleInterface.uninstall))

		# clean
		commander
			.command('clean')
			.description(locale.consoleDescriptionClean)
			.action(consoleInterface.wrapAction(consoleInterface.clean))

		# info
		commander
			.command('info')
			.description(locale.consoleDescriptionInfo)
			.action(consoleInterface.wrapAction(consoleInterface.info))

		# help
		commander
			.command('help')
			.description(locale.consoleDescriptionHelp)
			.action(consoleInterface.wrapAction(consoleInterface.help))

		# unknown
		commander
			.command('*')
			.description(locale.consoleDescriptionUnknown)
			.action(consoleInterface.wrapAction(consoleInterface.help))


		# -----------------------------
		# DocPad Listeners

		# Welcome
		docpad.on 'welcome', (data,next) ->
			return consoleInterface.welcomeCallback(data,next)


		# -----------------------------
		# Finish Up

		# Plugins
		docpad.emitSerial 'consoleSetup', {consoleInterface,commander}, (err) ->
			return consoleInterface.destroyWithError(err)  if err
			return next(null, consoleInterface)

		# Chain
		@


	# =================================
	# Helpers

	###*
	# Start the CLI
	# @method start
	# @param {Array} argv
	###
	start: (argv) =>
		@commander.parse(argv or process.argv)
		@

	###*
	# Get the commander
	# @method getCommander
	# @return the commander instance
	###
	getCommander: =>
		@commander

	###*
	# Destructor.
	# @method destroy
	# @param {Object} err
	###
	destroy: (err) =>
		# Prepare
		docpad = @docpad
		locale = docpad.getLocale()
		logLevel = docpad.getLogLevel()

		# Error?
		docpadUtil.writeError(err)  if err

		# Log Shutdown
		docpad.log('info', locale.consoleShutdown)

		# Close stdin
		process.stdin.end()

		# Destroy docpad
		docpad.destroy (err) ->
			# Error?
			docpadUtil.writeError(err)  if err

			# Output if we are not in silent mode
			if 6 <= logLevel
				# Note any requests that are still active
				activeRequests = process._getActiveRequests()
				if activeRequests?.length
					console.log """
						Waiting on the requests:
						#{docpadUtil.inspect activeRequests}
						"""

				# Note any handles that are still active
				activeHandles = process._getActiveHandles()
				if activeHandles?.length
					console.log """
						Waiting on the handles:
						#{docpadUtil.inspect activeHandles}
						"""

		# Chain
		@


	###*
	# Wrap Action
	# @method wrapAction
	# @param {Object} action
	# @param {Object} config
	###
	wrapAction: (action,config) =>
		consoleInterface = @
		return (args...) ->
			consoleInterface.performAction(action, args, config)

	###*
	# Perform Action
	# @method performAction
	# @param {Object} action
	# @param {Object} args
	# @param {Object} [config={}]
	###
	performAction: (action,args,config={}) =>
		# Prepare
		consoleInterface = @
		docpad = @docpad

		# Special Opts
		stayAlive = false
		if config._stayAlive
			stayAlive = config._stayAlive
			delete config._stayAlive

		# Create
		opts = {}
		opts.commander = args[-1...][0]
		opts.args = args[...-1]
		opts.instanceConfig = extendr.safeDeepExtendPlainObjects({}, @extractConfig(opts.commander), config)

		# Complete Action
		completeAction = (err) ->
			# Prepare
			locale = docpad.getLocale()

			# Handle the error
			if err
				docpad.log('error', locale.consoleSuccess)
				return docpad.fatal(err)

			# Success
			docpad.log('info', locale.consoleSuccess)

			# Shutdown
			return consoleInterface.destroy()  if stayAlive is false

		# Load
		docpad.action 'load ready', opts.instanceConfig, (err) ->
			# Check
			return completeAction(err)  if err

			# Action
			return action(completeAction, opts)  # this order for interface actions for b/c

		# Chain
		@

	###*
	# Extract Configuration
	# @method extractConfig
	# @param {Object} [customConfig={}]
	# @return {Object} the DocPad config
	###
	extractConfig: (customConfig={}) =>
		# Prepare
		config = {}
		commanderConfig = @commander
		sourceConfig = @docpad.initialConfig

		# debug -> logLevel
		if commanderConfig.debug
			commanderConfig.debug = 7  if commanderConfig.debug is true
			commanderConfig.logLevel = commanderConfig.debug

		# silent -> prompt
		if commanderConfig.silent?
			commanderConfig.prompts = !(commanderConfig.silent)

		# cache -> databaseCache
		if commanderConfig.silent?
			commanderConfig.databaseCache = commanderConfig.cache

		# config -> configPaths
		if commanderConfig.config
			configPath = pathUtil.resolve(process.cwd(),commanderConfig.config)
			commanderConfig.configPaths = [configPath]

		# out -> outPath
		if commanderConfig.out
			outPath = pathUtil.resolve(process.cwd(),commanderConfig.out)
			commanderConfig.outPath = outPath

		# Apply global configuration
		for own key, value of commanderConfig
			if typeof sourceConfig[key] isnt 'undefined'
				config[key] = value

		# Apply custom configuration
		for own key, value of customConfig
			if typeof sourceConfig[key] isnt 'undefined'
				config[key] = value

		# Return config object
		config

	###*
	# Select a skeleton
	# @method selectSkeletonCallback
	# @param {Object} skeletonsCollection
	# @param {Function} next
	###
	selectSkeletonCallback: (skeletonsCollection,next) =>
		# Prepare
		consoleInterface = @
		commander = @commander
		docpad = @docpad
		locale = docpad.getLocale()
		skeletonNames = []

		# Already selected?
		if @commander.skeleton
			skeletonModel = skeletonsCollection.get(@commander.skeleton)
			if skeletonModel
				next(null, skeletonModel)
			else
				err = new Error("Couldn't fetch the skeleton with id #{@commander.skeleton}")
				next(err)
			return @

		# Show
		docpad.log 'info', locale.skeletonSelectionIntroduction+'\n'
		skeletonsCollection.forEach (skeletonModel) ->
			skeletonName = skeletonModel.get('name')
			skeletonDescription = skeletonModel.get('description').replace(/\n/g,'\n\t')
			skeletonNames.push(skeletonName)
			console.log "  #{skeletonModel.get('position')+1}.\t#{skeletonName}\n  \t#{skeletonDescription}\n"

		# Select
		consoleInterface.choose locale.skeletonSelectionPrompt, skeletonNames, null, (err, choice) ->
			return next(err)  if err
			index = skeletonNames.indexOf(choice)
			return next(null, skeletonsCollection.at(index))

		# Chain
		@

	###*
	# Welcome Callback
	# @method welcomeCallback
	# @param {Object} opts
	# @param {Function} next
	###
	welcomeCallback: (opts,next) =>
		# Prepare
		consoleInterface = @
		commander = @commander
		docpad = @docpad
		locale = docpad.getLocale()
		userConfig = docpad.userConfig
		welcomeTasks = new TaskGroup('welcome tasks').done(next)

		# TOS
		welcomeTasks.addTask 'tos', (complete) ->
			return complete()  if docpad.config.prompts is false or userConfig.tos is true

			# Ask the user if they agree to the TOS
			consoleInterface.confirm locale.tosPrompt, {default:true}, (err, ok) ->
				# Check
				return complete(err)  if err

				# Track
				docpad.track 'tos', {ok}, (err) ->
					# Check
					if ok
						userConfig.tos = true
						console.log locale.tosAgree
						docpad.updateUserConfig(complete)
						return
					else
						console.log locale.tosDisagree
						process.exit()
						return

		# Newsletter
		welcomeTasks.addTask (complete) ->
			return complete()  if docpad.config.prompts is false or userConfig.subscribed? or (userConfig.subscribeTryAgain? and (new Date()) > (new Date(userConfig.subscribeTryAgain)))

			# Ask the user if they want to subscribe to the newsletter
			consoleInterface.confirm locale.subscribePrompt, {default:true}, (err, ok) ->
				# Check
				return complete(err)  if err

				# Track
				docpad.track 'subscribe', {ok}, (err) ->
					# If they don't want to, that's okay
					unless ok
						# Inform the user that we received their preference
						console.log locale.subscribeIgnore

						# Save their preference in the user configuration
						userConfig.subscribed = false
						docpad.updateUserConfig (err) ->
							return complete(err)  if err
							setTimeout(complete, 2000)
						return

					# Scan configuration to speed up the process
					commands = [
						['config','--get','user.name']
						['config','--get','user.email']
						['config','--get','github.user']
					]
					safeps.spawnCommands 'git', commands, (err,results) ->
						# Ignore error as it just means a config value wasn't defined

						# Fetch
						# The or to '' is there because otherwise we will get "undefined" as a string if the value doesn't exist
						userConfig.name = String(results?[0]?[1] or '').toString().trim() or null
						userConfig.email = String(results?[1]?[1] or '').toString().trim() or null
						userConfig.username = String(results?[2]?[1] or '').toString().trim() or null

						# Let the user know we scanned their configuration if we got anything useful
						if userConfig.name or userConfig.email or userConfig.username
							console.log locale.subscribeConfigNotify

						# Tasks
						subscribeTasks = new TaskGroup('subscribe tasks').done (err) ->
							# Error?
							if err
								# Inform the user
								console.log locale.subscribeError

								# Save a time when we should try to subscribe again
								userConfig.subscribeTryAgain = new Date().getTime() + 1000*60*60*24  # tomorrow

							# Success
							else
								# Inform the user
								console.log locale.subscribeSuccess

								# Save the updated subscription status, and continue to what is next
								userConfig.subscribed = true
								userConfig.subscribeTryAgain = null

							# Save the new user configuration changes, and forward to the next task
							docpad.updateUserConfig(userConfig, complete)

						# Name Fallback
						subscribeTasks.addTask 'name fallback', (complete) ->
							consoleInterface.prompt locale.subscribeNamePrompt, {default: userConfig.name}, (err, result) ->
								return complete(err)  if err
								userConfig.name = result
								return complete()

						# Email Fallback
						subscribeTasks.addTask 'email fallback', (complete) ->
							consoleInterface.prompt locale.subscribeEmailPrompt, {default: userConfig.email}, (err, result) ->
								return complete(err)  if err
								userConfig.email = result
								return complete()

						# Username Fallback
						subscribeTasks.addTask 'username fallback', (complete) ->
							consoleInterface.prompt locale.subscribeUsernamePrompt, {default: userConfig.username}, (err, result) ->
								return complete(err)  if err
								userConfig.username = result
								return complete()

						# Save the details
						subscribeTasks.addTask 'save defaults', (complete) ->
							return docpad.updateUserConfig(complete)

						# Perform the subscribe
						subscribeTasks.addTask 'subscribe', (complete) ->
							# Inform the user
							console.log locale.subscribeProgress

							# Forward
							docpad.subscribe (err,res) ->
								# Check
								if err
									docpad.log 'debug', locale.subscribeRequestError, err.message
									return complete(err)

								# Success
								docpad.log 'debug', locale.subscribeRequestData, res.text
								return complete()

						# Run
						subscribeTasks.run()

		# Run
		welcomeTasks.run()

		# Chain
		@

	###*
	# Prompt for input
	# @method prompt
	# @param {String} message
	# @param {Object} [opts={}]
	# @param {Function} next
	###
	prompt: (message, opts={}, next) ->
		# Default
		message += " [#{opts.default}]"  if opts.default

		# Options
		opts = extendr.extend({
			trim: true
			retry: true
			silent: false
		}, opts)

		# Log
		promptly.prompt(message, opts, next)

		# Chain
		@

	###*
	# Confirm an option
	# @method confirm
	# @param {String} message
	# @param {Object} [opts={}]
	# @param {Function} next
	###
	confirm: (message, opts={}, next) ->
		# Default
		if opts.default is true
			message += " [Y/n]"
		else if opts.default is false
			message += " [y/N]"

		# Options
		opts = extendr.extend({
			trim: true
			retry: true
			silent: false
		}, opts)

		# Log
		promptly.confirm(message, opts, next)

		# Chain
		@

	###*
	# Choose something
	# @method choose
	# @param {String} message
	# @param {Object} choices
	# @param {Object} [opts={}]
	# @param {Function} next
	###
	choose: (message, choices, opts={}, next) ->
		# Default
		message += " [1-#{choices.length}]"
		indexes = []
		for choice,i in choices
			index = i+1
			indexes.push(index)
			message += "\n  #{index}.\t#{choice}"

		# Options
		opts = extendr.extend({
			trim: true
			retry: true
			silent: false
		}, opts)

		# Prompt
		prompt = '> '
		prompt += " [#{opts.default}]"  if opts.default

		# Log
		console.log(message)
		promptly.choose prompt, indexes, opts, (err, index) ->
			return next(err)  if err
			choice = choices[index-1]
			return next(null, choice)

		# Chain
		@


	# =================================
	# Actions

	###*
	# Do action
	# @method action
	# @param {Function} next
	# @param {Object} opts
	###
	action: (next,opts) =>
		actions = opts.args[0]
		@docpad.log 'info', 'Performing the actions:', actions
		@docpad.action(actions, next)
		@

	###*
	# Action initialise
	# @method init
	# @param {Function} next
	###
	init: (next) =>
		@docpad.action('init', next)
		@

	###*
	# Generate action
	# @method generate
	# @param {Function} next
	###
	generate: (next) =>
		@docpad.action('generate', next)
		@

	###*
	# Help method
	# @method help
	# @param {Function} next
	###
	help: (next) =>
		help = @commander.helpInformation()
		console.log(help)
		next()
		@

	###*
	# Info method
	# @method info
	# @param {Function} next
	###
	info: (next) =>
		docpad = @docpad
		info = docpad.inspector(docpad.config)
		console.log(info)
		next()
		@

	###*
	# Update method
	# @method update
	# @param {Function} next
	# @param {Object} opts
	###
	update: (next,opts) =>
		# Act
		@docpad.action('clean update', next)

		# Chain
		@
	###*
	# Upgrade method
	# @method upgrade
	# @param {Function} next
	# @param {Object} opts
	###
	upgrade: (next,opts) =>
		# Act
		@docpad.action('upgrade', next)

		# Chain
		@

	###*
	# Install method
	# @method install
	# @param {Function} next
	# @param {Object} opts
	###
	install: (next,opts) =>
		# Extract
		plugin = opts.args[0] or null

		# Act
		@docpad.action('install', {plugin}, next)

		# Chain
		@

	###*
	# Uninstall method
	# @method uninstall
	# @param {Function} next
	# @param {Object} opts
	###
	uninstall: (next,opts) =>
		# Extract
		plugin = opts.args[0] or null

		# Act
		@docpad.action('uninstall', {plugin}, next)

		# Chain
		@

	###*
	# Render method
	# @method render
	# @param {Function} next
	# @param {Object} opts
	###
	render: (next,opts) =>
		# Prepare
		docpad = @docpad
		commander = @commander
		renderOpts = {}

		# Extract
		filename = opts.args[0] or null
		basename = pathUtil.basename(filename)
		renderOpts.filename = filename
		renderOpts.renderSingleExtensions = 'auto'

		# Prepare text
		data = ''

		# Render
		useStdin = true
		renderDocument = (complete) ->
			# Perform the render
			docpad.action 'render', renderOpts, (err,result) ->
				return complete(err)  if err

				# Path
				if commander.out?
					safefs.writeFile(commander.out, result, complete)

				# Stdout
				else
					process.stdout.write(result)
					return complete()

		# Timeout if we don't have stdin
		timeout = docpadUtil.wait 1000, ->
			# Clear timeout
			timeout = null

			# Skip if we are using stdin
			return next()  if data.replace(/\s+/,'')

			# Close stdin as we are not using it
			useStdin = false
			stdin.pause()

			# Render the document
			renderDocument(next)

		# Read stdin
		stdin = process.stdin
		stdin.resume()
		stdin.setEncoding('utf8')
		stdin.on 'data', (_data) ->
			data += _data.toString()
		process.stdin.on 'end', ->
			return  unless useStdin
			if timeout
				clearTimeout(timeout)
				timeout = null
			renderOpts.data = data
			renderDocument(next)

		@

	###*
	# Run method
	# @method run
	# @param {Function} next
	###
	run: (next) =>
		@docpad.action('run', {
			selectSkeletonCallback: @selectSkeletonCallback
			next: next
		})
		@

	###*
	# Server method
	# @method server
	# @param {Function} next
	###
	server: (next) =>
		@docpad.action('server generate', next)
		@

	###*
	# Clean method
	# @method clean
	# @param {Function} next
	###
	clean: (next) =>
		@docpad.action('clean', next)
		@

	###*
	# Watch method
	# @method watch
	# @param {Function} next
	###
	watch: (next) =>
		@docpad.action('generate watch', next)
		@


# =====================================
# Export
module.exports = ConsoleInterface