Skip to main content

CLI Commands Standards

CLI commands let you run administrative tasks directly on the server (like Mattermost CLI or GitLab Rails console). They have direct access to services and the database without needing HTTP or authentication.

Core Principles

Commands run server-side CLI commands run locally or via SSH on the server. They're for admin tasks, not remote API access.

Commands live with their domain Each module can have a commands/ folder alongside dto/ and entities/.

Direct service access Commands inject services directly. No HTTP, no auth, no DTOs - just call service methods.

Use nest-commander We use nest-commander for CLI commands. It's actively maintained and integrates well with NestJS.

File Structure

Commands live in a commands/ folder within each domain module:

src/
├── cli/
│ ├── cli.module.ts # Imports all command modules
│ └── cli.ts # CLI entry point
├── v1/
│ ├── user/
│ │ ├── commands/
│ │ │ ├── user-create.ts
│ │ │ └── user-reset-password.ts
│ │ ├── dto/
│ │ ├── user.service.ts
│ │ ├── user.controller.ts
│ │ └── user.module.ts
│ ├── user-integration/
│ │ ├── commands/
│ │ │ └── user-integration-create-hello-world.ts
│ │ └── ...
└── app.module.ts

Naming Convention

Files: module-action-detail.ts where module is the full module folder name (kebab-case)

  • user-create.ts
  • user-reset-password.ts
  • user-integration-create-hello-world.ts

Classes: Match the file name in PascalCase

  • UserCreateCommand
  • UserResetPasswordCommand
  • UserIntegrationCreateHelloWorldCommand

Command names: Use colon separator (can be shorter than filename)

  • user:create
  • user:reset-password
  • integration:create-hello-world

Basic Command

Commands extend CommandRunner and implement the run() method.

// src/v1/user/commands/user-create.ts
import { Command, CommandRunner, Option } from 'nest-commander';
import { password } from '@inquirer/prompts';
import { UserService } from '../user.service';
import { UserAlreadyExistsError } from '../user.error';

@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 {
// 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);
}

// Create user
const user = await this.userService.createOne({
email,
name: name || '',
password: passwordValue,
});

console.log('✓ User created successfully');
console.log(` ID: ${user.id}`);
console.log(` Email: ${user.email}`);
console.log(` Name: ${user.name}`);
} catch (error) {
if (error instanceof UserAlreadyExistsError) {
console.error(`✗ User with email ${email} already exists`);
process.exit(1);
}
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;
}
}

Setup

1. Install dependencies

pnpm add nest-commander @inquirer/prompts
pnpm add -D @types/inquirer

2. Create CLI module - src/cli/cli.module.ts

The CLI module sets up its own database connection and imports only what it needs, avoiding unnecessary dependencies like HTTP controllers or auth.

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

3. Create CLI entry point - src/cli/cli.ts

Load environment variables first, then bootstrap the CLI with proper error handling.

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);
});

4. Add package.json script

{
"scripts": {
"cli": "ts-node -r tsconfig-paths/register src/cli/cli.ts"
}
}

5. Run commands

pnpm run cli user:create --email="test@example.com" --name="Test User"
pnpm run cli integration:create-hello-world
pnpm run cli --help

Key Patterns

Environment variables: Load dotenv at the very top of cli.ts before any other imports.

Database setup: CLI module sets up its own TypeORM connection. Don't import feature modules (like UserModule) - import only services and entities directly.

Interactive prompts: Use @inquirer/prompts for sensitive input like passwords. The password function provides masked input.

Error handling: Always catch service errors and convert to user-friendly messages. Exit with code 1 on errors.

Complex Command Example

Commands can depend on multiple services. This example creates a test integration.

// src/v1/user-integration/commands/user-integration-create-hello-world.ts
import { Command, CommandRunner } from 'nest-commander';
import { UserService } from '../../user/user.service';
import { UserIntegrationService } from '../user-integration.service';

@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];
} else {
user = await this.userService.createOne({
email: 'test@example.com',
name: 'Test User',
});
}

// Create integration
const integration = await this.integrationService.createOne({
userId: user.id,
integrationDomain: 'hello-world',
credentials: {},
syncStatus: 'active',
nextSyncAt: new Date(),
pollIntervalMinutes: 1,
});

console.log('✓ Test integration created');
console.log(` ID: ${integration.id}`);
console.log(` Next sync: ${integration.nextSyncAt.toISOString()}`);
} catch (error) {
console.error(`✗ Failed: ${error.message}`);
process.exit(1);
}
}
}

Anti-Patterns

❌ Don't import feature modules into CliModule Import services and entities directly to avoid unnecessary dependencies.

// Bad - imports controllers, auth, etc.
@Module({
imports: [UserModule],
providers: [UserCreateCommand],
})

// Good - imports only what's needed
@Module({
imports: [TypeOrmModule.forFeature([UserEntity])],
providers: [UserService, UserCreateCommand],
})

❌ Don't skip error handling Always catch errors and exit with non-zero code.

// Bad
async run() {
const user = await this.service.createOne({...}); // Crashes on error
}

// Good
async run() {
try {
const user = await this.service.createOne({...});
console.log(`✓ Created: ${user.id}`);
} catch (error) {
console.error(`✗ Failed: ${error.message}`);
process.exit(1);
}
}

❌ Don't forget to load environment variables Load dotenv before any other imports in cli.ts.

// Bad - config might not load
import { CommandFactory } from 'nest-commander';
import * as dotenv from 'dotenv';
dotenv.config();

// Good - load config first
import * as dotenv from 'dotenv';
dotenv.config();
import { CommandFactory } from 'nest-commander';

Other common mistakes:

  • ❌ Not using checkmarks/crosses in output (✓/✗)
  • ❌ Not calling process.exit(1) on errors
  • ❌ Using generic names like seed instead of specific actions
  • ❌ Not using full module name in filename (e.g., integration-create.ts instead of user-integration-create.ts)
  • ❌ Passing sensitive data as command-line arguments instead of interactive prompts