Writing CLI Commands
Introduction
What is a CLI Command?
A CLI command is a server-side administrative tool that runs directly on the server with full access to services and the database. Commands are used for tasks like creating users, seeding data, running migrations, or performing maintenance operations - similar to Mattermost CLI or GitLab Rails console.
How Commands Work
Commands bypass the HTTP layer entirely and interact directly with services:
- Command Execution - You run a command via the CLI entry point
- Service Injection - NestJS injects services directly into the command
- Direct Access - Command calls service methods without auth, HTTP, or DTOs
- Output - Results are printed to stdout/stderr with proper exit codes
Example: User Creation Flow
1. User runs: pnpm run cli user:create"
2. CLI bootstraps NestJS application context
3. UserCreateCommand is instantiated with UserService injected
4. Command prompts for password interactively
5. Command calls userService.createOne() directly
6. Success/error message printed to terminal
7. Process exits with code 0 (success) or 1 (error)
Getting Started
Command File Structure
Commands live in a commands/ folder within each domain module:
backend/src/
├── cli/
│ ├── cli.module.ts # Imports all command classes
│ └── cli.ts # CLI entry point
├── v1/
│ ├── user/
│ │ ├── commands/
│ │ │ ├── user-create.ts
│ │ │ └── user-reset-password.ts
│ │ ├── dto/
│ │ ├── entities/
│ │ ├── user.service.ts
│ │ ├── user.controller.ts
│ │ └── user.module.ts
│ ├── user-integration/
│ │ ├── commands/
│ │ │ └── user-integration-create-hello-world.ts
│ │ └── ...
└── app.module.ts
Step 1: Install Dependencies
First-time setup only. These packages are likely already installed:
pnpm add nest-commander @inquirer/prompts
pnpm add -D @types/inquirer
Step 2: Define Your Command Class
Create a new file following the naming convention:
File naming: {module}-{action}-{detail}.ts
user-create.tsuser-reset-password.tsuser-integration-create-hello-world.ts
Class naming: Match the file name in PascalCase
UserCreateCommandUserResetPasswordCommandUserIntegrationCreateHelloWorldCommand
Command naming: Use colon separator (can be shorter than filename)
user:createuser:reset-passwordintegration:create-hello-world
// src/v1/user/commands/user-create.ts
import { Command, CommandRunner, Option } from 'nest-commander';
import { UserService } from '../user.service';
@Command({
name: 'user:create',
description: 'Create a new user',
})
export class UserCreateCommand extends CommandRunner {
constructor(private readonly userService: UserService) {
super();
}
async run(inputs: string[], options: Record<string, any>): Promise<void> {
const { email, name } = options;
try {
const user = await this.userService.createOne({
email,
name: name || '',
password: 'temporary-password',
});
console.log('✓ User created successfully');
console.log(` ID: ${user.id}`);
console.log(` Email: ${user.email}`);
console.log(` Name: ${user.name}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`✗ Failed to create user: ${message}`);
process.exit(1);
}
}
@Option({
flags: '-e, --email <email>',
description: 'User email address',
required: true,
})
parseEmail(val: string): string {
return val;
}
@Option({
flags: '-n, --name <name>',
description: 'User name',
required: false,
})
parseName(val: string): string {
return val;
}
}
Step 3: Add Options and Prompts
Command-Line Options:
Use @Option() decorator for flags and arguments:
@Option({
flags: '-e, --email <email>',
description: 'User email address',
required: true,
})
parseEmail(val: string): string {
return val;
}
Interactive Prompts:
Use @inquirer/prompts for sensitive data like passwords:
import { password } from '@inquirer/prompts';
async run(inputs: string[], options: Record<string, any>): Promise<void> {
const { email, name } = options;
// Prompt for password with masking (hidden input like Linux)
const passwordValue = await password({
message: 'Password:',
mask: '*',
});
if (!passwordValue || passwordValue.trim() === '') {
console.error('✗ Password cannot be empty');
process.exit(1);
}
// Use the password...
const user = await this.userService.createOne({
email,
name,
password: passwordValue,
});
}
Step 4: Register in CLI Module
The CLI module imports command classes and sets up the database connection.
Important: Don't import feature modules (like UserModule). Import services and entities directly to avoid unnecessary dependencies like HTTP controllers and auth guards.
// src/cli/cli.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SnakeNamingStrategy } from 'typeorm-naming-strategies';
import { UserCreateCommand } from '../v1/user/commands/user-create';
import { UserEntity } from '../v1/user/entities/user.entity';
import { UserService } from '../v1/user/user.service';
import {
POSTGRES_URL,
POSTGRES_PORT,
POSTGRES_USERNAME,
POSTGRES_PASSWORD,
POSTGRES_DB,
} from '../common/config/secrets';
@Module({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: POSTGRES_URL || 'localhost',
port: parseInt(POSTGRES_PORT) || 5432,
username: POSTGRES_USERNAME || 'postgres',
password: POSTGRES_PASSWORD || 'postgres',
database: POSTGRES_DB || 'yew',
autoLoadEntities: true,
synchronize: false,
logging: process.env.NODE_ENV === 'development',
namingStrategy: new SnakeNamingStrategy(),
}),
TypeOrmModule.forFeature([UserEntity]),
],
providers: [UserService, UserCreateCommand],
})
export class CliModule {}
CLI Entry Point - src/cli/cli.ts:
Load environment variables first, then bootstrap the CLI:
import * as dotenv from 'dotenv';
dotenv.config();
import { CommandFactory } from 'nest-commander';
import { CliModule } from './cli.module';
async function bootstrap() {
try {
await CommandFactory.run(CliModule, ['log', 'error', 'warn', 'debug']);
} catch (error) {
console.error('Bootstrap error:', error);
process.exit(1);
}
}
bootstrap().catch((error) => {
console.error('Unhandled bootstrap error:', error);
process.exit(1);
});
Package.json Script:
{
"scripts": {
"cli": "ts-node -r tsconfig-paths/register src/cli/cli.ts"
}
}
Step 5: Run Your Command
# With required options
pnpm run cli user:create --email="test@example.com" --name="Test User"
# View help
pnpm run cli user:create --help
# List all commands
pnpm run cli --help
Core Concepts
Command Lifecycle
1. CLI Entry Point (cli.ts)
└─> Loads environment variables
└─> Bootstraps NestJS context with CliModule
2. Command Discovery
└─> nest-commander scans @Command decorators
└─> Builds command registry
3. Command Execution
└─> Parses CLI arguments
└─> Validates options
└─> Injects services via constructor
└─> Calls run() method
4. Service Interaction
└─> Command calls service methods directly
└─> No HTTP, no auth, no DTOs
5. Exit
└─> Success: exit code 0
└─> Error: exit code 1
Service Injection
Commands use NestJS dependency injection to access services:
export class UserCreateCommand extends CommandRunner {
constructor(
private readonly userService: UserService,
private readonly emailService: EmailService,
) {
super();
}
async run(): Promise<void> {
// Both services are available
const user = await this.userService.createOne({...});
await this.emailService.sendWelcome(user.email);
}
}
Key Points:
- Services must be registered in
CliModule.providers - Entities must be imported via
TypeOrmModule.forFeature([...]) - Don't import feature modules (use services/entities directly)
Interactive Prompts
Use @inquirer/prompts for user input:
import { password, confirm, input } from '@inquirer/prompts';
async run(): Promise<void> {
// Hidden input (passwords)
const pwd = await password({
message: 'Password:',
mask: '*',
});
// Text input
const name = await input({
message: 'Enter name:',
});
// Confirmation
const shouldContinue = await confirm({
message: 'Are you sure?',
});
if (!shouldContinue) {
console.log('✗ Cancelled');
process.exit(0);
}
}
When to use prompts vs options:
- Options (
--flag): Non-sensitive data, scriptable commands - Prompts: Sensitive data (passwords), confirmations, optional inputs
Error Handling
Always catch errors and exit with proper codes:
async run(): Promise<void> {
try {
// Call service
const result = await this.service.createOne({...});
// Success output
console.log('✓ Operation successful');
console.log(` ID: ${result.id}`);
} catch (error) {
// Handle known errors
if (error instanceof UserAlreadyExistsError) {
console.error('✗ User already exists');
process.exit(1);
}
// Handle unknown errors
const message = error instanceof Error ? error.message : String(error);
console.error(`✗ Failed: ${message}`);
process.exit(1);
}
}
Output Format:
- ✓ Use checkmarks for success
- ✗ Use crosses for errors
- Indent details with 2 spaces
- Exit with code 1 on any error
Common Patterns
Pattern 1: Simple CRUD Command
Direct service calls for create/read/update/delete operations:
@Command({
name: 'user:list',
description: 'List all users',
})
export class UserListCommand extends CommandRunner {
constructor(private readonly userService: UserService) {
super();
}
async run(): Promise<void> {
try {
const { results } = await this.userService.readMany({
limit: 100,
});
console.log(`✓ Found ${results.length} users:`);
for (const user of results) {
console.log(` ${user.id} - ${user.email} (${user.name})`);
}
} catch (error) {
console.error(`✗ Failed: ${error.message}`);
process.exit(1);
}
}
}
Pattern 2: Interactive Prompt Command
Use prompts for sensitive or optional data:
@Command({
name: 'user:reset-password',
description: 'Reset user password',
})
export class UserResetPasswordCommand extends CommandRunner {
constructor(private readonly userService: UserService) {
super();
}
async run(inputs: string[], options: Record<string, any>): Promise<void> {
const { email } = options;
try {
// Verify user exists
const { results } = await this.userService.readMany({
inEmails: [email],
limit: 1,
});
if (results.length === 0) {
console.error(`✗ User with email ${email} not found`);
process.exit(1);
}
const user = results[0];
// Prompt for new password
const newPassword = await password({
message: 'New password:',
mask: '*',
});
const confirmPassword = await password({
message: 'Confirm password:',
mask: '*',
});
if (newPassword !== confirmPassword) {
console.error('✗ Passwords do not match');
process.exit(1);
}
// Update password
await this.userService.updateOne({
id: user.id,
password: newPassword,
});
console.log('✓ Password reset successfully');
console.log(` User: ${user.email}`);
} catch (error) {
console.error(`✗ Failed: ${error.message}`);
process.exit(1);
}
}
@Option({
flags: '-e, --email <email>',
description: 'User email address',
required: true,
})
parseEmail(val: string): string {
return val;
}
}
Pattern 3: Multi-Service Command
Commands can inject and coordinate multiple services:
@Command({
name: 'integration:create-hello-world',
description: 'Create hello-world test integration',
})
export class UserIntegrationCreateHelloWorldCommand extends CommandRunner {
constructor(
private readonly userService: UserService,
private readonly integrationService: UserIntegrationService,
) {
super();
}
async run(): Promise<void> {
try {
// Create or find test user
let user;
const { results } = await this.userService.readMany({
inEmails: ['test@example.com'],
limit: 1,
});
if (results.length > 0) {
user = results[0];
console.log('✓ Found existing test user');
} else {
user = await this.userService.createOne({
email: 'test@example.com',
name: 'Test User',
password: 'test123',
});
console.log('✓ Created test user');
}
console.log(` ID: ${user.id}`);
// Create integration
const integration = await this.integrationService.createOne({
userId: user.id,
integrationId: 'hello-world',
integrationDomain: 'hello-world',
credentials: {},
status: 'ACTIVE',
});
console.log('✓ Created test integration');
console.log(` ID: ${integration.id}`);
console.log(` Domain: ${integration.integrationDomain}`);
} catch (error) {
console.error(`✗ Failed: ${error.message}`);
process.exit(1);
}
}
}
Reference
Complete Command Checklist
- File named with pattern:
{module}-{action}-{detail}.ts - Class extends
CommandRunner -
@Command()decorator with name and description - Command name uses colon separator (e.g.,
user:create) - Constructor injects required services
-
run()method implemented -
@Option()decorators for command-line flags - Interactive prompts for sensitive data (passwords)
- Try-catch block wraps all operations
- Errors handled with user-friendly messages
-
process.exit(1)called on errors - Success messages use ✓ checkmarks
- Error messages use ✗ crosses
- Command registered in
CliModule.providers - Required entities imported in
CliModule - Required services imported in
CliModule
Complete Example: User Create Command
See the full implementation above in "Step 2: Define Your Command Class" and "Step 3: Add Options and Prompts" for a complete, production-ready example of user:create.
Key features demonstrated:
- Service injection
- Command-line options
- Interactive password prompt
- Error handling for known errors (UserAlreadyExistsError)
- Proper exit codes
- Clean output formatting
Troubleshooting
Q: Command not found when running pnpm run cli
- Check that
cliscript exists inpackage.json - Verify
src/cli/cli.tsexists - Ensure
CliModuleis properly configured
Q: Service injection fails
- Verify service is registered in
CliModule.providers - Check that required entities are imported via
TypeOrmModule.forFeature([...]) - Don't import feature modules (import services/entities directly)
Q: Environment variables not loading
- Ensure
dotenv.config()is called before any other imports incli.ts - Check that
.envfile exists in project root - Verify environment variable names match your config
Q: Database connection fails
- Check database credentials in
.env - Verify PostgreSQL is running
- Ensure
synchronize: falsein production - Check that
namingStrategyis configured correctly
Q: Interactive prompts not working
- Install
@inquirer/prompts:pnpm add @inquirer/prompts - Use
password()for masked input - Use
input()for plain text - Validate input before proceeding
Q: Command runs but doesn't output anything
- Check that you're using
console.log()orconsole.error() - Verify you're not swallowing errors silently
- Make sure
process.exit(1)is called on errors
Next Steps
After creating your command:
- Test Locally - Run the command and verify it works as expected
- Add to Documentation - Document the command's purpose and usage
- CI/CD Integration - Use commands in deployment scripts if needed
- Monitor Usage - Track which commands are used most frequently