netlify-cli
Version:
Netlify command line tool
462 lines (419 loc) • 15.3 kB
JavaScript
const cp = require('child_process')
const fs = require('fs')
const path = require('path')
const process = require('process')
const { flags: flagsLib } = require('@oclif/command')
const chalk = require('chalk')
const copy = require('copy-template-dir')
const fuzzy = require('fuzzy')
const inquirer = require('inquirer')
const inquirerAutocompletePrompt = require('inquirer-autocomplete-prompt')
const fetch = require('node-fetch')
const ora = require('ora')
const { mkdirRecursiveSync } = require('../../lib/fs')
const { getSiteData, getAddons, getCurrentAddon } = require('../../utils/addons/prepare')
const Command = require('../../utils/command')
const { getSiteInformation, addEnvVariables } = require('../../utils/dev')
const {
// NETLIFYDEV,
NETLIFYDEVLOG,
NETLIFYDEVWARN,
NETLIFYDEVERR,
} = require('../../utils/logo')
const { readRepoURL, validateRepoURL } = require('../../utils/read-repo-url')
const templatesDir = path.resolve(__dirname, '../../functions-templates')
/**
* Be very clear what is the SOURCE (templates dir) vs the DEST (functions dir)
*/
class FunctionsCreateCommand extends Command {
async run() {
const { flags, args } = this.parse(FunctionsCreateCommand)
const { config } = this.netlify
const functionsDir = ensureFunctionDirExists(this, flags, config)
/* either download from URL or scaffold from template */
const mainFunc = flags.url ? downloadFromURL : scaffoldFromTemplate
await mainFunc(this, flags, args, functionsDir)
await this.config.runHook('analytics', {
eventName: 'command',
payload: {
command: 'functions:create',
},
})
}
}
FunctionsCreateCommand.args = [
{
name: 'name',
description: 'name of your new function file inside your functions folder',
},
]
FunctionsCreateCommand.description = `Create a new function locally`
FunctionsCreateCommand.examples = [
'netlify functions:create',
'netlify functions:create hello-world',
'netlify functions:create --name hello-world',
]
FunctionsCreateCommand.aliases = ['function:create']
FunctionsCreateCommand.flags = {
name: flagsLib.string({ char: 'n', description: 'function name' }),
url: flagsLib.string({ char: 'u', description: 'pull template from URL' }),
...FunctionsCreateCommand.flags,
}
module.exports = FunctionsCreateCommand
/**
* all subsections of code called from the main logic flow above
*/
// prompt for a name if name not supplied
const getNameFromArgs = async function (args, flags, defaultName) {
if (flags.name) {
if (args.name) {
throw new Error('function name specified in both flag and arg format, pick one')
}
return flags.name
}
if (args.name) {
return args.name
}
const { name } = await inquirer.prompt([
{
name: 'name',
message: 'name your function: ',
default: defaultName,
type: 'input',
validate: (val) => Boolean(val) && /^[\w.-]+$/i.test(val),
// make sure it is not undefined and is a valid filename.
// this has some nuance i have ignored, eg crossenv and i18n concerns
},
])
return name
}
const filterRegistry = function (registry, input) {
const temp = registry.map((value) => value.name + value.description)
const filteredTemplates = fuzzy.filter(input, temp)
const filteredTemplateNames = new Set(
filteredTemplates.map((filteredTemplate) => (input ? filteredTemplate.string : filteredTemplate)),
)
return registry
.filter((t) => filteredTemplateNames.has(t.name + t.description))
.map((t) => {
// add the score
const { score } = filteredTemplates.find((filteredTemplate) => filteredTemplate.string === t.name + t.description)
t.score = score
return t
})
}
const formatRegistryArrayForInquirer = function (lang) {
const folderNames = fs.readdirSync(path.join(templatesDir, lang))
const registry = folderNames
// filter out markdown files
.filter((folderName) => !folderName.endsWith('.md'))
// eslint-disable-next-line node/global-require, import/no-dynamic-require
.map((folderName) => require(path.join(templatesDir, lang, folderName, '.netlify-function-template.js')))
.sort(
(folderNameA, folderNameB) =>
(folderNameA.priority || DEFAULT_PRIORITY) - (folderNameB.priority || DEFAULT_PRIORITY),
)
.map((t) => {
t.lang = lang
return {
// confusing but this is the format inquirer wants
name: `[${t.name}] ${t.description}`,
value: t,
short: `${lang}-${t.name}`,
}
})
return registry
}
// pick template from our existing templates
const pickTemplate = async function () {
inquirer.registerPrompt('autocomplete', inquirerAutocompletePrompt)
// doesnt scale but will be ok for now
const [
jsreg,
// tsreg, goreg
] = [
'js',
// 'ts', 'go'
].map(formatRegistryArrayForInquirer)
const specialCommands = [
new inquirer.Separator(`----[Special Commands]----`),
{
name: `*** Clone template from Github URL ***`,
value: 'url',
short: 'gh-url',
},
{
name: `*** Report issue with, or suggest a new template ***`,
value: 'report',
short: 'gh-report',
},
]
const { chosentemplate } = await inquirer.prompt({
name: 'chosentemplate',
message: 'Pick a template',
type: 'autocomplete',
// suggestOnly: true, // we can explore this for entering URL in future
source(answersSoFar, input) {
if (!input || input === '') {
// show separators
return [
new inquirer.Separator(`----[JS]----`),
...jsreg,
// new inquirer.Separator(`----[TS]----`),
// ...tsreg,
// new inquirer.Separator(`----[GO]----`),
// ...goreg
...specialCommands,
]
}
// only show filtered results sorted by score
const answers = [
...filterRegistry(jsreg, input),
// ...filterRegistry(tsreg, input),
// ...filterRegistry(goreg, input)
...specialCommands,
].sort((answerA, answerB) => answerB.score - answerA.score)
return answers
},
})
return chosentemplate
}
const DEFAULT_PRIORITY = 999
/* get functions dir (and make it if necessary) */
const ensureFunctionDirExists = function (context, flags, config) {
const functionsDir = config.build && config.build.functions
if (!functionsDir) {
context.log(`${NETLIFYDEVLOG} No functions folder specified in netlify.toml`)
process.exit(1)
}
if (!fs.existsSync(functionsDir)) {
context.log(
`${NETLIFYDEVLOG} functions folder ${chalk.magenta.inverse(
functionsDir,
)} specified in netlify.toml but folder not found, creating it...`,
)
fs.mkdirSync(functionsDir)
context.log(`${NETLIFYDEVLOG} functions folder ${chalk.magenta.inverse(functionsDir)} created`)
}
return functionsDir
}
// Download files from a given github URL
const downloadFromURL = async function (context, flags, args, functionsDir) {
const folderContents = await readRepoURL(flags.url)
const [functionName] = flags.url.split('/').slice(-1)
const nameToUse = await getNameFromArgs(args, flags, functionName)
const fnFolder = path.join(functionsDir, nameToUse)
if (fs.existsSync(`${fnFolder}.js`) && fs.lstatSync(`${fnFolder}.js`).isFile()) {
context.log(
`${NETLIFYDEVWARN}: A single file version of the function ${nameToUse} already exists at ${fnFolder}.js. Terminating without further action.`,
)
process.exit(1)
}
try {
mkdirRecursiveSync(fnFolder)
} catch (error) {
// Ignore
}
await Promise.all(
folderContents.map(async ({ name, download_url: downloadUrl }) => {
try {
const res = await fetch(downloadUrl)
const finalName = path.basename(name, '.js') === functionName ? `${nameToUse}.js` : name
const dest = fs.createWriteStream(path.join(fnFolder, finalName))
res.body.pipe(dest)
} catch (error) {
throw new Error(`Error while retrieving ${downloadUrl} ${error}`)
}
}),
)
context.log(`${NETLIFYDEVLOG} Installing dependencies for ${nameToUse}...`)
cp.exec('npm i', { cwd: path.join(functionsDir, nameToUse) }, () => {
context.log(`${NETLIFYDEVLOG} Installing dependencies for ${nameToUse} complete `)
})
// read, execute, and delete function template file if exists
const fnTemplateFile = path.join(fnFolder, '.netlify-function-template.js')
if (fs.existsSync(fnTemplateFile)) {
// eslint-disable-next-line node/global-require, import/no-dynamic-require
const { onComplete, addons = [] } = require(fnTemplateFile)
await installAddons(context, addons, path.resolve(fnFolder))
await handleOnComplete({ context, onComplete })
// delete
fs.unlinkSync(fnTemplateFile)
}
}
const installDeps = function (functionPath) {
return new Promise((resolve) => {
cp.exec('npm i', { cwd: path.join(functionPath) }, () => {
resolve()
})
})
}
// no --url flag specified, pick from a provided template
const scaffoldFromTemplate = async function (context, flags, args, functionsDir) {
// pull the rest of the metadata from the template
const chosentemplate = await pickTemplate()
if (chosentemplate === 'url') {
const { chosenurl } = await inquirer.prompt([
{
name: 'chosenurl',
message: 'URL to clone: ',
type: 'input',
validate: (val) => Boolean(validateRepoURL(val)),
// make sure it is not undefined and is a valid filename.
// this has some nuance i have ignored, eg crossenv and i18n concerns
},
])
flags.url = chosenurl.trim()
try {
await downloadFromURL(context, flags, args, functionsDir)
} catch (error) {
context.error(`$${NETLIFYDEVERR} Error downloading from URL: ${flags.url}`)
context.error(error)
process.exit(1)
}
} else if (chosentemplate === 'report') {
context.log(`${NETLIFYDEVLOG} Open in browser: https://github.com/netlify/cli/issues/new`)
} else {
const { onComplete, name: templateName, lang, addons = [] } = chosentemplate
const pathToTemplate = path.join(templatesDir, lang, templateName)
if (!fs.existsSync(pathToTemplate)) {
throw new Error(
`there isnt a corresponding folder to the selected name, ${templateName} template is misconfigured`,
)
}
const name = await getNameFromArgs(args, flags, templateName)
context.log(`${NETLIFYDEVLOG} Creating function ${chalk.cyan.inverse(name)}`)
const functionPath = ensureFunctionPathIsOk(context, functionsDir, name)
// SWYX: note to future devs - useful for debugging source to output issues
// this.log('from ', pathToTemplate, ' to ', functionPath)
// SWYX: TODO
const vars = { NETLIFY_STUFF_TO_REPLACE: 'REPLACEMENT' }
let hasPackageJSON = false
copy(pathToTemplate, functionPath, vars, async (err, createdFiles) => {
if (err) throw err
createdFiles.forEach((filePath) => {
if (filePath.endsWith('.netlify-function-template.js')) return
context.log(`${NETLIFYDEVLOG} ${chalk.greenBright('Created')} ${filePath}`)
fs.chmodSync(path.resolve(filePath), TEMPLATE_PERMISSIONS)
if (filePath.includes('package.json')) hasPackageJSON = true
})
// delete function template file that was copied over by copydir
fs.unlinkSync(path.join(functionPath, '.netlify-function-template.js'))
// rename the root function file if it has a different name from default
if (name !== templateName) {
fs.renameSync(path.join(functionPath, `${templateName}.js`), path.join(functionPath, `${name}.js`))
}
// npm install
if (hasPackageJSON) {
const spinner = ora({
text: `installing dependencies for ${name}`,
spinner: 'moon',
}).start()
await installDeps(functionPath)
spinner.succeed(`installed dependencies for ${name}`)
}
await installAddons(context, addons, path.resolve(functionPath))
await handleOnComplete({ context, onComplete })
})
}
}
const TEMPLATE_PERMISSIONS = 0o777
const createFunctionAddon = async function ({ api, addons, siteId, addonName, siteData, log, error }) {
try {
const addon = getCurrentAddon({ addons, addonName })
if (addon && addon.id) {
log(`The "${addonName} add-on" already exists for ${siteData.name}`)
return false
}
await api.createServiceInstance({
siteId,
addon: addonName,
body: { config: {} },
})
log(`Add-on "${addonName}" created for ${siteData.name}`)
return true
} catch (error_) {
error(error_.message)
}
}
const injectEnvVariables = async ({ context }) => {
const { log, warn, error, netlify } = context
const { api, site, siteInfo } = netlify
const { teamEnv, addonsEnv, siteEnv, dotFilesEnv } = await getSiteInformation({
api,
site,
warn,
error,
siteInfo,
})
await addEnvVariables({ log, teamEnv, addonsEnv, siteEnv, dotFilesEnv })
}
const handleOnComplete = async ({ context, onComplete }) => {
if (onComplete) {
await injectEnvVariables({ context })
await onComplete.call(context)
}
}
const handleAddonDidInstall = async ({ addonCreated, addonDidInstall, context, fnPath }) => {
if (!addonCreated || !addonDidInstall) {
return
}
const { confirmPostInstall } = await inquirer.prompt([
{
type: 'confirm',
name: 'confirmPostInstall',
message: `This template has an optional setup script that runs after addon install. This can be helpful for first time users to try out templates. Run the script?`,
default: false,
},
])
if (!confirmPostInstall) {
return
}
await injectEnvVariables({ context })
addonDidInstall(fnPath)
}
const installAddons = async function (context, functionAddons, fnPath) {
if (functionAddons.length === 0) {
return
}
const { log, error } = context
const { api, site } = context.netlify
const siteId = site.id
if (!siteId) {
log('No site id found, please run inside a site folder or `netlify link`')
return false
}
log(`${NETLIFYDEVLOG} checking Netlify APIs...`)
const [siteData, siteAddons] = await Promise.all([
getSiteData({ api, siteId, error }),
getAddons({ api, siteId, error }),
])
const arr = functionAddons.map(async ({ addonName, addonDidInstall }) => {
log(`${NETLIFYDEVLOG} installing addon: ${chalk.yellow.inverse(addonName)}`)
try {
const addonCreated = await createFunctionAddon({
api,
addons: siteAddons,
siteId,
addonName,
siteData,
log,
error,
})
await handleAddonDidInstall({ addonCreated, addonDidInstall, context, fnPath })
} catch (error_) {
error(`${NETLIFYDEVERR} Error installing addon: `, error_)
}
})
return Promise.all(arr)
}
// we used to allow for a --dir command,
// but have retired that to force every scaffolded function to be a folder
const ensureFunctionPathIsOk = function (context, functionsDir, name) {
const functionPath = path.join(functionsDir, name)
if (fs.existsSync(functionPath)) {
context.log(`${NETLIFYDEVLOG} Function ${functionPath} already exists, cancelling...`)
process.exit(1)
}
return functionPath
}