UNPKG

netlify-cli

Version:

Netlify command line tool

647 lines (569 loc) 18.8 kB
const path = require('path') const process = require('process') const { flags: flagsLib } = require('@oclif/command') const chalk = require('chalk') const cliSpinnerNames = Object.keys(require('cli-spinners')) const dotProp = require('dot-prop') const inquirer = require('inquirer') const get = require('lodash/get') const isObject = require('lodash/isObject') const logSymbols = require('log-symbols') const ora = require('ora') const prettyjson = require('prettyjson') const randomItem = require('random-item') const { cancelDeploy } = require('../lib/api') const { getBuildOptions, runBuild } = require('../lib/build') const { statAsync } = require('../lib/fs') const { getLogMessage } = require('../lib/log') const Command = require('../utils/command') const { deployEdgeHandlers } = require('../utils/edge-handlers') const { NETLIFYDEV, NETLIFYDEVLOG, NETLIFYDEVERR } = require('../utils/logo') const openBrowser = require('../utils/open-browser') const LinkCommand = require('./link') const SitesCreateCommand = require('./sites/create') const DEFAULT_DEPLOY_TIMEOUT = 1.2e6 const triggerDeploy = async ({ api, siteId, siteData, log, error }) => { try { const siteBuild = await api.createSiteBuild({ siteId }) log( `${NETLIFYDEV} A new deployment was triggered successfully. Visit https://app.netlify.com/sites/${siteData.name}/deploys/${siteBuild.deploy_id} to see the logs.`, ) } catch (error_) { if (error_.status === 404) { error('Site not found. Please rerun "netlify link" and make sure that your site has CI configured.') } else { error(error_.message) } } } const getDeployFolder = async ({ flags, config, site, siteData, log }) => { let deployFolder if (flags.dir) { deployFolder = path.resolve(process.cwd(), flags.dir) } else if (get(config, 'build.publish')) { deployFolder = path.resolve(site.root, get(config, 'build.publish')) } else if (get(siteData, 'build_settings.dir')) { deployFolder = path.resolve(site.root, get(siteData, 'build_settings.dir')) } if (!deployFolder) { log('Please provide a publish directory (e.g. "public" or "dist" or "."):') log(process.cwd()) const { promptPath } = await inquirer.prompt([ { type: 'input', name: 'promptPath', message: 'Publish directory', default: '.', filter: (input) => path.resolve(process.cwd(), input), }, ]) deployFolder = promptPath } return deployFolder } const validateDeployFolder = async ({ deployFolder, error }) => { let stat try { stat = await statAsync(deployFolder) } catch (error_) { if (error_.code === 'ENOENT') { return error(`No such directory ${deployFolder}! Did you forget to run a build?`) } // Improve the message of permission errors if (error_.code === 'EACCES') { return error('Permission error when trying to access deploy folder') } throw error_ } if (!stat.isDirectory()) { return error('Deploy target must be a path to a directory') } return stat } const getFunctionsFolder = ({ flags, config, site, siteData }) => { let functionsFolder // Support "functions" and "Functions" const funcConfig = get(config, 'build.functions') || get(config, 'build.Functions') if (flags.functions) { functionsFolder = path.resolve(process.cwd(), flags.functions) } else if (funcConfig) { functionsFolder = path.resolve(site.root, funcConfig) } else if (get(siteData, 'build_settings.functions_dir')) { functionsFolder = path.resolve(site.root, get(siteData, 'build_settings.functions_dir')) } return functionsFolder } const validateFunctionsFolder = async ({ functionsFolder, log, error }) => { let stat if (functionsFolder) { // we used to hard error if functions folder is specified but doesn't exist // but this was too strict for onboarding. we can just log a warning. try { stat = await statAsync(functionsFolder) } catch (error_) { if (error_.code === 'ENOENT') { log( `Functions folder "${functionsFolder}" specified but it doesn't exist! Will proceed without deploying functions`, ) } // Improve the message of permission errors if (error_.code === 'EACCES') { error('Permission error when trying to access functions folder') } } } if (stat && !stat.isDirectory()) { error('Functions folder must be a path to a directory') } return stat } const validateFolders = async ({ deployFolder, functionsFolder, error, log }) => { const deployFolderStat = await validateDeployFolder({ deployFolder, error }) const functionsFolderStat = await validateFunctionsFolder({ functionsFolder, error, log }) return { deployFolderStat, functionsFolderStat } } const getDeployFilesFilter = ({ site, deployFolder }) => { // site.root === deployFolder can happen when users run `netlify deploy --dir .` // in that specific case we don't want to publish the repo node_modules // when site.root !== deployFolder the behaviour matches our buildbot const skipNodeModules = site.root === deployFolder return (filename) => { if (filename == null) { return false } if (filename === deployFolder) { return true } const basename = path.basename(filename) const skipFile = (skipNodeModules && basename === 'node_modules') || (basename.startsWith('.') && basename !== '.well-known') || basename.startsWith('__MACOSX') || basename.includes('/.') return !skipFile } } const SEC_TO_MILLISEC = 1e3 // 100 bytes const SYNC_FILE_LIMIT = 1e2 const prepareProductionDeploy = async ({ siteData, api, log, exit }) => { if (isObject(siteData.published_deploy) && siteData.published_deploy.locked) { log(`\n${NETLIFYDEVERR} Deployments are "locked" for production context of this site\n`) const { unlockChoice } = await inquirer.prompt([ { type: 'confirm', name: 'unlockChoice', message: 'Would you like to "unlock" deployments for production context to proceed?', default: false, }, ]) if (!unlockChoice) exit(0) await api.unlockDeploy({ deploy_id: siteData.published_deploy.id }) log(`\n${NETLIFYDEVLOG} "Auto publishing" has been enabled for production context\n`) } log('Deploying to main site URL...') } const hasErrorMessage = (actual, expected) => { if (typeof actual === 'string') { return actual.includes(expected) } return false } const getJsonErrorMessage = (error) => { return dotProp.get(error, 'json.message', '') } const reportDeployError = ({ error, warn, failAndExit }) => { switch (true) { case error.name === 'JSONHTTPError': { const message = getJsonErrorMessage(error) if (hasErrorMessage(message, 'Background Functions not allowed by team plan')) { return failAndExit(`\n${getLogMessage('functions.backgroundNotSupported')}`) } warn(`JSONHTTPError: ${message} ${error.status}`) warn(`\n${JSON.stringify(error, null, ' ')}\n`) failAndExit(error) return } case error.name === 'TextHTTPError': { warn(`TextHTTPError: ${error.status}`) warn(`\n${error}\n`) failAndExit(error) return } case hasErrorMessage(error.message, 'Invalid filename'): { warn(error.message) failAndExit(error) return } default: { warn(`\n${JSON.stringify(error, null, ' ')}\n`) failAndExit(error) } } } const runDeploy = async ({ flags, deployToProduction, site, siteData, api, siteId, deployFolder, configPath, functionsFolder, alias, log, warn, error, exit, }) => { let results let deployId try { if (deployToProduction) { await prepareProductionDeploy({ siteData, api, log, exit }) } else { log('Deploying to draft URL...') } const draft = !deployToProduction && !alias const title = flags.message results = await api.createSiteDeploy({ siteId, title, body: { draft, branch: alias } }) deployId = results.id const silent = flags.json || flags.silent await deployEdgeHandlers({ site, deployId, api, silent, error, warn, }) results = await api.deploy(siteId, deployFolder, { configPath, fnDir: functionsFolder, statusCb: silent ? () => {} : deployProgressCb(), deployTimeout: flags.timeout * SEC_TO_MILLISEC || DEFAULT_DEPLOY_TIMEOUT, syncFileLimit: SYNC_FILE_LIMIT, // pass an existing deployId to update deployId, filter: getDeployFilesFilter({ site, deployFolder }), }) } catch (error_) { if (deployId) { await cancelDeploy({ api, deployId, warn }) } reportDeployError({ error: error_, warn, failAndExit: error }) } const siteUrl = results.deploy.ssl_url || results.deploy.url const deployUrl = get(results, 'deploy.deploy_ssl_url') || get(results, 'deploy.deploy_url') const logsUrl = `${get(results, 'deploy.admin_url')}/deploys/${get(results, 'deploy.id')}` return { siteId: results.deploy.site_id, siteName: results.deploy.name, deployId: results.deployId, siteUrl, deployUrl, logsUrl, } } const printResults = ({ flags, results, deployToProduction, log, logJson, exit }) => { const msgData = { Logs: `${results.logsUrl}`, 'Unique Deploy URL': results.deployUrl, } if (deployToProduction) { msgData['Website URL'] = results.siteUrl } else { delete msgData['Unique Deploy URL'] msgData['Website Draft URL'] = results.deployUrl } // Spacer log() // Json response for piping commands if (flags.json) { const jsonData = { name: results.name, site_id: results.site_id, site_name: results.siteName, deploy_id: results.deployId, deploy_url: results.deployUrl, logs: results.logsUrl, } if (deployToProduction) { jsonData.url = results.siteUrl } logJson(jsonData) exit(0) } else { log(prettyjson.render(msgData)) if (!deployToProduction) { log() log('If everything looks good on your draft URL, deploy it to your main site URL with the --prod flag.') log(`${chalk.cyanBright.bold('netlify deploy --prod')}`) log() } } } class DeployCommand extends Command { async run() { const { flags } = this.parse(DeployCommand) const { log, logJson, warn, error, exit } = this const { api, site, config } = this.netlify const alias = flags.alias || flags.branch if (flags.branch) { warn('--branch flag has been renamed to --alias and will be removed in future versions') } const deployToProduction = flags.prod await this.authenticate(flags.auth) await this.config.runHook('analytics', { eventName: 'command', payload: { command: 'deploy', open: flags.open, prod: flags.prod, json: flags.json, alias: Boolean(alias), }, }) let siteId = flags.site || site.id let siteData = {} if (siteId) { try { siteData = await api.getSite({ siteId }) } catch (error_) { // TODO specifically handle known cases (e.g. no account access) if (error_.status === 404) { error('Site not found') } else { error(error_.message) } } } else { this.log("This folder isn't linked to a site yet") const NEW_SITE = '+ Create & configure a new site' const EXISTING_SITE = 'Link this directory to an existing site' const initializeOpts = [EXISTING_SITE, NEW_SITE] const { initChoice } = await inquirer.prompt([ { type: 'list', name: 'initChoice', message: 'What would you like to do?', choices: initializeOpts, }, ]) // create site or search for one if (initChoice === NEW_SITE) { // run site:create command siteData = await SitesCreateCommand.run([]) site.id = siteData.id siteId = site.id } else if (initChoice === EXISTING_SITE) { // run link command siteData = await LinkCommand.run([], false) site.id = siteData.id siteId = site.id } } if (flags.trigger) { return triggerDeploy({ api, siteId, siteData, log, error }) } if (flags.build) { const options = await getBuildOptions({ context: this, token: this.getConfigToken()[0], flags, }) const exitCode = await runBuild(options) if (exitCode !== 0) { this.exit(exitCode) } } const deployFolder = await getDeployFolder({ flags, config, site, siteData, log }) const functionsFolder = getFunctionsFolder({ flags, config, site, siteData }) const { configPath } = site log( prettyjson.render({ 'Deploy path': deployFolder, 'Functions path': functionsFolder, 'Configuration path': configPath, }), ) const { functionsFolderStat } = await validateFolders({ deployFolder, functionsFolder, error, log, }) const results = await runDeploy({ flags, deployToProduction, site, siteData, api, siteId, deployFolder, configPath, // pass undefined functionsFolder if doesn't exist functionsFolder: functionsFolderStat && functionsFolder, alias, log, warn, error, exit, }) printResults({ flags, results, deployToProduction, log, logJson, exit }) if (flags.open) { const urlToOpen = deployToProduction ? results.siteUrl : results.deployUrl await openBrowser({ url: urlToOpen, log }) exit() } } } DeployCommand.description = `Create a new deploy from the contents of a folder Deploys from the build settings found in the netlify.toml file, or settings from the API. The following environment variables can be used to override configuration file lookups and prompts: - \`NETLIFY_AUTH_TOKEN\` - an access token to use when authenticating commands. Keep this value private. - \`NETLIFY_SITE_ID\` - override any linked site in the current working directory. Lambda functions in the function folder can be in the following configurations for deployment: Built Go binaries: ------------------ \`\`\` functions/ └── nameOfGoFunction \`\`\` Build binaries of your Go language functions into the functions folder as part of your build process. Single file Node.js functions: ----------------------------- Build dependency bundled Node.js lambda functions with tools like netlify-lambda, webpack or browserify into the function folder as part of your build process. \`\`\` functions/ └── nameOfBundledNodeJSFunction.js \`\`\` Unbundled Node.js functions that have dependencies outside or inside of the functions folder: --------------------------------------------------------------------------------------------- You can ship unbundled Node.js functions with the CLI, utilizing top level project dependencies, or a nested package.json. If you use nested dependencies, be sure to populate the nested node_modules as part of your build process before deploying using npm or yarn. \`\`\` project/ ├── functions │ ├── functionName/ │ │ ├── functionName.js (Note the folder and the function name need to match) │ │ ├── package.json │ │ └── node_modules/ │ └── unbundledFunction.js ├── package.json ├── netlify.toml └── node_modules/ \`\`\` Any mix of these configurations works as well. Node.js function entry points ----------------------------- Function entry points are determined by the file name and name of the folder they are in: \`\`\` functions/ ├── aFolderlessFunctionEntrypoint.js └── functionName/ ├── notTheEntryPoint.js └── functionName.js \`\`\` Support for package.json's main field, and intrinsic index.js entrypoints are coming soon. ` DeployCommand.examples = [ 'netlify deploy', 'netlify deploy --prod', 'netlify deploy --prod --open', 'netlify deploy --message "A message with an $ENV_VAR"', 'netlify deploy --auth $NETLIFY_AUTH_TOKEN', 'netlify deploy --trigger', ] DeployCommand.flags = { dir: flagsLib.string({ char: 'd', description: 'Specify a folder to deploy', }), functions: flagsLib.string({ char: 'f', description: 'Specify a functions folder to deploy', }), prod: flagsLib.boolean({ char: 'p', description: 'Deploy to production', default: false, exclusive: ['alias', 'branch'], }), alias: flagsLib.string({ description: "Specifies the alias for deployment. Useful for creating predictable deployment URL's", }), branch: flagsLib.string({ char: 'b', description: 'Serves the same functionality as --alias. Deprecated and will be removed in future versions', }), open: flagsLib.boolean({ char: 'o', description: 'Open site after deploy', default: false, }), message: flagsLib.string({ char: 'm', description: 'A short message to include in the deploy log', }), auth: flagsLib.string({ char: 'a', description: 'Netlify auth token to deploy with', env: 'NETLIFY_AUTH_TOKEN', }), site: flagsLib.string({ char: 's', description: 'A site ID to deploy to', env: 'NETLIFY_SITE_ID', }), json: flagsLib.boolean({ description: 'Output deployment data as JSON', }), timeout: flagsLib.integer({ description: 'Timeout to wait for deployment to finish', }), trigger: flagsLib.boolean({ description: 'Trigger a new build of your site on Netlify without uploading local files', exclusive: ['build'], }), build: flagsLib.boolean({ description: 'Run build command before deploying', }), ...DeployCommand.flags, } const deployProgressCb = function () { const events = {} // statusObj: { // type: name-of-step // msg: msg to print // phase: [start, progress, stop] // } // return (ev) => { switch (ev.phase) { case 'start': { const spinner = ev.spinner || randomItem(cliSpinnerNames) events[ev.type] = ora({ text: ev.msg, spinner, }).start() return } case 'progress': { const spinner = events[ev.type] if (spinner) spinner.text = ev.msg return } case 'stop': default: { const spinner = events[ev.type] if (spinner) { spinner.stopAndPersist({ text: ev.msg, symbol: logSymbols.success }) delete events[ev.type] } } } } } module.exports = DeployCommand