Typed Options Interface
// src/types.ts
export interface CreateOptions {
template: string
directory: string
install: boolean
git: boolean
}
export interface ProjectTemplate {
name: string
description: string
files: TemplateFile[]
dependencies: Record<string, string>
devDependencies: Record<string, string>
}
export interface TemplateFile {
path: string
content: string | ((vars: TemplateVars) => string)
}
export interface TemplateVars {
projectName: string
authorName: string
description: string
year: number
}
Interactive Prompts with Inquirer
// src/prompts.ts
import inquirer from 'inquirer'
import type { TemplateVars } from './types.js'
export async function promptProjectDetails(
projectName?: string
): Promise<TemplateVars & { template: string; initGit: boolean; runInstall: boolean }> {
const answers = await inquirer.prompt([
{
type: 'input',
name: 'projectName',
message: 'Project name:',
default: projectName ?? 'my-app',
validate: (input: string) =>
/^[a-z0-9-]+$/.test(input) || 'Use lowercase letters, numbers, and hyphens only',
},
{
type: 'input',
name: 'description',
message: 'Project description:',
default: 'A new Node.js project',
},
{
type: 'input',
name: 'authorName',
message: 'Author name:',
default: process.env.GIT_AUTHOR_NAME ?? 'Your Name',
},
{
type: 'list',
name: 'template',
message: 'Choose a template:',
choices: [
{ name: 'Minimal — TypeScript + ESLint', value: 'minimal' },
{ name: 'API — Express + Zod + Prisma', value: 'api' },
{ name: 'CLI — Commander + Inquirer', value: 'cli' },
{ name: 'Full-stack — Next.js 16', value: 'nextjs' },
],
},
{
type: 'confirm',
name: 'initGit',
message: 'Initialize git repository?',
default: true,
},
{
type: 'confirm',
name: 'runInstall',
message: 'Run npm install after scaffolding?',
default: true,
},
])
return { ...answers, year: new Date().getFullYear() }
}
The Create Command with Progress Indicators
// src/commands/create.ts
import path from 'path'
import ora from 'ora'
import chalk from 'chalk'
import fs from 'fs-extra'
import { execSync } from 'child_process'
import { promptProjectDetails } from '../prompts.js'
import { getTemplate } from '../templates/index.js'
import type { CreateOptions } from '../types.js'
export async function createProject(
nameArg: string | undefined,
options: CreateOptions
): Promise<void> {
console.log(chalk.bold.cyan('
create-my-app v1.0.0
'))
// Collect details interactively if not provided via flags
const details = await promptProjectDetails(nameArg)
const targetDir = path.resolve(options.directory, details.projectName)
if (await fs.pathExists(targetDir)) {
console.error(chalk.red(`Error: Directory "${details.projectName}" already exists.`))
process.exit(1)
}
const template = getTemplate(details.template)
if (!template) {
console.error(chalk.red(`Error: Template "${details.template}" not found.`))
process.exit(1)
}
// Step 1: Scaffold files
const scaffoldSpinner = ora('Scaffolding project files...').start()
try {
await fs.ensureDir(targetDir)
for (const file of template.files) {
const content =
typeof file.content === 'function' ? file.content(details) : file.content
await fs.outputFile(path.join(targetDir, file.path), content)
}
// Write package.json
await fs.writeJson(
path.join(targetDir, 'package.json'),
buildPackageJson(details.projectName, details, template),
{ spaces: 2 }
)
scaffoldSpinner.succeed(chalk.green('Project files created'))
} catch (error) {
scaffoldSpinner.fail('Failed to scaffold files')
throw error
}
// Step 2: npm install
if (options.install) {
const installSpinner = ora('Installing dependencies...').start()
try {
execSync('npm install', { cwd: targetDir, stdio: 'ignore' })
installSpinner.succeed(chalk.green('Dependencies installed'))
} catch {
installSpinner.fail('npm install failed — run it manually')
}
}
// Step 3: git init
if (options.git || details.initGit) {
const gitSpinner = ora('Initializing git repository...').start()
try {
execSync('git init && git add -A && git commit -m "Initial commit"', {
cwd: targetDir,
stdio: 'ignore',
})
gitSpinner.succeed(chalk.green('Git repository initialized'))
} catch {
gitSpinner.fail('git init failed — run it manually')
}
}
// Success summary
console.log(`
${chalk.bold.green('Project created successfully!')}
${chalk.cyan('Next steps:')}
${chalk.white(`cd ${details.projectName}`)}
${chalk.white('npm run dev')}
${chalk.gray('Happy building!')}
`)
}
function buildPackageJson(
name: string,
vars: { description: string; authorName: string },
template: { dependencies: Record<string, string>; devDependencies: Record<string, string> }
) {
return {
name,
version: '0.1.0',
description: vars.description,
author: vars.authorName,
license: 'MIT',
type: 'module',
scripts: { dev: 'tsx src/index.ts', build: 'tsc', start: 'node dist/index.js' },
dependencies: template.dependencies,
devDependencies: {
typescript: '^5.4.0',
tsx: '^4.7.0',
'@types/node': '^20.0.0',
...template.devDependencies,
},
}
}
File I/O Helper — Safe Read/Write
// src/utils/file-io.ts
import fs from 'fs-extra'
import path from 'path'
export async function readJsonSafe<T>(filePath: string): Promise<T | null> {
try {
return await fs.readJson(filePath)
} catch {
return null
}
}
export async function writeJsonSafe(filePath: string, data: unknown): Promise<boolean> {
try {
await fs.ensureDir(path.dirname(filePath))
await fs.writeJson(filePath, data, { spaces: 2 })
return true
} catch {
return false
}
}
export async function copyTemplate(
templateDir: string,
destDir: string,
replacements: Record<string, string>
): Promise<void> {
await fs.copy(templateDir, destDir)
// Walk all files and apply replacements
const files = await walkDir(destDir)
for (const file of files) {
if (isBinaryFile(file)) continue
let content = await fs.readFile(file, 'utf-8')
for (const [placeholder, value] of Object.entries(replacements)) {
content = content.replaceAll(placeholder, value)
}
await fs.writeFile(file, content)
}
}
async function walkDir(dir: string): Promise<string[]> {
const entries = await fs.readdir(dir, { withFileTypes: true })
const files = await Promise.all(
entries.map((entry) => {
const fullPath = path.join(dir, entry.name)
return entry.isDirectory() ? walkDir(fullPath) : [fullPath]
})
)
return files.flat()
}
function isBinaryFile(filePath: string): boolean {
const binaryExts = ['.png', '.jpg', '.gif', '.ico', '.woff', '.woff2', '.ttf', '.zip']
return binaryExts.includes(path.extname(filePath).toLowerCase())
}
Publishing to npm
# 1. Build TypeScript to dist/
npm run build
# 2. Add shebang to compiled entry point (if not already present)
# dist/index.js should start with: #!/usr/bin/env node
# 3. Test locally before publishing
npm link
create-my-app --help
create-my-app create test-project
# 4. Ensure your package.json has the right fields
# "main": "dist/index.js", "files": ["dist"], "bin": {"create-my-app": "dist/index.js"}
# 5. Login and publish
npm login
npm publish --access public
# 6. Test the published package
npx create-my-app create hello-world
People Also Ask
Should I use Commander.js or yargs for building a CLI tool?
Both are solid choices. Commander.js is slightly lighter and has a cleaner TypeScript API in version 12+. Yargs has more built-in features (completion, middleware) but a heavier bundle. For most CLIs, Commander.js is the better starting point — you can add complexity as needed. Avoid building your own argument parser; both libraries handle edge cases (quoted arguments, boolean flags, negation) that are tedious to implement correctly.
How do I make my CLI tool work on both Windows and macOS/Linux?
Key cross-platform concerns: use path.join() instead of string concatenation for file paths (forward vs backslash), use os.homedir() instead of ~ for the home directory, avoid shell-specific syntax in execSync commands, and use cross-env for environment variables in npm scripts. Test on Windows with WSL if you do not have a Windows machine handy.
How do I store user configuration between CLI runs?
Use the conf or configstore npm packages — they handle the platform-specific config directory (~/.config/your-app on Linux/macOS, %APPDATA%your-app on Windows) transparently. For simple cases, write a JSON file to path.join(os.homedir(), '.your-app', 'config.json') using fs-extra.
Comments · 0
No comments yet. Be the first to share your thoughts.