Skip to content

Instantly share code, notes, and snippets.

@jalateras
Last active June 18, 2025 04:43
Show Gist options
  • Save jalateras/2ce3c1345c98f115ed76478248507a85 to your computer and use it in GitHub Desktop.
Save jalateras/2ce3c1345c98f115ed76478248507a85 to your computer and use it in GitHub Desktop.
This shell script bootstraps a modern TypeScript-based Command Line Interface (CLI) application with a full development and testing toolchain. It sets up a ready-to-use project that supports ESM modules, includes robust linting, testing, coverage reporting, and build tooling, and integrates Git hooks for quality enforcement.
#!/usr/bin/env bash
# ------------------------------------------------------------------
# bootstrap-ts-cli.sh — create a TypeScript CLI scaffold
# stack: npm · TypeScript · tsx · tsup · Vitest · Commander · Husky
# ------------------------------------------------------------------
set -euo pipefail
# Error handling
trap 'error "❌ An error occurred. Exiting..."' ERR
# Colored output functions
info() { echo -e "\033[0;34m$1\033[0m"; }
success() { echo -e "\033[0;32m$1\033[0m"; }
error() { echo -e "\033[0;31m$1\033[0m" >&2; }
# ---- 1. basic argument handling ----------------------------------
if [[ $# -ne 1 ]]; then
error "Usage: $0 <project-name>"
exit 1
fi
APP="$1"
# Validate project name
if [[ ! "$APP" =~ ^[a-z0-9-]+$ ]]; then
error "❌ Project name should only contain lowercase letters, numbers, and hyphens"
exit 1
fi
# Prevent accidental overwrite
if [[ -e "$APP" ]]; then
error "❌ Directory '$APP' already exists. Please choose another name."
exit 1
fi
info "🚀 Creating TypeScript CLI project: $APP"
# ---- 2. create repo skeleton -------------------------------------
mkdir -p "$APP"/{src/{commands/__tests__,lib,types},.husky,.github/workflows}
cd "$APP"
git init --initial-branch=main -q
# ---- 3. initialise package ---------------------------------------
info "📦 Initializing package manager..."
npm init -y
# mark project as ESM
npm pkg set type=module
# add bin field for CLI
npm pkg set bin.${APP}="./dist/cli.js"
# ---- 4. install runtime + dev deps --------------------------------
info "📥 Installing dependencies..."
npm install commander chalk read-pkg
npm install --save-dev \
typescript tsx tsup @types/node \
vitest @vitest/coverage-v8 \
eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin \
prettier eslint-config-prettier \
husky lint-staged \
@commitlint/cli @commitlint/config-conventional \
rimraf
# ---- 5. configure package.json scripts & hooks --------------------
npm pkg set scripts.dev="tsx watch src/cli.ts"
npm pkg set scripts.typecheck="tsc --noEmit"
npm pkg set scripts.lint="eslint src --ext .ts"
npm pkg set scripts.format="prettier --write 'src/**/*.{ts,json}'"
npm pkg set scripts.test="vitest"
npm pkg set scripts.test:coverage="vitest --coverage"
npm pkg set scripts.clean="rimraf dist coverage"
npm pkg set scripts.build="tsup"
npm pkg set scripts.start="node dist/cli.js"
npm pkg set scripts.prepare="husky"
# lint-staged runs ESLint and Prettier on staged files
# Use Node.js to modify package.json directly
node -e '
const fs = require("fs");
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
pkg["lint-staged"] = {
"*.ts": ["eslint --fix", "prettier --write"],
"*.json": "prettier --write"
};
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n");
'
# ---- 6. baseline config files ------------------------------------
info "📝 Creating configuration files..."
# Enhanced TypeScript configuration
cat > tsconfig.json <<'JSON'
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"sourceMap": true,
"declaration": true,
"declarationMap": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*"],
"exclude": ["**/*.test.ts", "**/*.spec.ts", "dist", "node_modules"]
}
JSON
# tsup configuration
cat > tsup.config.ts <<'TS'
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/cli.ts'],
format: ['esm', 'cjs'],
dts: true,
sourcemap: true,
clean: true,
minify: true,
splitting: false,
shims: true,
banner: {
js: '#!/usr/bin/env node',
},
onSuccess: async () => {
await import('fs').then(fs =>
fs.promises.copyFile('package.json', 'dist/package.json')
);
},
});
TS
# Vitest configuration
cat > vitest.config.ts <<'TS'
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'dist/', '**/*.config.ts']
}
}
});
TS
# ESLint configuration
cat > eslint.config.js <<'JS'
import tseslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import eslintConfigPrettier from 'eslint-config-prettier';
export default [
{
ignores: ['dist/**', '*.cjs', '*.config.ts', 'eslint.config.js'],
},
{
files: ['src/**/*.ts'],
ignores: ['src/**/*.test.ts', 'src/**/*.spec.ts'],
languageOptions: {
parser: tsParser,
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: import.meta.dirname,
},
ecmaVersion: 2022,
sourceType: 'module',
},
plugins: {
'@typescript-eslint': tseslint,
},
rules: {
...tseslint.configs.recommended.rules,
...tseslint.configs['recommended-requiring-type-checking'].rules,
},
},
{
files: ['src/**/*.test.ts', 'src/**/*.spec.ts'],
languageOptions: {
parser: tsParser,
ecmaVersion: 2022,
sourceType: 'module',
},
plugins: {
'@typescript-eslint': tseslint,
},
rules: {
...tseslint.configs.recommended.rules,
},
},
eslintConfigPrettier,
];
JS
# Prettier configuration
cat > .prettierrc.json <<'JSON'
{
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}
JSON
# Enhanced .gitignore
cat > .gitignore <<'EOF'
node_modules
dist
coverage
.DS_Store
.env*
*.log
.vscode
.idea
*.swp
*.swo
.nyc_output
tmp
*.tsbuildinfo
EOF
# Commitlint configuration
cat > commitlint.config.cjs <<'CJS'
module.exports = { extends: ['@commitlint/config-conventional'] };
CJS
# Node version file
cat > .nvmrc <<'EOF'
22
EOF
# ---- 7. README.md ------------------------------------------------
cat > README.md <<EOF
# ${APP}
A TypeScript CLI application bootstrapped with modern tooling.
## 🚀 Features
- **TypeScript** - Type-safe development
- **Commander.js** - Robust CLI framework
- **tsup** - Lightning-fast bundling
- **Vitest** - Modern testing framework
- **ESLint & Prettier** - Code quality and formatting
- **Husky & lint-staged** - Git hooks for quality control
## 📦 Installation
\`\`\`bash
# Install dependencies
npm install
# Build the CLI
npm run build
# Link for local development
npm link
\`\`\`
## 🛠️ Development
\`\`\`bash
npm run dev # Watch mode with tsx
npm test # Run tests with Vitest
npm run test:coverage # Run tests with coverage
npm run lint # Lint with ESLint
npm run format # Format with Prettier
npm run typecheck # Type checking
npm run build # Build for production
\`\`\`
## 📖 Usage
After building:
\`\`\`bash
# Check version
node dist/cli.js version
node dist/cli.js version --json
# Built-in help
node dist/cli.js --help
\`\`\`
After linking:
\`\`\`bash
${APP} version --json
${APP} --help
\`\`\`
## 🏗️ Project Structure
\`\`\`
${APP}/
├── src/
│ ├── commands/ # CLI commands
│ │ └── __tests__/ # Command tests
│ ├── lib/ # Shared utilities
│ ├── types/ # TypeScript types
│ └── cli.ts # Entry point
├── dist/ # Compiled output
├── coverage/ # Test coverage reports
└── package.json
\`\`\`
## 📄 License
MIT
EOF
# ---- 8. GitHub Actions CI workflow -------------------------------
cat > .github/workflows/ci.yml <<'YAML'
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Type check
run: npm run typecheck
- name: Test
run: npm run test:coverage
- name: Build
run: npm run build
- name: Test CLI
run: node dist/cli.js version
YAML
# ---- 9. Makefile -------------------------------------------------
cat > Makefile <<'MAKE'
SHELL := bash
.ONESHELL:
.SHELLFLAGS := -eu -o pipefail -c
PKG_MGR ?= npm
.PHONY: help install dev lint typecheck test build clean format start ci
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
install: ## Install dependencies
$(PKG_MGR) ci
lint: ## Run ESLint
$(PKG_MGR) run lint
typecheck: ## Run TypeScript type checking
$(PKG_MGR) run typecheck
test: ## Run tests
$(PKG_MGR) test
test-coverage: ## Run tests with coverage
$(PKG_MGR) run test:coverage
format: ## Format code with Prettier
$(PKG_MGR) run format
clean: ## Clean build artifacts
$(PKG_MGR) run clean
build: clean ## Build for production
$(PKG_MGR) run build
chmod +x dist/cli.js
ci: ## Run CI pipeline locally
@echo "Running CI pipeline..."
make lint
make typecheck
make test-coverage
make build
@echo "✅ CI pipeline passed!"
# Wrapper to exec any sub-command: e.g., make run version
run: build ## Run CLI commands (usage: make run <command>)
node dist/cli.js $(filter-out $@,$(MAKECMDGOALS))
# Prevent make from interpreting extra args as targets
%:
@:
MAKE
# ---- 10. Husky & Commitlint hooks --------------------------------
info "🪝 Setting up Git hooks..."
# Initialize Husky (modern way)
npx husky init
# Create pre-commit hook
cat > .husky/pre-commit <<'HOOK'
npx lint-staged
HOOK
# Create commit-msg hook
cat > .husky/commit-msg <<'HOOK'
npx commitlint --edit $1
HOOK
# Make hooks executable
chmod +x .husky/pre-commit
chmod +x .husky/commit-msg
# ---- 11. source files --------------------------------------------
info "📄 Creating source files..."
# Simple CLI entry point with version command
cat > src/cli.ts <<'TS'
import { Command } from 'commander';
import chalk from 'chalk';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { versionCommand } from './commands/version.js';
interface PackageJson {
name: string;
version: string;
description?: string;
}
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf-8')) as PackageJson;
const program = new Command()
.name(pkg.name)
.description(pkg.description ?? 'A TypeScript CLI application')
.version(pkg.version)
.addHelpText(
'after',
`\n${chalk.gray('For more information, visit: https://github.com/your-username/' + pkg.name)}`,
)
.addCommand(versionCommand); // Version command
// Global error handling
process.on('unhandledRejection', (reason: Error) => {
console.error(chalk.red('Unhandled rejection:'), reason.message);
process.exit(1);
});
process.on('uncaughtException', (error: Error) => {
console.error(chalk.red('Uncaught exception:'), error.message);
process.exit(1);
});
// Parse CLI arguments
program.parseAsync(process.argv).catch((err: Error) => {
console.error(chalk.red('Error:'), err.message);
process.exit(1);
});
TS
# Enhanced version command (sync)
cat > src/commands/version.ts <<'TS'
import { Command } from 'commander';
import chalk from 'chalk';
import { readPackage } from 'read-pkg';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
interface PackageJson {
name: string;
version: string;
description?: string;
author?: string | { name: string; email?: string };
license?: string;
}
// APPROACH 1: Use read-pkg's built-in search (RECOMMENDED)
async function getPackageAutoSearch(): Promise<PackageJson> {
try {
// read-pkg automatically searches up the directory tree from cwd
const packageJson = await readPackage();
return packageJson;
} catch (error) {
throw new Error(`Could not find package.json: ${error instanceof Error ? error.message : String(error)}`);
}
}
// APPROACH 2: Read from a specific, known location
function getPackageFromBundled(): PackageJson {
try {
const __dirname = dirname(fileURLToPath(import.meta.url));
// If you bundle package.json with your CLI distribution
const packagePath = resolve(__dirname, 'package.json');
const packageJson = JSON.parse(readFileSync(packagePath, 'utf8')) as PackageJson;
return packageJson;
} catch (error) {
throw new Error(`Could not read bundled package.json: ${error instanceof Error ? error.message : String(error)}`);
}
}
// APPROACH 3: Embed version info at build time (BEST for distributed CLIs)
const EMBEDDED_VERSION = process.env.CLI_VERSION || '0.0.0';
const EMBEDDED_NAME = process.env.CLI_NAME || 'unknown-cli';
function getEmbeddedPackageInfo(): PackageJson {
return {
name: EMBEDDED_NAME,
version: EMBEDDED_VERSION,
description: process.env.CLI_DESCRIPTION,
author: process.env.CLI_AUTHOR,
license: process.env.CLI_LICENSE,
};
}
// APPROACH 4: Use import.meta.resolve (Node 20.6+)
function getPackageViaImportMeta(): PackageJson {
try {
// This resolves relative to the current module
const packageUrl = import.meta.resolve('./package.json');
const packagePath = fileURLToPath(packageUrl);
const packageJson = JSON.parse(readFileSync(packagePath, 'utf8')) as PackageJson;
return packageJson;
} catch (error) {
throw new Error(`Could not resolve package.json via import.meta: ${error instanceof Error ? error.message : String(error)}`);
}
}
function formatAuthor(author?: string | { name: string; email?: string }): string | undefined {
if (!author) return undefined;
if (typeof author === 'string') {
return author;
}
return author.email ? `${author.name} <${author.email}>` : author.name;
}
export const versionCommand = new Command('version')
.description('Show CLI version and metadata')
.option('-j, --json', 'Output in JSON format')
.option('--method <method>', 'Method to use: auto, bundled, embedded, import-meta', 'auto')
.action(async (options: { json?: boolean; method?: string }) => {
try {
let pkg: PackageJson;
switch (options.method) {
case 'bundled':
pkg = getPackageFromBundled();
break;
case 'embedded':
pkg = getEmbeddedPackageInfo();
break;
case 'import-meta':
pkg = getPackageViaImportMeta();
break;
case 'auto':
default:
pkg = await getPackageAutoSearch();
break;
}
if (options.json) {
console.log(JSON.stringify(pkg, null, 2));
return;
}
console.log(chalk.bold.blue(`${pkg.name} v${pkg.version}`));
const metadata: Array<[string, string | undefined]> = [
['Description', pkg.description],
['Author', formatAuthor(pkg.author)],
['License', pkg.license],
];
metadata.forEach(([key, value]) => {
if (value) {
console.log(`${chalk.gray(key + ':')} ${value}`);
}
});
} catch (error) {
console.error(chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error');
process.exit(1);
}
});
TS
# Sample test file
cat > src/commands/__tests__/version.test.ts <<'TS'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { versionCommand } from '../version.js';
describe('version command', () => {
let consoleLogSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
consoleLogSpy.mockRestore();
});
it('should have correct command name', () => {
expect(versionCommand.name()).toBe('version');
});
it('should have a description', () => {
expect(versionCommand.description()).toBeTruthy();
});
it('should display version information', async () => {
await versionCommand.parseAsync(['node', 'test']);
expect(consoleLogSpy).toHaveBeenCalled();
});
it('should output JSON when --json flag is used', async () => {
await versionCommand.parseAsync(['node', 'test', '--json']);
expect(consoleLogSpy).toHaveBeenCalled();
const output = consoleLogSpy.mock.calls[0][0];
expect(() => JSON.parse(output)).not.toThrow();
});
});
TS
# Basic type definitions
cat > src/types/index.ts <<'TS'
// CLI configuration types
export interface CliConfig {
verbose?: boolean;
quiet?: boolean;
debug?: boolean;
}
// Command result types
export interface CommandResult<T = unknown> {
success: boolean;
message?: string;
data?: T;
error?: Error;
}
// Command options base interface
export interface BaseCommandOptions {
verbose?: boolean;
quiet?: boolean;
}
TS
# Simple logger utility
cat > src/lib/logger.ts <<'TS'
import chalk from 'chalk';
export interface LoggerOptions {
silent?: boolean;
timestamps?: boolean;
}
export class Logger {
private silent: boolean;
private timestamps: boolean;
constructor(options: LoggerOptions = {}) {
this.silent = options.silent ?? false;
this.timestamps = options.timestamps ?? false;
}
private formatMessage(message: string): string {
if (this.timestamps) {
const timestamp = new Date().toISOString();
return `[${timestamp}] ${message}`;
}
return message;
}
info(message: string): void {
if (!this.silent) {
console.log(chalk.blue('ℹ'), this.formatMessage(message));
}
}
success(message: string): void {
if (!this.silent) {
console.log(chalk.green('✔'), this.formatMessage(message));
}
}
warning(message: string): void {
if (!this.silent) {
console.warn(chalk.yellow('⚠'), this.formatMessage(message));
}
}
error(message: string): void {
console.error(chalk.red('✖'), this.formatMessage(message));
}
debug(message: string): void {
if (process.env.DEBUG) {
console.log(chalk.gray('●'), this.formatMessage(message));
}
}
}
export const logger = new Logger();
TS
# Create command base classes for better organization
cat > src/lib/command-base.ts <<'TS'
import { Command } from 'commander';
import { logger } from './logger.js';
import type { BaseCommandOptions } from '../types/index.js';
export abstract class BaseCommand {
protected command: Command;
constructor(name: string, description: string) {
this.command = new Command(name).description(description);
this.setupOptions();
this.setupAction();
}
protected setupOptions(): void {
// Base options that all commands share
this.command
.option('-v, --verbose', 'Enable verbose output')
.option('-q, --quiet', 'Suppress non-error output');
}
protected abstract setupAction(): void;
protected createLogger(): typeof logger {
return logger;
}
public getCommand(): Command {
return this.command;
}
}
// For synchronous commands
export abstract class SyncCommand<
T extends BaseCommandOptions = BaseCommandOptions,
> extends BaseCommand {
protected abstract execute(args: unknown, options: T): void;
protected setupAction(): void {
this.command.action((args: unknown, options: T) => {
try {
this.execute(args, options);
} catch (error) {
logger.error(error instanceof Error ? error.message : 'Unknown error');
process.exit(1);
}
});
}
}
// For asynchronous commands
export abstract class AsyncCommand<
T extends BaseCommandOptions = BaseCommandOptions,
> extends BaseCommand {
protected abstract execute(args: unknown, options: T): Promise<void>;
protected setupAction(): void {
this.command.action(async (args: unknown, options: T) => {
try {
await this.execute(args, options);
} catch (error) {
logger.error(error instanceof Error ? error.message : 'Unknown error');
process.exit(1);
}
});
}
}
TS
chmod +x src/cli.ts
# ---- 12. first commit --------------------------------------------
info "📸 Creating initial commit..."
git add .
git commit -qm "chore: bootstrap TypeScript CLI scaffold with modern tooling"
# ---- 13. final instructions --------------------------------------
success "
✅ Project '$APP' successfully created!
📂 Project structure:
$APP/
├── src/ # Source files
├── dist/ # Build output
├── coverage/ # Test coverage
└── .github/ # CI workflows
🚀 Quick start:
cd $APP
make help # Show all available commands
npm run dev # Start development mode
npm test # Run tests"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment