buster/apps/cli/CLAUDE.md

13 KiB

CLI Application

This is the command-line interface application built with TypeScript and Bun. It provides terminal-based access to Buster functionality.

Core Responsibility

@buster-app/cli is responsible for:

  • Command-line interface for Buster operations
  • Developer tools and utilities
  • Administrative tasks
  • Data import/export operations
  • Communication with server via server-shared types

Architecture

CLI Commands → @buster-app/cli → @buster/server-shared → API Server
                    ↓
                Packages
           (Direct package usage)

Command Structure

Directory Organization

cli/
├── src/
│   ├── commands/
│   │   ├── auth/
│   │   │   ├── login.ts
│   │   │   ├── logout.ts
│   │   │   └── status.ts
│   │   ├── data/
│   │   │   ├── import.ts
│   │   │   ├── export.ts
│   │   │   └── sync.ts
│   │   ├── dashboard/
│   │   │   ├── create.ts
│   │   │   ├── list.ts
│   │   │   └── delete.ts
│   │   └── admin/
│   │       ├── users.ts
│   │       ├── organizations.ts
│   │       └── system.ts
│   ├── lib/
│   │   ├── api-client.ts
│   │   ├── config.ts
│   │   └── utils.ts
│   └── index.ts

Command Implementation

Basic Command Pattern

Commands are pure functions using Commander.js:

import { Command } from 'commander';
import { z } from 'zod';
import type { CreateDashboardRequest } from '@buster/server-shared';
import { apiClient } from '../lib/api-client';

// Command options schema
const CreateDashboardOptionsSchema = z.object({
  name: z.string().describe('Dashboard name'),
  description: z.string().optional().describe('Dashboard description'),
  datasource: z.string().uuid().describe('Data source ID'),
  public: z.boolean().default(false).describe('Make dashboard public')
});

type CreateDashboardOptions = z.infer<typeof CreateDashboardOptionsSchema>;

export const createDashboardCommand = new Command('create')
  .description('Create a new dashboard')
  .requiredOption('-n, --name <name>', 'Dashboard name')
  .option('-d, --description <desc>', 'Dashboard description')
  .requiredOption('-s, --datasource <id>', 'Data source ID')
  .option('-p, --public', 'Make dashboard public', false)
  .action(async (options: unknown) => {
    try {
      const validated = CreateDashboardOptionsSchema.parse(options);
      
      // Create request
      const request: CreateDashboardRequest = {
        name: validated.name,
        description: validated.description,
        dataSourceId: validated.datasource,
        isPublic: validated.public
      };
      
      // Call API
      const response = await apiClient.createDashboard(request);
      
      // Display result
      console.info(`✅ Dashboard created: ${response.dashboard.id}`);
      console.info(`   Name: ${response.dashboard.name}`);
      console.info(`   URL: ${response.dashboard.url}`);
    } catch (error) {
      if (error instanceof z.ZodError) {
        console.error('❌ Invalid options:', error.errors);
      } else {
        console.error('❌ Failed to create dashboard:', error.message);
      }
      process.exit(1);
    }
  });

Interactive Command Pattern

import { prompt } from 'enquirer';

export const loginCommand = new Command('login')
  .description('Login to Buster')
  .option('-e, --email <email>', 'Email address')
  .option('-p, --password <password>', 'Password')
  .action(async (options) => {
    // Get credentials interactively if not provided
    const credentials = await getCredentials(options);
    
    try {
      const response = await apiClient.login(credentials);
      
      // Save token
      await saveAuthToken(response.token);
      
      console.info('✅ Successfully logged in');
      console.info(`   User: ${response.user.email}`);
      console.info(`   Organization: ${response.organization.name}`);
    } catch (error) {
      console.error('❌ Login failed:', error.message);
      process.exit(1);
    }
  });

async function getCredentials(options: any) {
  const email = options.email || await prompt({
    type: 'input',
    name: 'email',
    message: 'Email:',
    validate: (value) => z.string().email().safeParse(value).success
  });
  
  const password = options.password || await prompt({
    type: 'password',
    name: 'password',
    message: 'Password:'
  });
  
  return { email, password };
}

