project templates cli using nodejs

How to build your own project templates using Node CLI and typescript

Pongsatorn Tonglairoum

--

This is a journey of having my own cli to create new project from templates.

My Problem:

I have a need to create new project from time to time. Most of time, these project have the same setup and installation. For example, a graphql server in typescript on nodejs. Every time before creating a new project, I have to recall what I did for the previous project and it is quite bothering me. So, I think it is the time that I should create the template of the project and find a way to reuse it as easy as possible. As a result, I decided to create a nodejs cli that helps me create a new project.

My Solution:

I started by using a solution from this article “Creating a project generator with Node” then add more features as needed.

Requirements:

  • a cli written in typescript using nodejs
  • run on macos and windows
  • dynamically read templates folders
  • be able to replace a template with new value (using template engine “ejs”)
  • support configuration per template such as post message after installation phase

In case, you just need the whole code, it is here.

  1. Create new typescript project

You can follow this article to create typescript project.

Or you can bootstrap new project using the cli resulting from this article itself (as an usage example).

npm install -g @pongsatt/mycli
mycli --name testbare --template node-ts-bare

Resulting folder should look like this:

Initial project folders

2. Add a template to the project under folder “src/templates”

We will create a template called “simple-project” which include only “package.json” file.

{
"name": "simple-project",
"version": "1.0.0",
"license": "MIT"
}
template folder

3. Add “shebang” for node at the top of “src/index.ts”

#!/usr/bin/env node...

4. Read templates as choices for user to choose

Install dependencies.

yarn add -D @types/node @types/inquirer
yarn add inquirer

Update “src/index.ts”.

Read templates folder as questions.

import * as fs from 'fs';
import * as path from 'path';
const CHOICES = fs.readdirSync(path.join(__dirname, 'templates'));const QUESTIONS = [
{
name: 'template',
type: 'list',
message: 'What project template would you like to generate?',
choices: CHOICES
},
{
name: 'name',
type: 'input',
message: 'Project name:'
}];

Read templates folder as questions.

import * as inquirer from 'inquirer';
import chalk from 'chalk';
inquirer.prompt(QUESTIONS)
.then(answers => {
console.log(answers);
});

You can test it by running command below and should see result as picture below.

yarn start
Result of yarn start

It will show more templates as you add more folder under templates.

5. Read all the options (Update “src/index.ts”)

export interface CliOptions {
projectName: string
templateName: string
templatePath: string
tartgetPath: string
}
const CURR_DIR = process.cwd();inquirer.prompt(QUESTIONS)
.then(answers => {
const projectChoice = answers['template'];
const projectName = answers['name'];
const templatePath = path.join(__dirname, 'templates', projectChoice);
const tartgetPath = path.join(CURR_DIR, projectName);
const options: CliOptions = {
projectName,
templateName: projectChoice,
templatePath,
tartgetPath
}
console.log(options);
});

6. Create the project folder

Create a folder name as user input “projectName” in the current directory. It will show error message if the folder exists.

Define function.

function createProject(projectPath: string) {
if (fs.existsSync(projectPath)) {
console.log(chalk.red(`Folder ${projectPath} exists. Delete or use another name.`));
return false;
}
fs.mkdirSync(projectPath);

return true;
}

Use the function inside answer method. In the case of failure, stop proceed.

....inquirer.prompt(QUESTIONS)
.then(answers => {
....

if (!createProject(tartgetPath)) {
return;
}
});

7. Copy and transform all the contents from template to project folder

The logic is based on “Creating a project generator with Node”.

Define a function that accept template and destination folders.

