Have you ever run a command like npx create-next-app my-app
or npm init vite@latest
and marveled at how quickly a new project is set up for you? These CLI tools do a lot behind the scenes: they parse your arguments, prompt you for options, copy template files, initialize git, install dependencies, and print out beautiful next steps—all in a matter of seconds.
But how do they actually work? How can you build your own CLI tool that scaffolds projects just as smoothly?
In this blog post, we'll walk through building a modern, modular CLI scaffolder from scratch using TypeScript and popular Node.js libraries.
This blog post will walk you through the architecture and implementation of a modern CLI tool for scaffolding minimal app projects, using TypeScript and popular Node.js libraries. We'll break down the core filescli.ts
, logger.ts
, scaffold.ts
, and utils.ts
—and explain how they work together to deliver a robust developer experience.
Project Overview
Our CLI tool, lershi-minimal-app
, allows users to quickly scaffold a new project with a single command. It supports:
Multiple templates (e.g., default, html)
Interactive prompts (or a
-y
flag to skip them)Automatic git initialization
Dependency installation with the user's choice of package manager
Update notifications
Clean, colored terminal output
File-by-File Breakdown
src/cli.ts
— The Command-Line Interface
This is the entry point for the CLI. It uses commander for argument parsing, enquirer for prompts, update-notifier for update checks, and our custom logger
for output.
Key Features:
Defines CLI arguments and options (project name, template, -y/--yes)
Shows custom help/version output
Handles missing arguments gracefully
Prompts the user for install/git options (unless
-y
is passed)Calls the scaffolding logic and handles post-creation steps
Example:
1program
2
3 .name('lershi-minimal-app')
4
5 .description('A CLI tool to scaffold minimal project structures')
6
7 .usage('<project-name> [options]')
8
9 .version(pkg.version, '-v, --version', 'output the current version')
10
11 .addHelpText('before', ...)
12
13 .addHelpText('after', ...)
14
15 .argument('<project-name>', 'Name of the project')
16
17 .option('-t, --template <template>', 'Project template to use', 'default')
18
19 .option('-y, --yes', 'Skip prompts and use default values')
20
21 .action(async (projectName, options) => { /* ... */ });
Prompting and Actions:
If
-y
is not passed, prompts for install and git options.Uses the
logger
for all output and spinners.Calls
scaffoldProject
to create the project.Optionally runs
git init
and installs dependencies.
Error Handling:
Uses
exitOverride
andconfigureOutput
to show concise, custom error messages for missing arguments, help, and version.
src/logger.ts
— Unified Logging and Spinners
This file wraps [chalk](https://www.npmjs.com/package/chalk) and [ora](https://www.npmjs.com/package/ora) to provide a consistent logging interface for the CLI.
Features:
start(message)
: Starts a spinnersucceed(message)
: Succeeds the spinner with a green messagefail(message)
: Fails the spinner with a red messageinfo(message)
: Prints blue info textwarn(message)
: Prints yellow warnings
error(message)
: Prints red errors
Why use a logger?
Keeps output consistent and easy to update
Centralizes color and spinner logic
Makes the CLI code cleaner
src/scaffold.ts
— Project Scaffolding Logic
This is the core logic for creating a new project. It:
Validates the project name (using
validateProjectName
fromutils.ts
)Copies the selected template directory (e.g.,
templates/default/
ortemplates/html/
) to the new project locationReplaces placeholders (like
${name}
) in all text filesReturns the project path
Key Implementation:
1export async function scaffoldProject(projectName: string, options = {}) {
2
3 await validateProjectName(projectName);
4
5 const projectPath = path.resolve(process.cwd(), projectName);
6
7 const templateName = (options as { template?: string }).template || 'default';
8
9 const templatePath = path.resolve(__dirname, ../templates/${templateName});
10
11 // Copy all template files
12
13 await fs.copy(templatePath, projectPath);
14
15 // Replace placeholders
16
17 const allFiles = walkSync(projectPath);
18
19 for (const file of allFiles) {
20
21 if (isTextFile(file)) {
22
23 let content = await fs.readFile(file, 'utf-8');
24
25 const replaced = content.replace(/\$\{name\}/g, projectName);
26
27 if (replaced !== content) {
28
29 await fs.writeFile(file, replaced);
30
31 }
32
33 }
34
35 }
36
37 return projectPath;
38
39}
Why this approach?
Using a template directory makes it easy to add or update templates without changing code.
Placeholder replacement allows for dynamic project names and customization.
src/utils.ts
— Utility Functions
This file contains helper functions for validation and (optionally) other file operations.
Currently used:
validateProjectName(name: string)
: Ensures the project name is valid and the directory does not already exist.
Example:
1export async function validateProjectName(name: string): Promise<void> {
2
3 if (!name) throw new Error('Project name is required');
4
5 if (!/^[a-z0-9-]+$/.test(name)) {
6
7 throw new Error('Project name can only contain lowercase letters, numbers, and hyphens');
8
9 }
10
11 const projectPath = path.resolve(process.cwd(), name);
12
13 if (await fs.pathExists(projectPath)) {
14
15 throw new ErrorDirectory ${name} already exists);
16
17 }
18
19}
Versioning: Keeping CLI Version in Sync
The Problem
Originally, the CLI tried to read the version from package.json
at runtime using:
1const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'));
However, after building and publishing, this caused errors like:
Error: ENOENT: no such file or directory, open '.../dist/package.json'
This is because the built CLI runs from the dist/
directory, and package.json
is not always present or in the expected location in the published npm package.
The Solution: Build-Time Version Injection
To solve this, we generate a src/version.ts
file at build time using a script scripts/update-version.cjs
). This script reads the version from package.json
and writes it to src/version.ts
:
1export const version = "0.7.0"
The CLI then imports this version directly:
1import { version } from './version.js';
Why the .js
Extension?
When using ESM (with "type": "module"
in package.json
), Node.js requires explicit file extensions in imports. If you write import { version } from './version'
, Node will NOT find version.js
and you'll get:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../dist/version' imported from .../dist/cli.js
By using import { version } from './version.js';
, the import works correctly in both development and after publishing to npm.
Summary
No more runtime file reads or path issues.
The version is always in sync with
package.json
.The CLI works reliably with npx, local, and global installs.
Putting It All Together
When a user runs:
1npx lershi-minimal-app my-app -t html
The CLI parses the arguments and options.
If not using
-y
, it prompts for install and git options.It validates the project name.
It copies the template files and replaces placeholders.
It optionally runs
git init
and installs dependencies (if the template offers git init).It prints next steps and links to npm/GitHub.
Extending the CLI
Add more templates: Just add a new folder under
templates/
.Add more prompts: Use
enquirer
to ask for licenses, features, etc.Add more logging: Use the
logger
for all output.Add more validation: Extend
utils.ts
as needed.
Conclusion
This architecture keeps your CLI modular, testable, and easy to extend. By separating concerns (CLI, logging, scaffolding, utilities), you can add features or templates with minimal changes to the core logic.
Github code: https://github.com/lershi-devlabs/lershi-minimal-app
Happy scaffolding! 🙏