API Client

Type-Safe API Client

// lib/api-client.ts
import type { 
  CreateDashboardRequest,
  CreateDashboardResponse,
  GetDashboardsResponse 
} from '@buster/server-shared';

class ApiClient {
  private baseUrl: string;
  private token?: string;
  
  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
    this.token = getStoredToken();
  }
  
  private async request<T>(
    path: string,
    options: RequestInit = {}
  ): Promise<T> {
    const response = await fetch(`${this.baseUrl}${path}`, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        ...(this.token && { Authorization: `Bearer ${this.token}` }),
        ...options.headers
      }
    });
    
    if (!response.ok) {
      const error = await response.json();
      throw new ApiError(error.message, response.status);
    }
    
    return response.json();
  }
  
  async createDashboard(
    data: CreateDashboardRequest
  ): Promise<CreateDashboardResponse> {
    return this.request('/api/v2/dashboards', {
      method: 'POST',
      body: JSON.stringify(data)
    });
  }
  
  async getDashboards(): Promise<GetDashboardsResponse> {
    return this.request('/api/v2/dashboards');
  }
}

export const apiClient = new ApiClient(
  process.env.BUSTER_API_URL || 'http://localhost:8080'
);

Configuration Management

Config File Pattern

// lib/config.ts
import { z } from 'zod';
import { readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';

const ConfigSchema = z.object({
  apiUrl: z.string().url().default('http://localhost:8080'),
  token: z.string().optional(),
  organization: z.string().uuid().optional(),
  outputFormat: z.enum(['json', 'table', 'csv']).default('table')
});

type Config = z.infer<typeof ConfigSchema>;

const CONFIG_PATH = join(homedir(), '.buster', 'config.json');

export function loadConfig(): Config {
  try {
    const data = readFileSync(CONFIG_PATH, 'utf-8');
    return ConfigSchema.parse(JSON.parse(data));
  } catch {
    return ConfigSchema.parse({});
  }
}

export function saveConfig(config: Partial<Config>): void {
  const current = loadConfig();
  const updated = { ...current, ...config };
  const validated = ConfigSchema.parse(updated);
  
  writeFileSync(
    CONFIG_PATH,
    JSON.stringify(validated, null, 2)
  );
}

Output Formatting

Table Output

import { table } from 'table';

export function formatAsTable(data: any[]): string {
  if (data.length === 0) {
    return 'No data';
  }
  
  const headers = Object.keys(data[0]);
  const rows = data.map(item => 
    headers.map(h => String(item[h] ?? ''))
  );
  
  return table([headers, ...rows], {
    border: {
      topBody: '─',
      topJoin: '┬',
      topLeft: '┌',
      topRight: '┐',
      bottomBody: '─',
      bottomJoin: '┴',
      bottomLeft: '└',
      bottomRight: '┘',
      bodyLeft: '│',
      bodyRight: '│',
      bodyJoin: '│',
      joinBody: '─',
      joinLeft: '├',
      joinRight: '┤',
      joinJoin: '┼'
    }
  });
}

JSON Output

export function formatAsJson(data: any): string {
  return JSON.stringify(data, null, 2);
}

CSV Output

export function formatAsCsv(data: any[]): string {
  if (data.length === 0) {
    return '';
  }
  
  const headers = Object.keys(data[0]);
  const rows = data.map(item => 
    headers.map(h => escapeCSV(String(item[h] ?? '')))
  );
  
  return [
    headers.join(','),
    ...rows.map(r => r.join(','))
  ].join('\n');
}

function escapeCSV(value: string): string {
  if (value.includes(',') || value.includes('"') || value.includes('\n')) {
    return `"${value.replace(/"/g, '""')}"`;
  }
  return value;
}

Progress Indicators

Spinner Pattern

import ora from 'ora';

export async function withSpinner<T>(
  message: string,
  task: () => Promise<T>
): Promise<T> {
  const spinner = ora(message).start();
  
  try {
    const result = await task();
    spinner.succeed();
    return result;
  } catch (error) {
    spinner.fail();
    throw error;
  }
}

// Usage
const dashboards = await withSpinner(
  'Fetching dashboards...',
  () => apiClient.getDashboards()
);

Progress Bar

import { createProgressBar } from './utils';

export async function importData(file: string) {
  const data = await readDataFile(file);
  const progress = createProgressBar(data.length);
  
  for (const [index, record] of data.entries()) {
    await processRecord(record);
    progress.update(index + 1);
  }
  
  progress.stop();
  console.info('✅ Import complete');
}

Error Handling

User-Friendly Errors

export class CliError extends Error {
  constructor(
    message: string,
    public readonly code: string,
    public readonly details?: any
  ) {
    super(message);
    this.name = 'CliError';
  }
}

export function handleError(error: unknown): void {
  if (error instanceof CliError) {
    console.error(`❌ ${error.message}`);
    if (error.details && process.env.DEBUG) {
      console.error('Details:', error.details);
    }
  } else if (error instanceof z.ZodError) {
    console.error('❌ Validation error:');
    error.errors.forEach(e => {
      console.error(`   - ${e.path.join('.')}: ${e.message}`);
    });
  } else if (error instanceof ApiError) {
    console.error(`❌ API error: ${error.message}`);
  } else {
    console.error('❌ Unexpected error:', error);
  }
  
  process.exit(1);
}

Main Entry Point

// index.ts
#!/usr/bin/env bun

import { Command } from 'commander';
import { version } from '../package.json';
import { authCommands } from './commands/auth';
import { dataCommands } from './commands/data';
import { dashboardCommands } from './commands/dashboard';

const program = new Command();

program
  .name('buster')
  .description('Buster CLI - Command line interface for Buster')
  .version(version);

// Add command groups
program.addCommand(authCommands);
program.addCommand(dataCommands);
program.addCommand(dashboardCommands);

// Global error handling
program.exitOverride((err) => {
  handleError(err);
});

// Parse arguments
program.parse(process.argv);

// Show help if no command
if (!process.argv.slice(2).length) {
  program.outputHelp();
}

Testing Patterns

Command Testing

describe('create dashboard command', () => {
  it('should create dashboard with valid options', async () => {
    const mockApi = jest.spyOn(apiClient, 'createDashboard')
      .mockResolvedValue({
        dashboard: {
          id: '123',
          name: 'Test Dashboard',
          url: 'http://example.com/dashboard/123'
        }
      });
    
    await createDashboardCommand.parseAsync([
      'node',
      'cli',
      '--name',
      'Test Dashboard',
      '--datasource',
      'abc-123'
    ]);
    
    expect(mockApi).toHaveBeenCalledWith({
      name: 'Test Dashboard',
      dataSourceId: 'abc-123',
      isPublic: false
    });
  });
});

Best Practices

DO:

  • Use Commander.js for command parsing
  • Validate all inputs with Zod
  • Import types from server-shared
  • Provide interactive prompts for missing options
  • Use spinners for long operations
  • Format output based on user preference
  • Handle errors gracefully
  • Store configuration securely

DON'T:

  • Define API types locally
  • Use classes for command logic
  • Skip input validation
  • Expose sensitive information
  • Block on long operations without feedback
  • Hardcode API endpoints
  • Store passwords in plain text

Package Integration

The CLI can directly use packages for local operations:

import { introspectDatabase } from '@buster/data-source';
import { validateSchema } from '@buster/database';

export const validateCommand = new Command('validate')
  .description('Validate database schema')
  .requiredOption('-d, --datasource <id>', 'Data source ID')
  .action(async (options) => {
    const spinner = ora('Validating schema...').start();
    
    try {
      // Direct package usage for local operation
      const schema = await introspectDatabase(options.datasource);
      const validation = await validateSchema(schema);
      
      spinner.succeed('Schema validated');
      
      if (validation.warnings.length > 0) {
        console.warn('⚠️  Warnings:');
        validation.warnings.forEach(w => console.warn(`   - ${w}`));
      }
    } catch (error) {
      spinner.fail('Validation failed');
      throw error;
    }
  });

This app should assemble packages for CLI operations and communicate with the server using server-shared types.