Building a Modern CLI Scaffolder from Scratch

Development

May 26, 2025 montek.dev

0 views

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) => { /* ... */ });
typescript

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 and configureOutput 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 spinner

  • succeed(message): Succeeds the spinner with a green message

  • fail(message): Fails the spinner with a red message

  • info(message): Prints blue info text

  • warn(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 from utils.ts)

  • Copies the selected template directory (e.g., templates/default/ or templates/html/) to the new project location

  • Replaces placeholders (like ${name}) in all text files

  • Returns 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}
typescript

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}
typescript

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'));
typescript

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"
typescript

The CLI then imports this version directly:

1import { version } from './version.js';
typescript

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
shell
  1. The CLI parses the arguments and options.

  2. If not using -y, it prompts for install and git options.

  3. It validates the project name.

  4. It copies the template files and replaces placeholders.

  5. It optionally runs git init and installs dependencies (if the template offers git init).

  6. 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! 🙏


May 26, 2025 montek.dev

0 views

Comments

Join the discussion! Share your thoughts and engage with other readers.

Leave comment