mirror of https://github.com/buster-so/buster.git
13 KiB
13 KiB
Buster CLI Development Guidelines
This document provides specific guidelines for developing the TypeScript-based Buster CLI in this monorepo.
Architecture Overview
The Buster CLI is a thin client that serves as a gateway to the Buster server API. It handles:
- File system operations (reading/writing YAML files)
- API communication with the server
- Rich terminal UI using Ink (React for CLI)
Important: The CLI does NOT directly access databases or AI services. All business logic is handled by the server.
Directory Structure
apps/cli/
├── src/
│ ├── commands/ # Command implementations
│ │ ├── auth/ # Each command in its own folder
│ │ │ ├── index.ts # Command definition and setup
│ │ │ ├── types.ts # Command-specific types (Zod schemas)
│ │ │ ├── helpers.ts # Command utilities
│ │ │ └── auth.test.ts
│ │ ├── init/
│ │ ├── deploy/
│ │ └── ...
│ ├── components/ # Reusable Ink UI components
│ │ ├── forms/ # Form components
│ │ ├── tables/ # Table display components
│ │ ├── progress/ # Progress indicators
│ │ ├── prompts/ # Input prompts
│ │ └── status/ # Status displays
│ ├── utils/ # Shared utilities
│ │ ├── api-client.ts # Server API communication
│ │ ├── config.ts # Configuration management
│ │ ├── errors.ts # Error handling
│ │ └── validation.ts # Zod validation utilities
│ ├── schemas/ # Zod schemas
│ │ ├── commands/ # Command argument schemas
│ │ ├── config/ # Configuration schemas
│ │ ├── models/ # Data model schemas
│ │ └── api/ # API request/response schemas
│ └── main.ts # CLI entry point
├── scripts/
│ └── validate-env.ts # Environment validation
└── tests/
├── unit/ # Unit tests
├── integration/ # Integration tests
└── utils/ # Test utilities
Command Development Pattern
Each command follows a consistent structure:
1. Command Definition (index.ts
)
import { Command } from 'commander';
import { z } from 'zod';
import React from 'react';
import { render } from 'ink';
import { AuthUI } from './components.js';
import { authHandler } from './handlers.js';
import { AuthArgsSchema } from './types.js';
export const authCommand = new Command('auth')
.description('Authenticate with Buster')
.option('-h, --host <host>', 'API host URL')
.option('-k, --api-key <key>', 'API key')
.action(async (options) => {
// Validate arguments
const args = AuthArgsSchema.parse(options);
// Render Ink UI
const { waitUntilExit } = render(
<AuthUI args={args} onComplete={authHandler} />
);
await waitUntilExit();
});
2. Type Definitions (types.ts
)
import { z } from 'zod';
// Command arguments schema
export const AuthArgsSchema = z.object({
host: z.string().url().optional(),
apiKey: z.string().optional(),
});
export type AuthArgs = z.infer<typeof AuthArgsSchema>;
// Internal types
export const CredentialsSchema = z.object({
apiKey: z.string(),
apiUrl: z.string().url(),
environment: z.enum(['local', 'cloud']),
});
export type Credentials = z.infer<typeof CredentialsSchema>;
3. Ink UI Components (components.tsx
)
import React, { useState } from 'react';
import { Box, Text } from 'ink';
import TextInput from 'ink-text-input';
import SelectInput from 'ink-select-input';
import Spinner from 'ink-spinner';
interface AuthUIProps {
args: AuthArgs;
onComplete: (credentials: Credentials) => Promise<void>;
}
export const AuthUI: React.FC<AuthUIProps> = ({ args, onComplete }) => {
const [step, setStep] = useState<'input' | 'validating' | 'complete'>('input');
const [apiKey, setApiKey] = useState(args.apiKey || '');
// UI implementation with Ink components
return (
<Box flexDirection="column">
{step === 'input' && (
<Box>
<Text>Enter your API key: </Text>
<TextInput value={apiKey} onChange={setApiKey} />
</Box>
)}
{step === 'validating' && (
<Text>
<Spinner type="dots" /> Validating credentials...
</Text>
)}
</Box>
);
};
4. Command Logic (helpers.ts
)
import { apiClient } from '../../utils/api-client.js';
import { configManager } from '../../utils/config.js';
import type { Credentials } from './types.js';
export async function validateCredentials(credentials: Credentials): Promise<boolean> {
try {
await apiClient.validateAuth(credentials);
return true;
} catch (error) {
return false;
}
}
export async function saveCredentials(credentials: Credentials): Promise<void> {
await configManager.saveCredentials(credentials);
}
Zod-First Type System
We use Zod schemas for all type definitions and runtime validation:
1. Define Schema First
// Always define the schema first
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
});
// Then export the type
export type User = z.infer<typeof UserSchema>;
2. Validate User Input
// In command actions
.action(async (options) => {
const args = ArgsSchema.parse(options); // Throws if invalid
// args is now fully typed and validated
});
3. YAML File Validation
import yaml from 'js-yaml';
import { BusterConfigSchema } from '../schemas/config/buster-config.js';
export async function loadBusterConfig(path: string): Promise<BusterConfig> {
const content = await fs.readFile(path, 'utf-8');
const parsed = yaml.load(content);
return BusterConfigSchema.parse(parsed); // Validates and types
}
API Client Pattern
All server communication goes through the centralized API client:
// utils/api-client.ts
import type { User } from '@buster/server-shared/users';
import { z } from 'zod';
export class ApiClient {
constructor(private baseUrl: string, private apiKey?: string) {}
async request<T>({
method,
path,
body,
responseSchema,
}: {
method: string;
path: string;
body?: unknown;
responseSchema: z.ZodSchema<T>;
}): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...(this.apiKey && { Authorization: `Bearer ${this.apiKey}` }),
},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
throw new ApiError(response.status, await response.text());
}
const data = await response.json();
return responseSchema.parse(data);
}
}
Ink UI Components Guidelines
1. Component Organization
- Place reusable components in
src/components/
- Command-specific components stay in the command folder
- Export all components from index files
2. Common Patterns
// Progress indicator
<Box>
<Spinner type="dots" />
<Text> {message}</Text>
</Box>
// Form with validation
<Box flexDirection="column">
<TextInput
value={value}
onChange={setValue}
placeholder="Enter value"
/>
{error && <Text color="red">❌ {error}</Text>}
</Box>
// Status display
<Box borderStyle="round" padding={1}>
<Text color="green">✓ Operation successful</Text>
</Box>
3. State Management
- Use React hooks for local state
- Pass callbacks for command completion
- Handle errors gracefully with try/catch
Testing Strategy
1. Unit Tests
// auth.test.ts
import { describe, it, expect, vi } from 'vitest';
import { validateCredentials } from './helpers.js';
describe('auth helpers', () => {
it('should validate correct credentials', async () => {
const mockApiClient = vi.mocked(apiClient);
mockApiClient.validateAuth.mockResolvedValue(true);
const result = await validateCredentials({
apiKey: 'test-key',
apiUrl: 'https://api.buster.com',
});
expect(result).toBe(true);
});
});
2. Integration Tests
// auth.int.test.ts
import { testCLI } from '../../tests/utils/cli-tester.js';
describe('auth command integration', () => {
it('should authenticate with valid credentials', async () => {
const result = await testCLI(['auth', '--api-key', 'test-key']);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Successfully authenticated');
});
});
3. Ink Component Tests
import { render } from 'ink-testing-library';
import { AuthUI } from './components.js';
it('should render auth form', () => {
const { lastFrame } = render(<AuthUI args={{}} onComplete={vi.fn()} />);
expect(lastFrame()).toContain('Enter your API key:');
});
Error Handling
1. Custom Error Classes
export class CLIError extends Error {
constructor(message: string, public code: string) {
super(message);
this.name = 'CLIError';
}
}
export class ApiError extends CLIError {
constructor(public status: number, message: string) {
super(message, 'API_ERROR');
}
}
export class ValidationError extends CLIError {
constructor(message: string) {
super(message, 'VALIDATION_ERROR');
}
}
2. Error Display in Ink
interface ErrorDisplayProps {
error: Error;
}
export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ error }) => (
<Box borderStyle="round" borderColor="red" padding={1}>
<Text color="red">
❌ {error.message}
{error instanceof CLIError && (
<Text dimColor> (Code: {error.code})</Text>
)}
</Text>
</Box>
);
Configuration Management
1. File Locations
- Global config:
~/.buster/config.yml
- Project config:
./buster.yml
- Credentials:
~/.buster/credentials
(encrypted)
2. Schema Validation
export const BusterConfigSchema = z.object({
version: z.string(),
projectName: z.string(),
organization: z.string().optional(),
settings: z.object({
autoUpdate: z.boolean().default(true),
telemetry: z.boolean().default(true),
}).optional(),
});
Best Practices
- Keep Commands Simple: Commands should only handle argument parsing and UI rendering
- Delegate to Handlers: Business logic goes in handler functions
- Use Zod Everywhere: All user input and file parsing should use Zod validation
- Server-First: All operations should go through the server API
- Rich UI Feedback: Use Ink components to provide clear, beautiful feedback
- Handle Errors Gracefully: Show helpful error messages with recovery suggestions
- Test Everything: Unit test logic, integration test commands, component test UI
Common Patterns
Loading Configuration
export async function loadProjectConfig(): Promise<BusterConfig | null> {
try {
const configPath = path.join(process.cwd(), 'buster.yml');
const content = await fs.readFile(configPath, 'utf-8');
const parsed = yaml.load(content);
return BusterConfigSchema.parse(parsed);
} catch (error) {
if (error.code === 'ENOENT') {
return null; // No config file
}
throw new ValidationError('Invalid buster.yml configuration');
}
}
API Request with Progress
const { unmount } = render(
<ProgressDisplay message="Deploying models..." />
);
try {
const result = await apiClient.deployModels(models);
unmount();
render(<SuccessDisplay result={result} />);
} catch (error) {
unmount();
render(<ErrorDisplay error={error} />);
}
Multi-Step Operations
export const InitUI: React.FC = () => {
const [step, setStep] = useState(0);
const steps = ['Create folders', 'Generate config', 'Validate setup'];
return (
<Box flexDirection="column">
<ProgressSteps steps={steps} currentStep={step} />
{/* Step-specific UI */}
</Box>
);
};
Environment Variables
BUSTER_API_URL
: Base URL for the Buster APIBUSTER_API_KEY
: API authentication keyBUSTER_CONFIG_DIR
: Override config directory locationBUSTER_CACHE_DIR
: Override cache directory locationBUSTER_AUTO_UPDATE
: Enable/disable auto-updatesBUSTER_TELEMETRY_DISABLED
: Disable telemetry
Distribution
The CLI is distributed as:
- npm package:
npm install -g @buster/cli
- Homebrew:
brew install buster-cli
- Direct binary: Download from GitHub releases
Binaries are compiled using Bun's compile feature for all major platforms.