Build a Custom Valentine's Day CLI Scaffold with Plugins Using Lerna and Node.js
This tutorial walks you through creating a modular, plugin‑based CLI scaffold for a Valentine’s Day project, covering monorepo setup with Lerna, core CLI package creation, plugin registration, command handling, inter‑plugin communication with tapable, and deployment considerations.
Background
The article shows how to build a personalized Valentine’s Day CLI scaffold that demonstrates plugin‑based architecture while teaching relevant development techniques.
Prerequisites
You need basic command‑line knowledge and Node.js version >=10. Install yarn if you don’t have it.
Project Directory Structure
.
├── lerna.json
├── package.json
├── packages
│ ├── cli // core CLI scaffold
│ │ ├── api
│ │ │ ├── commandAPI.js
│ │ │ └── share-utils.js
│ │ ├── bin
│ │ │ └── valentine
│ │ ├── commands
│ │ │ └── happy.js
│ │ └── package.json
│ └── confession // example plugin
│ ├── command.config.js
│ └── package.json
├── scripts
│ └── np.js // custom deployment script
└── yarn.lockInitialize the Monorepo
Create an empty folder valentine and run yarn init -y. Ensure Node.js >=10.
Install lerna as a dev dependency: yarn add --dev lerna. Lerna will manage multiple packages in the packages folder.
Add a lerna.json with the following content:
{
"packages": ["packages/*"],
"version": "0.0.1",
"npmClient": "yarn",
"useWorkspaces": true
}Enable Yarn workspaces by adding to the root package.json:
{
"private": true,
"workspaces": ["packages/*"]
}Create the packages directory to hold the core scaffold and plugins.
Set Up the Core CLI Package
Inside packages, create a cli folder and add a package.json:
{
"name": "@o2team/valentine-cli",
"version": "0.0.1",
"main": "index.js",
"license": "MIT",
"bin": { "valentine": "./bin/valentine" },
"scripts": { "test": "echo \"Error: no test specified\" && exit 1" },
"dependencies": {
"chalk": "^4.1.0",
"commander": "^5.1.0",
"tapable": "^1.1.3"
}
}Create cli/bin/valentine, make it executable ( chmod +x ./valentine), and add a simple hello message:
#!/usr/bin/env node
console.log('-------- 情人节快乐 --------')Run ./packages/cli/bin/valentine to see the greeting.
Core Logic – Command Discovery
The CLI must load built‑in commands and plugin commands. The discovery logic lives in api/commandAPI.js and uses two helper files: share-utils.js provides getAllPluginIdOfPackageJson which scans package.json for dependencies matching the regex /^(@o2team\/)?valentine-plugin-/. commandAPI.js exports getAllCommands, injectCommand, and commandComplete. It reads the local commands folder, loads each file, then loads any plugin command config files found under node_modules.
const fs = require('fs');
const path = require('path');
module.exports.getAllCommands = () => {
const cwdFns = [];
const localCwdPath = path.join(__dirname, '..', 'commands');
const localCwdNames = [...fs.readdirSync(localCwdPath)];
localCwdNames.forEach(name => {
const cwdPath = path.join(localCwdPath, name);
cwdFns.push(require(cwdPath));
});
const { getAllPluginIdOfPackageJson } = require('./share-utils');
getAllPluginIdOfPackageJson().forEach(name => {
const command = path.join(process.cwd(), 'node_modules', name, 'command.config.js');
try { const cwd = require(command); cwdFns.push(cwd); }
catch (e) { console.log(`${command} 不存在`); }
});
return cwdFns;
};Utility – Plugin ID Extraction
const fs = require('fs');
const path = require('path');
const pkPluginRE = /^(@o2team\/)?valentine-plugin-/;
exports.pkPluginRE = pkPluginRE;
exports.getAllPluginIdOfPackageJson = () => {
const pkgJsonPath = path.join(process.cwd(), 'package.json');
const deps = {};
const plugins = [];
if (fs.existsSync(pkgJsonPath)) {
const pkg = require(pkgJsonPath);
Object.assign(deps, pkg.devDependencies || {}, pkg.dependencies || {});
Object.keys(deps).forEach(dep => { if (pkPluginRE.test(dep)) plugins.push(dep); });
}
return plugins;
};Built‑in Plugin (happy)
Create cli/commands/happy.js:
module.exports = ({ injectCommand, operateHooks }) => {
const { hooksMap, createHook } = operateHooks;
createHook('happyStartHook');
injectCommand(function({ program, cliConfig }) {
program.command('happy')
.description('情人节祝福')
.action(async () => {
const { name, hobby } = cliConfig;
await hooksMap.happyStartHook.promise();
console.log(`喜欢 ${hobby} 的 ${name}, 祝你情人节快乐~`);
});
});
};Run ./bin/valentine happy to see the output.
External Plugin (confession)
In packages/confession create a package.json whose name matches the plugin regex, e.g. @o2team/valentine-plugin-confession. Then add command.config.js:
module.exports = ({ injectCommand }) => {
injectCommand(function({ program }) {
program.command('love')
.description('情人节表白')
.action(() => { logLove(); });
});
};
function logLove() {
console.log(`
____ __ ____ _ ________ __ ______ __ __
/ _/ / / / __\\ | / /____/ \\/ /__ \\/ / / /
/ / / / / / / / | / /__/ \\ / / / / / /
_/ / / /___/_/_/| |/ /___ / /_/_/ /_/_/
___/ /_____/\\____/ |___/_____/ /_/\\____/\\____/
`);
}Publish both cli and confession to npm, then install them in a new project. Executing ./node_modules/.bin/valentine love runs the confession plugin.
Inter‑Plugin Communication with Tapable
Use tapable to let plugins trigger each other. Define an OperateHooks class ( api/operateHooks.js) that creates AsyncSeriesHook instances, stores them in hooksMap, and provides tapHook and bindHooks methods.
const { AsyncSeriesHook } = require('tapable');
module.exports = class OperateHooks {
constructor() {
this.hooksMap = {};
this.hooksTapList = [];
this.tapHook = this.tapHook.bind(this);
this.bindHooks = this.bindHooks.bind(this);
this.createHook = this.createHook.bind(this);
}
createHook(nameSpace) { this.hooksMap[nameSpace] = new AsyncSeriesHook(); }
tapHook(hookName, eventName, cb) { this.hooksTapList.push({ hookName, eventName, cb }); }
bindHooks() { this.hooksTapList.forEach(({ hookName, eventName, cb }) => {
this.hooksMap[hookName].tapPromise(eventName, async () => { await cb(); });
}); }
};Instantiate it in api/commandAPI.js and expose it. Modify the CLI entry script to pass operateHooks to each plugin’s registration function.
#!/usr/bin/env node
const { injectCommand, getAllCommands, commandComplete, operateHooks } = require('../api/commandAPI');
console.log('-------- 情人节快乐 --------');
getAllCommands().forEach(cwd => { cwd({ injectCommand, operateHooks }); });
commandComplete();In the built‑in happy.js plugin, create a hook happyStartHook. In the external confession/command.config.js, tap that hook to run logLove before the happy command executes.
// happy.js (excerpt)
const { hooksMap, createHook } = operateHooks;
createHook('happyStartHook');
// ... injectCommand as before
// confession/command.config.js (excerpt)
operateHooks.tapHook('happyStartHook', 'love', async () => { logLove(); });
injectCommand(({ program }) => {
program.command('love')
.description('情人节表白')
.action(() => { logLove(); });
});Optimization Notes
Scanning dependencies with a regex can become slow in large monorepos.
When the project is itself a monorepo, the current plugin‑resolution may use the wrong working directory.
Only project‑local plugins are supported; global plugins are not yet handled.
These points are left as exercises for the reader.
Final Result
The scaffold now supports built‑in and external plugins, command registration, and hook‑based communication, producing a personalized Valentine’s Day CLI tool.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Aotu Lab
Aotu Lab, founded in October 2015, is a front-end engineering team serving multi-platform products. The articles in this public account are intended to share and discuss technology, reflecting only the personal views of Aotu Lab members and not the official stance of JD.com Technology.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
