Creating a custom CLI
Code Shaper provides the shaper
CLI for generating code. However, in some
cases you may want to create your own CLI which is customized to your project.
You can use the custom-cli
plugin to do just that.
Example CLI
Code Shaper's shaper
CLI gives you a modular way to construct your projects in
a monorepo. In this example, we will create a custom CLI called react-shaper
with single focus - to generate a React app along with an opinionated stack all
in one shot (similar to create-react-app). You
can look at the finished example
to get an idea of what we will build.
Create an empty monorepo
Create an empty monorepo using Code Shaper.
# Change directory to a location where you create projects, e.g.
cd ~/projects
# Run Code Shaper and follow the prompts to create an empty monorepo
npx shaper
? Which plugin would you like to run? Repo
? Which generator would you like to run? turborepo
? Repository name? react-shaper
# Install dependencies
cd react-shaper
git init
npm install
# Add Code Shaper plugins
npm install @code-shaper/shaper-utils @code-shaper/custom-cli @code-shaper/plugin
# Commit
git add .
git commit -m "Initial commit"
Create a custom CLI
Now let's create react-shaper
, our custom CLI.
npx shaper
? Which plugin would you like to run? Custom CLI
? Which generator would you like to run? cli
? CLI name? react-shaper
? Parent directory? apps
? Package name used for publishing? react-shaper
# In the root directory, run:
npm install
# To make sure that everything is set up correctly, run a build
npm run build
# Commit
git add .
git commit -m "Added react-shaper CLI"
Create a plugin + generator
Now that we have our CLI ready, let's create a plugin for generating our React app.
# Run shaper in the repo's root directory
npx shaper
? Which plugin would you like to run? Plugin
? Which generator would you like to run? plugin
? Plugin name? react-starter
? Parent directory? plugins
? Package name used for publishing? @movie-magic/react-starter
# In the root directory, run:
npm install
# To make sure that everything is set up correctly, run a build
npm run build
# Run shaper again to generate a generator
npx shaper
? Which plugin would you like to run? Plugin
? Which generator would you like to run? generator
? Generator name? app
? Which plugin should this go under? plugins/react-starter
Register the app
generator in your react-starter
plugin by editing the
index.ts
file of your plugin. See highlighted lines below.
import {
Options,
Plugin,
Generator,
GeneratorMap,
selectGenerator,
} from '@code-shaper/shaper-utils';
import { appGenerator } from './appGenerator';
const generators: GeneratorMap = {};
function registerGenerator(generator: Generator) {
const { id } = generator;
generators[id] = generator;
}
// ----- Register Generators Here -----
registerGenerator(appGenerator);
const reactPatternsPlugin: Plugin = {
id: '@movie-magic/react-starter',
name: 'React Starter',
description: 'generates react-starter artifacts',
run: async (inputOptions: Options) => {
const generator = await selectGenerator(generators, inputOptions);
if (!generator) {
return Promise.resolve();
}
return generator.generate(inputOptions);
},
};
export default reactStarterPlugin;
Replace the contents of appGenerator/index.ts
with the code shown below. The
main difference is that we are not asking for a parent directory - the reason
being that we will always generate the app under the current directory.
import { cc, FileUtils, Generator, Options } from '@code-shaper/shaper-utils';
import inquirer from 'inquirer';
import path from 'path';
export const appGenerator: Generator = {
id: 'app',
name: 'App',
description: 'generates a App',
generate: generateApp,
};
async function generateApp(inputOptions: Options) {
const questions = [
{
type: 'input',
name: 'itemName',
message: 'App name? (e.g. "movie-magic")',
},
];
const options = await inquirer.prompt(questions, inputOptions);
const { itemName } = options;
// --------------------------------------------------------------------------
// Add more options for code generation here
// --------------------------------------------------------------------------
// itemNameKebabCase = movie-magic
options['itemNameKebabCase'] = cc.kebabCase(itemName);
// itemNameCamelCase = movieMagic
options['itemNameCamelCase'] = cc.camelCase(itemName);
// itemNamePascalCase = MovieMagic
options['itemNamePascalCase'] = cc.pascalCase(itemName);
// itemNameCapitalCase = Movie Magic
options['itemNameCapitalCase'] = cc.capitalCase(itemName);
// --------------------------------------------------------------------------
const { itemNameKebabCase } = options;
const srcDir = path.join(__dirname, 'templates');
const dstDir = path.join(process.cwd(), itemNameKebabCase);
console.log();
console.log(`Creating ${itemName}...`);
FileUtils.transformFiles(srcDir, dstDir, options);
console.log();
console.log('Done. Now run:');
console.log();
console.log(` cd ${itemNameKebabCase}`);
console.log(' git init');
console.log(' npm install');
console.log(' git add .');
console.log(' git commit -m "Initial commit"');
console.log();
return Promise.resolve();
}
Create a template
We'll start with a single template file to test out our generator. Create a
templates
folder at plugins/react-starter/src/appGenerator/templates
. Create
a template file called package.json.ejs.t
under this folder with contents as
shown below:
{
"name": "<%= itemNameKebabCase %>",
"description": "<%= itemNameCapitalCase %>",
"version": "0.1.0",
"scripts": {
"build": "react-scripts build",
"eject": "react-scripts eject",
"start": "react-scripts start",
"test": "react-scripts test"
},
"dependencies": {
"react": "^18.1.0",
"react-dom": "^18.1.0",
"web-vitals": "^2.1.4"
},
"devDependencies": {
"@types/node": "^17.0.36",
"@types/react": "^18.0.9",
"@types/react-dom": "^18.0.5",
"react-scripts": "5.0.1",
"typescript": "^4.7.2"
}
}
Rebuild the generator by running the following command in the root directory.
npm run build
# Commit
git add .
git commit -m "Added react-starter plugin"
Load the plugin from our CLI
At this point our plugin is ready. Let's wire it into our CLI so that we can run it from there.
Add the react-starter
plugin as a dependency in the package.json
file of our
react-shaper
CLI.
{
...
"dependencies": {
"@code-shaper/shaper-utils": "*",
"@movie-magic/react-starter": "*",
"inquirer": "^8.2.4",
"inquirer-directory": "^2.2.0",
"yargs": "^17.5.1"
},
...
}
Now load the plugin in react-shaper.ts
. See highlighted lines below.
import {
getPluginChoices,
Options,
Plugin,
PluginUtils,
PluginMap,
} from '@code-shaper/shaper-utils';
import reactStarterPlugin from '@movie-magic/react-starter';
import inquirer from 'inquirer';
// ---------- Plugin Store ----------
// Static plugins are built into the CLI
const staticPlugins: PluginMap = {};
// Dynamic plugins are loaded at runtime
const dynamicPlugins: PluginMap = {};
// ---------- Plugin Registration ----------
function registerStaticPlugin(plugin: Plugin) {
const { id } = plugin;
staticPlugins[id] = plugin;
}
function registerDynamicPlugin(plugin: Plugin) {
const { id } = plugin;
dynamicPlugins[id] = plugin;
}
// ----- Register static plugins here -----
registerStaticPlugin(reactStarterPlugin);
// Register dynamic plugins
const plugins = PluginUtils.getDynamicPlugins();
plugins.forEach(registerDynamicPlugin);
Install dependencies and run a build to make sure everything is set up correctly.
# In the root directory, run:
npm install
# To make sure that everything is set up correctly, run a build
npm run build
# Commit
git add .
git commit -m "Wired react-starter plugin to the CLI"
Run the CLI
The CLI needs to be published to a binary repository (such as npm) before it can
be run. However, we should always make it work locally before publishing it to
the world! This is done using the npm link
command. Let's give it a try.
# Change directory to the react-shaper CLI
cd apps/react-shaper
# Make sure that the dist directory exists.
# It contains the compiled version of react-shaper.
ls -al dist
total 32
drwxr-xr-x 6 naresh staff 192 May 30 18:18 .
drwxr-xr-x 11 naresh staff 352 May 30 18:18 ..
-rw-r--r-- 2 naresh staff 11 May 30 18:18 index.d.ts
-rw-r--r-- 2 naresh staff 1109 May 30 18:18 index.js
-rw-r--r-- 2 naresh staff 209 May 30 18:18 react-shaper.d.ts
-rw-r--r-- 2 naresh staff 2162 May 30 18:18 react-shaper.js
# Symlink to the local npm repository
npm link
# Verify that the link was created
npm list -g
# The following link should appear in the listing.
react-shaper@0.0.1 -> ./../../../../../projects/react-shaper/apps/react-shaper
Now the react-shaper
command can be executed from any directory. Open a new
shell and execute the following commands:
# Change directory to a location where you create projects, e.g.
cd ~/projects
# Run react-shaper
# Note: If you are running in zsh, the following command may simply change
# the directory to react-shaper, instead of running react-shaper. To work
# around this issue, run this command first: unsetopt AUTO_CD
# See details here:
# https://unix.stackexchange.com/questions/126719/how-to-disable-auto-cd-in-zsh-with-oh-my-zsh
react-shaper
? Which plugin would you like to run? React Starter
? Which generator would you like to run? app
? App name? hello-world
Creating hello-world...
package.json
Done. Now run:
cd hello-world
This should create a directory called hello-world
with the following
package.json
file. Note the correct substitutions in name
and description
properties.
{
"name": "hello-world",
"description": "Hello World",
"version": "0.1.0",
"scripts": {
"build": "react-scripts build",
"eject": "react-scripts eject",
"start": "react-scripts start",
"test": "react-scripts test"
},
"dependencies": {
"react": "^18.1.0",
"react-dom": "^18.1.0",
"web-vitals": "^2.1.4"
},
"devDependencies": {
"@types/node": "^17.0.36",
"@types/react": "^18.0.9",
"@types/react-dom": "^18.0.5",
"react-scripts": "5.0.1",
"typescript": "^4.7.2"
}
}
Bypass plugin prompts
As seen above, when react-shaper starts, it asks the user for a plugin and a
generator. For a simple CLI with only one plugin and one generator, these
questions are unnecessary. Let's bypass them by specifying their ids in
react-shaper/index.ts
. Replace the contents of this file with the code below:
import yargs from 'yargs/yargs';
import { reactShaper } from './react-shaper';
async function main() {
// Parse command line
const argv = await yargs().parse(process.argv.slice(2));
const { _, $0: command, ...options } = argv; // eslint-disable-line
// Specify pluginId and generator
const pluginId = '@movie-magic/react-starter';
options['generator'] = 'app';
// Run reactShaper
await reactShaper.run(pluginId, options);
}
main().catch((err) => console.error('Error:', err));
Rebuild react-shaper
by running the following command in the root of the
repository:
npm run build
Now rerun react-shaper
in another shell. Remember to delete the old
hello-world
directory.
# Change directory to a location where you create projects, e.g.
cd ~/projects
# Delete old implementation of hello-world
rm -rf hello-world
# Regenerate hello-world using the new react-shaper
react-shaper
? App name? hello-world
Creating hello-world...
package.json
Done. Now run:
cd hello-world
Note that react-shaper
no longer asks for a plugin and a generator.
Go back to the repo root directory and commit your changes:
# Commit
git add .
git commit -m "Bypassed plugin prompts"
Add more templates (optional)
You have now successfully built a custom CLI. As a next step, you can add more
templates in the templates directory
(plugins/react-starter/src/appGenerator/templates
) to create a full-fledged
starter app. See an example here.
Publish to npm (optional)
You can share your CLI with others by publishing it to npm. For this you will need an account with npm. You will also have to make sure that the name of your CLI package is not taken already. Here are the rough steps to publish to npm:
# Remember to unlink your local react-shaper
npm unlink -g react-shaper
# Publish react-shaper CLI to npm
cd apps/react-shaper
npm publish
# Publish react-starter plugin to npm
cd ../../plugins/react-starter
npm publish
# Install react-shaper from npm
cd ~/projects
npm install -g react-shaper
# Try it out
react-shaper
? App name? hello-world
Creating hello-world...
package.json
Done...