// list of file/folder that should not be copied
const SKIP_FILES = ['node_modules', '.template.json'];
function createDirectoryContents(templatePath: string, projectName: string) {
// read all files/folders (1 level) from template folder
const filesToCreate = fs.readdirSync(templatePath);
// loop each file/folder
filesToCreate.forEach(file => {
const origFilePath = path.join(templatePath, file);

// get stats about the current file
const stats = fs.statSync(origFilePath);

// skip files that should not be copied
if (SKIP_FILES.indexOf(file) > -1) return;

if (stats.isFile()) {
// read file content and transform it using template engine
let contents = fs.readFileSync(origFilePath, 'utf8');
// write file to destination folder
const writePath = path.join(CURR_DIR, projectName, file);
fs.writeFileSync(writePath, contents, 'utf8');
} else if (stats.isDirectory()) {
// create folder in destination folder
fs.mkdirSync(path.join(CURR_DIR, projectName, file));
// copy files/folder inside current folder recursively
createDirectoryContents(path.join(templatePath, file), path.join(projectName, file));
}
});
}

Use defined function after the project creation part.

....
if (!createProject(tartgetPath)) {
return;
}
createDirectoryContents(templatePath, projectName);....

8. Testing the program as a CLI

Install tool “shx” for building script

yarn add -D shx

Add build script to “package.json”

"build": "tsc && shx rm -rf dist/templates && shx cp -r src/templates dist"
Build script

Run build

yarn build

Add “bin” to “package.json”

"bin": {
"mycli": "./dist/index.js"
},
bin script

Register “mycli” as a command line interface

npm link

If successful, you can run command “mycli” anywhere on your machine. (Make sure you have a permission to read/write files)

9. Transform template files using template engine

Currently, you can copy template project as a new project but it will be exactly the same. This is impractical.

In order to copy template project and replace anything with new value such as “projectName” will will need a template engine.

For example, update template “src/templates/simple-project/package.json” to have a “projectName” placeholder. It will be replaced with user input “projectName” while copying the template file.

{
"name": "<%= projectName %>",
"version": "1.0.0",
....
}

I use “ejs” as a template engine for its syntax and simplicity.

First, jnstall ejs.

yarn add ejsyarn add -D @types/ejs

Add utility function to render template under “src/utils/template.ts”.

import * as ejs from 'ejs';export interface TemplateData {
projectName: string
}
export function render(content: string, data: TemplateData) {
return ejs.render(content, data);
}

Add code to transform the content inside “src/index.ts” function “createDirectoryContents”

....if (stats.isFile()) {
// read file content and transform it using template engine
let contents = fs.readFileSync(origFilePath, 'utf8');
contents = template.render(contents, { projectName });
....

Run “yarn build” again and test it. The newly created project should use the new project name.

10. Add ability to do a post processing (optional)

Maybe you want to do something automatically after creating new project such as install node dependencies.

Install dependency.

yarn add shelljs
yarn add -D @types/shelljs

Define postprocess function.

function postProcess(options: CliOptions) {
const isNode = fs.existsSync(path.join(options.templatePath, 'package.json'));
if (isNode) {
shell.cd(options.tartgetPath);
const result = shell.exec('yarn install');
if (result.code !== 0) {
return false;
}
}

return true;
}

Use the defined function.

....createDirectoryContents(templatePath, projectName);postProcess(options);....

Run “yarn build” and run “mycli” again. You should see that after creating new project. It executes “yarn install”.

11. Add ability to specify options from the command line (optional)

This feature will enable you to specify the options as command line argument instead of picking from list.

For example,

mycli --name=testproject --template=simple-project

First, install dependency.

yarn add yargs
yarn add -D @types/yargs

Add a logic to skip asking user for input if it was provided by arguments.

import * as yargs from 'yargs';const QUESTIONS = [{
name: 'template',
type: 'list',
message: 'What project template would you like to generate?',
choices: CHOICES,
when: () => !yargs.argv['template']
},{
name: 'name',
type: 'input',
message: 'Project name:',
when: () => !yargs.argv['name']
}];

Merge command line arguments with user’s answers.

....
inquirer.prompt(QUESTIONS)
.then(answers => {
answers = Object.assign({}, answers, yargs.argv);
....

With this approach, user will be asked only the question missing from the arguments or skip asking user altogether if arguments are satisfied.

That’s all. You can get the complete code here.

Note: The complete code also support template configuration inside the template.

--

--