const fs = require('fs') const path = require('path') const { promisify } = require('util') const minimist = require('minimist') const { Signale } = require('signale') const { prompt } = require('enquirer') const colors = require('./colors.json') const icons = require('./feather-icons.json') const readFile = promisify(fs.readFile) const writeFile = promisify(fs.writeFile) const mkdir = promisify(fs.mkdir) /** * Reads a template file from disk. * * @param {string} filename The template filename to read. * @returns {Promise} The template file string contents. */ async function readTemplate (filename) { const templateDir = path.join(__dirname, 'template') return readFile(path.join(templateDir, filename), 'utf8') } /** * A predicate function to ensure a string is not empty. * * @param {string} value The string value. * @returns {boolean} Whether the string is empty or not. */ const isNotEmpty = value => value.length > 0 /** * The options object returned from the CLI questionnaire prompt. * @typedef {object} PromptAnswers * @property {string} name The action name. * @property {string} description The action description. * @property {string} icon The feather icon name. See `bin/feather-icons.json` for options. * @property {string} color The GitHub Action color. See `bin/colors.json` for options. */ /** * Prompts the user with a questionnaire to get key metadata for the GitHub Action. * * @returns {Promise} An object containing prompt answers. */ async function getActionMetadata () { return prompt([ { type: 'input', name: 'name', message: 'What is the name of your action?', initial: 'Your action name', validate: isNotEmpty }, { type: 'input', name: 'description', message: 'What is a short description of your action?', initial: 'A description of your action', validate: isNotEmpty }, { type: 'autocomplete', name: 'icon', message: 'Choose an icon for your action. Visit https://feathericons.com for a visual reference.', choices: icons, limit: 10 }, { type: 'autocomplete', name: 'color', message: 'Choose a background color background color used in the visual workflow editor for your action.', choices: colors } ]) } /** * Creates a Dockerfile contents string, replacing variables in the Dockerfile template * with values passed in by the user from the CLI prompt. * * @param {PromptAnswers} answers The CLI prompt answers. * @returns {Promise} The Dockerfile contents. */ async function createDockerfile (answers) { const dockerfileTemplate = await readTemplate('Dockerfile') return dockerfileTemplate .replace(':NAME', answers.name) .replace(':DESCRIPTION', answers.description) .replace(':ICON', answers.icon) .replace(':COLOR', answers.color) } /** * Creates a action.yml contents string, replacing variables in the action.yml template * with values passed in by the user from the CLI prompt. * * @param {PromptAnswers} answers The CLI prompt answers. * @returns {Promise} The action.yml contents. */ async function createActionYaml (answers) { const template = await readTemplate('action.yml') return template .replace(':NAME', answers.name) .replace(':DESCRIPTION', answers.description) .replace(':ICON', answers.icon) .replace(':COLOR', answers.color) } /** * Creates an index.test.js contents string, replacing variables in the index.test.js template * with values passed in by the user from the CLI prompt. * * @param {PromptAnswers} answers The CLI prompt answers. * @returns {Promise} The index.test.js contents. */ async function createIndexTest (answers) { const indexTest = await readTemplate('index.test.js') return indexTest .replace(':NAME', answers.name) } /** * Creates a `package.json` object with the latest version * of `actions-toolkit` ready to be installed. * * @param {string} name The action package name. * @returns {object} The `package.json` contents. */ function createPackageJson (name) { const { version, devDependencies } = require('../package.json') return { name, private: true, main: 'index.js', scripts: { start: 'node ./index.js', test: 'jest' }, dependencies: { 'actions-toolkit': `^${version}` }, devDependencies: { jest: devDependencies.jest } } } /** * Runs the create action CLI prompt and bootstraps a new directory for the user. * * @public * @param {string[]} argv The command line arguments to parse. * @param {import("signale").Signale} [logger] The Signale logger. * @returns {Promise} Nothing. */ module.exports = async function createAction (argv, signale = new Signale({ config: { displayLabel: false } })) { const args = minimist(argv) const directoryName = args._[0] if (!directoryName || args.help) { signale.log('\nUsage: npx actions-toolkit ') return process.exit(1) } signale.star('Welcome to actions-toolkit! Let\'s get started creating an action.\n') const base = path.join(process.cwd(), directoryName) try { signale.info(`Creating folder ${base}...`) await mkdir(base) } catch (err) { if (err.code === 'EEXIST') { signale.fatal(`Folder ${base} already exists!`) } throw err } // Collect answers const metadata = await getActionMetadata() signale.log('\n------------------------------------\n') // Create the templated files const actionYaml = await createActionYaml(metadata) const dockerfile = await createDockerfile(metadata) const indexTest = await createIndexTest(metadata) const packageJson = createPackageJson(directoryName) const entrypoint = await readTemplate('index.js') await Promise.all([ ['package.json', JSON.stringify(packageJson, null, 2)], ['Dockerfile', dockerfile], ['action.yml', actionYaml], ['index.js', entrypoint], ['index.test.js', indexTest] ].map(async ([filename, contents]) => { signale.info(`Creating ${filename}...`) await writeFile(path.join(base, filename), contents) })) signale.log('\n------------------------------------\n') signale.success(`Done! Enjoy building your GitHub Action!`) signale.info(`Get started with:\n\ncd ${directoryName} && npm install`) }