now-flow
Version:
Add deployment workflows to Zeit now
967 lines (833 loc) • 23.4 kB
JavaScript
// Native
const { resolve, basename } = require('path')
// Packages
const Progress = require('progress')
const fs = require('fs-extra')
const bytes = require('bytes')
const chalk = require('chalk')
const mri = require('mri')
const ms = require('ms')
const dotenv = require('dotenv')
const { eraseLines } = require('ansi-escapes')
const { write: copy } = require('clipboardy')
const inquirer = require('inquirer')
const retry = require('async-retry')
const jsonlines = require('jsonlines')
// Utilities
const Logger = require('../util/build-logger')
const Now = require('../util')
const toHumanPath = require('../../../util/humanize-path')
const { handleError, error } = require('../util/error')
const { fromGit, isRepoPath, gitPathParts } = require('../util/git')
const readMetaData = require('../util/read-metadata')
const checkPath = require('../util/check-path')
const logo = require('../../../util/output/logo')
const cmd = require('../../../util/output/cmd')
const info = require('../../../util/output/info')
const success = require('../../../util/output/success')
const wait = require('../../../util/output/wait')
const NowPlans = require('../util/plans')
const promptBool = require('../../../util/input/prompt-bool')
const promptOptions = require('../util/prompt-options')
const note = require('../../../util/output/note')
const exit = require('../../../util/exit')
const mriOpts = {
string: ['name', 'alias', 'session-affinity'],
boolean: [
'help',
'version',
'debug',
'force',
'links',
'no-clipboard',
'forward-npm',
'docker',
'npm',
'static',
'public'
],
alias: {
env: 'e',
dotenv: 'E',
help: 'h',
debug: 'd',
version: 'v',
force: 'f',
links: 'l',
public: 'p',
'no-clipboard': 'C',
'forward-npm': 'N',
'session-affinity': 'S',
name: 'n',
alias: 'a'
}
}
/*eslint-disable */
const getProcess = () => process
/*eslint-enable */
const help = () => {
console.log(`
${chalk.bold(`${logo} now`)} [options] <command | path>
${chalk.dim('Commands:')}
${chalk.dim('Cloud')}
deploy [path] Performs a deployment ${chalk.bold(
'(default)'
)}
ls | list [app] List deployments
rm | remove [id] Remove a deployment
ln | alias [id] [url] Configures aliases for deployments
domains [name] Manages your domain names
certs [cmd] Manages your SSL certificates
secrets [name] Manages your secret environment variables
dns [name] Manages your DNS records
logs [url] Displays the logs for a deployment
scale [args] Scales the instance count of a deployment
help [cmd] Displays complete help for [cmd]
${chalk.dim('Administrative')}
billing | cc [cmd] Manages your credit cards and billing methods
upgrade | downgrade [plan] Upgrades or downgrades your plan
teams [team] Manages your teams
switch Switches between teams and your account
login Login into your account or creates a new one
logout Logout from your account
${chalk.dim('Options:')}
-h, --help Output usage information
-v, --version Output the version number
-n, --name Set the name of the deployment
-A ${chalk.bold.underline('FILE')}, --local-config=${chalk.bold.underline(
'FILE'
)} Path to the local ${'`now.json`'} file
-Q ${chalk.bold.underline('DIR')}, --global-config=${chalk.bold.underline(
'DIR'
)} Path to the global ${'`.now`'} directory
-d, --debug Debug mode [off]
-f, --force Force a new deployment even if nothing has changed
-t ${chalk.underline('TOKEN')}, --token=${chalk.underline(
'TOKEN'
)} Login token
-l, --links Copy symlinks without resolving their target
-p, --public Deployment is public (${chalk.dim(
'`/_src`'
)} is exposed) [on for oss, off for premium]
-e, --env Include an env var (e.g.: ${chalk.dim(
'`-e KEY=value`'
)}). Can appear many times.
-E ${chalk.underline('FILE')}, --dotenv=${chalk.underline(
'FILE'
)} Include env vars from .env file. Defaults to '.env'
-C, --no-clipboard Do not attempt to copy URL to clipboard
-N, --forward-npm Forward login information to install private npm modules
--session-affinity Session affinity, \`ip\` (default) or \`random\` to control session affinity
-T, --team Set a custom team scope
${chalk.dim(
'Enforceable Types (by default, it\'s detected automatically):'
)}
--npm Node.js application
--docker Docker container
--static Static file hosting
${chalk.dim('Examples:')}
${chalk.gray('–')} Deploy the current directory
${chalk.cyan('$ now')}
${chalk.gray('–')} Deploy a custom path
${chalk.cyan('$ now /usr/src/project')}
${chalk.gray('–')} Deploy a GitHub repository
${chalk.cyan('$ now user/repo#ref')}
${chalk.gray('–')} Deploy with environment variables
${chalk.cyan(
'$ now -e NODE_ENV=production -e SECRET=@mysql-secret'
)}
${chalk.gray('–')} Show the usage information for the sub command ${chalk.dim(
'`list`'
)}
${chalk.cyan('$ now help list')}
`)
}
let argv
let path
// Options
let forceNew
let deploymentName
let sessionAffinity
let debug
let clipboard
let forwardNpm
let followSymlinks
let wantsPublic
let apiUrl
let isTTY
let quiet
let alwaysForwardNpm
// If the current deployment is a repo
const gitRepo = {}
const stopDeployment = async msg => {
handleError(msg)
await exit(1)
}
// Converts `env` Arrays, Strings and Objects into env Objects.
// `null` empty value means to prompt user for value upon deployment.
// `undefined` empty value means to inherit value from user's env.
const parseEnv = (env, empty) => {
if (!env) {
return {}
}
if (typeof env === 'string') {
// a single `--env` arg comes in as a String
env = [ env ]
}
if (Array.isArray(env)) {
return env.reduce((o, e) => {
let key
let value
const equalsSign = e.indexOf('=')
if (equalsSign === -1) {
key = e
value = empty
} else {
key = e.substr(0, equalsSign)
value = e.substr(equalsSign + 1)
}
o[key] = value
return o
}, {})
}
// assume it's already an Object
return env
}
const promptForEnvFields = async list => {
if (list.length === 0) {
return {}
}
const questions = []
for (const field of list) {
questions.push({
name: field,
message: field
})
}
// eslint-disable-next-line import/no-unassigned-import
require('../../../util/input/patch-inquirer')
console.log(
info('Please enter values for the following environment variables:')
)
const answers = await inquirer.prompt(questions)
for (const answer of Object.keys(answers)) {
const content = answers[answer]
if (content === '') {
await stopDeployment(`Enter a value for ${answer}`)
}
}
return answers
}
async function main(ctx) {
argv = mri(ctx.argv.slice(2), mriOpts)
// very ugly hack – this (now-cli's code) expects that `argv._[0]` is the path
// we should fix this ASAP
if (argv._[0] === 'sh') {
argv._.shift()
}
if (argv._[0] === 'deploy') {
argv._.shift()
}
if (argv._[0]) {
// If path is relative: resolve
// if path is absolute: clear up strange `/` etc
path = resolve(getProcess().cwd(), argv._[0])
} else {
path = getProcess().cwd()
}
// Options
forceNew = argv.force
deploymentName = argv.name
sessionAffinity = argv['session-affinity']
debug = argv.debug
clipboard = !argv['no-clipboard']
forwardNpm = argv['forward-npm']
followSymlinks = !argv.links
wantsPublic = argv.public
apiUrl = ctx.apiUrl
isTTY = getProcess().stdout.isTTY
quiet = !isTTY
if (argv.h || argv.help) {
help()
await exit(0)
}
const { authConfig: { credentials }, config: { sh } } = ctx
const { token } = credentials.find(item => item.provider === 'sh')
const config = sh
alwaysForwardNpm = config.forwardNpm
try {
return sync({ token, config })
} catch (err) {
await stopDeployment(err)
}
}
async function sync({ token, config: { currentTeam, user } }) {
return new Promise(async (_resolve, reject) => {
const start = Date.now()
const rawPath = argv._[0]
const nowPlans = new NowPlans({
apiUrl,
token,
debug,
currentTeam
})
const planPromise = nowPlans.getCurrent()
try {
await fs.stat(rawPath || path)
} catch (err) {
let repo
let isValidRepo = false
try {
isValidRepo = isRepoPath(rawPath)
} catch (_err) {
if (err.code === 'INVALID_URL') {
await stopDeployment(_err)
} else {
reject(_err)
}
}
if (isValidRepo) {
const gitParts = gitPathParts(rawPath)
Object.assign(gitRepo, gitParts)
const searchMessage = setTimeout(() => {
console.log(
`> Didn't find directory. Searching on ${gitRepo.type}...`
)
}, 500)
try {
repo = await fromGit(rawPath, debug)
/*eslint-disable */
} catch (err) {}
/*eslint-enable */
clearTimeout(searchMessage)
}
if (repo) {
// Tell now which directory to deploy
path = repo.path
// Set global variable for deleting tmp dir later
// once the deployment has finished
Object.assign(gitRepo, repo)
} else if (isValidRepo) {
const gitRef = gitRepo.ref ? `with "${chalk.bold(gitRepo.ref)}" ` : ''
await stopDeployment(
`There's no repository named "${chalk.bold(
gitRepo.main
)}" ${gitRef}on ${gitRepo.type}`
)
} else {
console.error(error(`The specified directory "${basename(path)}" doesn't exist.`))
await exit(1)
}
}
// Make sure that directory is deployable
try {
await checkPath(path)
} catch (err) {
console.error(error({
message: err.message,
slug: 'path-not-deployable'
}))
await exit(1)
}
if (!quiet) {
if (gitRepo.main) {
const gitRef = gitRepo.ref ? ` at "${chalk.bold(gitRepo.ref)}" ` : ''
console.log(
info(`Deploying ${gitRepo.type} repository "${chalk.bold(
gitRepo.main
)}"${gitRef} under ${chalk.bold(
(currentTeam && currentTeam.slug) || user.username || user.email
)}`)
)
} else {
console.log(
info(`Deploying ${chalk.bold(toHumanPath(path))} under ${chalk.bold(
(currentTeam && currentTeam.slug) || user.username || user.email
)}`)
)
}
}
let deploymentType
// CLI deployment type explicit overrides
if (argv.docker) {
if (debug) {
console.log('> [debug] Forcing `deploymentType` = `docker`')
}
deploymentType = 'docker'
} else if (argv.npm) {
if (debug) {
console.log('> [debug] Forcing `deploymentType` = `npm`')
}
deploymentType = 'npm'
} else if (argv.static) {
if (debug) {
console.log('> [debug] Forcing `deploymentType` = `static`')
}
deploymentType = 'static'
}
let meta
;({
meta,
deploymentName,
deploymentType,
sessionAffinity
} = await readMeta(path, deploymentName, deploymentType, sessionAffinity))
const nowConfig = meta.nowConfig
const now = new Now({ apiUrl, token, debug, currentTeam })
let dotenvConfig
let dotenvOption
if (argv.dotenv) {
dotenvOption = argv.dotenv
} else if (nowConfig && nowConfig.dotenv) {
dotenvOption = nowConfig.dotenv
}
if (dotenvOption) {
const dotenvFileName =
typeof dotenvOption === 'string' ? dotenvOption : '.env'
try {
const dotenvFile = await fs.readFile(dotenvFileName)
dotenvConfig = dotenv.parse(dotenvFile)
} catch (err) {
if (err.code === 'ENOENT') {
console.error(error({
message: `--dotenv flag is set but ${dotenvFileName} file is missing`,
slug: 'missing-dotenv-target'
}))
await exit(1)
} else {
throw err
}
}
}
// Merge dotenv config, `env` from now.json, and `--env` / `-e` arguments
const deploymentEnv = Object.assign(
{},
dotenvConfig,
parseEnv(nowConfig && nowConfig.env, null),
parseEnv(argv.env, undefined)
)
// If there's any envs with `null` then prompt the user for the values
const askFor = Object.keys(deploymentEnv).filter(key => deploymentEnv[key] === null)
Object.assign(deploymentEnv, await promptForEnvFields(askFor))
let secrets
const findSecret = async uidOrName => {
if (!secrets) {
secrets = await now.listSecrets()
}
return secrets.filter(secret => {
return secret.name === uidOrName || secret.uid === uidOrName
})
}
const env_ = await Promise.all(
Object.keys(deploymentEnv).map(async key => {
if (!key) {
console.error(error({
message: 'Environment variable name is missing',
slug: 'missing-env-key-value'
}))
await exit(1)
}
if (/[^A-z0-9_]/i.test(key)) {
console.error(error(
`Invalid ${chalk.dim('-e')} key ${chalk.bold(
`"${chalk.bold(key)}"`
)}. Only letters, digits and underscores are allowed.`
))
await exit(1)
}
let val = deploymentEnv[key]
if (val === undefined) {
if (key in getProcess().env) {
console.log(
`> Reading ${chalk.bold(
`"${chalk.bold(key)}"`
)} from your env (as no value was specified)`
)
// Escape value if it begins with @
val = getProcess().env[key].replace(/^@/, '\\@')
} else {
console.error(error(
`No value specified for env ${chalk.bold(
`"${chalk.bold(key)}"`
)} and it was not found in your env.`
))
await exit(1)
}
}
if (val[0] === '@') {
const uidOrName = val.substr(1)
const _secrets = await findSecret(uidOrName)
if (_secrets.length === 0) {
if (uidOrName === '') {
console.error(error(
`Empty reference provided for env key ${chalk.bold(
`"${chalk.bold(key)}"`
)}`
))
} else {
console.error(error({
message: `No secret found by uid or name ${chalk.bold(`"${uidOrName}"`)}`,
slug: 'env-no-secret'
}))
}
await exit(1)
} else if (_secrets.length > 1) {
console.error(error(
`Ambiguous secret ${chalk.bold(
`"${uidOrName}"`
)} (matches ${chalk.bold(_secrets.length)} secrets)`
))
await exit(1)
}
val = { uid: _secrets[0].uid }
}
return [key, typeof val === 'string' ? val.replace(/^\\@/, '@') : val]
})
)
const env = {}
env_.filter(v => Boolean(v)).forEach(([key, val]) => {
if (key in env) {
console.log(
note(`Overriding duplicate env key ${chalk.bold(`"${key}"`)}`)
)
}
env[key] = val
})
let syncCount
try {
const createArgs = Object.assign(
{
env,
followSymlinks,
forceNew,
forwardNpm: alwaysForwardNpm || forwardNpm,
quiet,
wantsPublic,
sessionAffinity
},
meta
)
await now.create(path, createArgs)
if (now.syncFileCount > 0) {
await new Promise((resolve) => {
if (debug && now.syncFileCount !== now.fileCount) {
console.log(
`> [debug] total files ${now.fileCount}, ${now.syncFileCount} changed. `
)
}
const size = bytes(now.syncAmount)
syncCount = `${now.syncFileCount} file${now.syncFileCount > 1
? 's'
: ''}`
const bar = new Progress(
`> Upload [:bar] :percent :etas (${size}) [${syncCount}]`,
{
width: 20,
complete: '=',
incomplete: '',
total: now.syncAmount,
clear: true
}
)
now.upload()
now.on('upload', ({ names, data }) => {
const amount = data.length
if (debug) {
console.log(
`> [debug] Uploaded: ${names.join(' ')} (${bytes(data.length)})`
)
}
bar.tick(amount)
})
now.on('complete', () => resolve())
now.on('error', err => {
console.error(error('Upload failed'))
reject(err)
})
})
await now.create(path, createArgs)
}
} catch (err) {
if (debug) {
console.log(`> [debug] error: ${err}\n${err.stack}`)
}
await stopDeployment(err)
}
const { url } = now
const elapsed = ms(new Date() - start)
if (isTTY) {
if (clipboard) {
try {
await copy(url)
console.log(
`${chalk.cyan('> Ready!')} ${chalk.bold(
url
)} (copied to clipboard) [${elapsed}]`
)
} catch (err) {
console.log(
`${chalk.cyan('> Ready!')} ${chalk.bold(url)} [${elapsed}]`
)
}
} else {
console.log(`> ${url} [${elapsed}]`)
}
} else {
getProcess().stdout.write(url)
}
const startU = new Date()
const plan = await planPromise
if (plan.id === 'oss' && !wantsPublic) {
if (isTTY) {
console.log(
info(
`${chalk.bold(
(currentTeam && `${currentTeam.slug} is`) ||
`You (${user.username || user.email}) are`
)} on the OSS plan. Your code and logs will be made ${chalk.bold(
'public'
)}.`
)
)
const proceed = await promptBool(
'Are you sure you want to proceed with the deployment?',
{ trailing: eraseLines(1) }
)
if (proceed) {
console.log(
note(`You can use ${cmd('now --public')} to skip this prompt`)
)
} else {
const stopSpinner = wait('Canceling deployment')
await now.remove(now.id, { hard: true })
stopSpinner()
console.log(
info(
'Deployment aborted. No files were synced.',
` You can upgrade by running ${cmd('now upgrade')}.`
)
)
return 0
}
} else if (!wantsPublic) {
const msg =
'\nYou are on the OSS plan. Your code and logs will be made public.' +
' If you agree with that, please run again with --public.'
await stopDeployment(msg)
}
}
if (!quiet) {
if (syncCount) {
const elapsedU = ms(new Date() - startU)
console.log(
`> Synced ${syncCount} (${bytes(now.syncAmount)}) [${elapsedU}] `
)
}
}
// Show build logs
if (deploymentType === 'static') {
if (!quiet) {
console.log(success('Deployment complete!'))
}
await exit(0)
} else {
if (nowConfig && nowConfig.atlas) {
const cancelWait = wait('Initializing…')
try {
await printEvents(now, currentTeam, {
onOpen: cancelWait,
debug
})
} catch (err) {
cancelWait()
throw err
}
await exit(0)
} else {
if (!quiet) {
console.log(info('Initializing…'))
}
printLogs(now.host, token, currentTeam, user)
}
}
})
}
async function readMeta(
_path,
_deploymentName,
deploymentType,
_sessionAffinity
) {
try {
const meta = await readMetaData(_path, {
deploymentType,
deploymentName: _deploymentName,
quiet: true,
sessionAffinity: _sessionAffinity
})
if (!deploymentType) {
deploymentType = meta.type
if (debug) {
console.log(
`> [debug] Detected \`deploymentType\` = \`${deploymentType}\``
)
}
}
if (!_deploymentName) {
_deploymentName = meta.name
if (debug) {
console.log(
`> [debug] Detected \`deploymentName\` = "${_deploymentName}"`
)
}
}
return {
meta,
deploymentName: _deploymentName,
deploymentType,
sessionAffinity: _sessionAffinity
}
} catch (err) {
if (isTTY && err.code === 'MULTIPLE_MANIFESTS') {
if (debug) {
console.log('> [debug] Multiple manifests found, disambiguating')
}
console.log(
`> Two manifests found. Press [${chalk.bold(
'n'
)}] to deploy or re-run with --flag`
)
deploymentType = await promptOptions([
['npm', `${chalk.bold('package.json')}\t${chalk.gray(' --npm')} `],
['docker', `${chalk.bold('Dockerfile')}\t${chalk.gray('--docker')} `]
])
if (debug) {
console.log(
`> [debug] Selected \`deploymentType\` = "${deploymentType}"`
)
}
return readMeta(_path, _deploymentName, deploymentType)
}
throw err
}
}
async function printEvents(now, currentTeam = null, {
onOpen = ()=>{},
debug = false
} = {}) {
let url = `${apiUrl}/v1/now/deployments/${now.id}/events?follow=1`
if (currentTeam) {
url += `&teamId=${currentTeam.id}`
}
if (debug) {
console.log(info(`[debug] events ${url}`))
}
// we keep track of how much we log in case we
// drop the connection and have to start over
let o = 0
await retry(async (bail, attemptNumber) => {
if (debug && attemptNumber > 1) {
console.log(info('[debug] retrying events'))
}
// if we are retrying, we clear past logs
if (!quiet && o) getProcess().stdout.write(eraseLines(0))
const res = await now._fetch(url)
if (res.ok) {
// fire the open callback and ensure it's only fired once
onOpen()
onOpen = ()=>{}
// handle the event stream and make the promise get rejected
// if errors occur so we can retry
return new Promise((resolve, reject) => {
const stream = res.body.pipe(jsonlines.parse())
const onData = ({ type, payload }) => {
// if we are 'quiet' because we are piping, simply
// wait for the first instance to be started
// and ignore everything else
if (quiet) {
if (type === 'instance-start') {
resolve()
}
return
}
switch (type) {
case 'build-start':
o++
console.log(info('Building…'))
break
case 'stdout':
case 'stderr':
console.log(payload)
break
case 'build-complete':
o++
console.log(success('Build complete'))
break
case 'instance-start':
o++
console.log(success('Deployment started!'))
// avoid lingering events
stream.off('data', onData)
// close the stream and resolve
stream.end()
resolve()
break
}
}
stream.on('data', onData)
stream.on('error', err => {
reject(new Error(`Deployment event stream error: ${err.stack}`))
})
})
} else {
const err = new Error(`Deployment events status ${res.status}`)
if (res.status < 500) {
bail(err)
} else {
throw err
}
}
}, {
retries: 4
})
}
function printLogs(host, token) {
// Log build
const logger = new Logger(host, token, { debug, quiet })
logger.on('error', async err => {
if (!quiet) {
if (err && err.type === 'BUILD_ERROR') {
console.error(error(
`The build step of your project failed. To retry, run ${cmd(
'now --force'
)}.`
))
} else {
console.error(error('Deployment failed'))
}
}
if (gitRepo && gitRepo.cleanup) {
// Delete temporary directory that contains repository
gitRepo.cleanup()
if (debug) {
console.log('> [debug] Removed temporary repo directory')
}
}
await exit(1)
})
logger.on('close', async () => {
if (!quiet) {
console.log(`${chalk.cyan('> Deployment complete!')}`)
}
if (gitRepo && gitRepo.cleanup) {
// Delete temporary directory that contains repository
gitRepo.cleanup()
if (debug) {
console.log('> [debug] Removed temporary repo directory')
}
}
await exit()
})
}
module.exports = main