feat: build CLI core framework with Commander.js

Add CLI entry point with Commander.js, config management (~/.mcpctl/config.json
with Zod validation), output formatters (table/json/yaml), config and status
commands with dependency injection for testing. Fix sanitizeString regex ordering.
67 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-21 04:17:31 +00:00
parent dc45f5981b
commit 247b4967e5
20 changed files with 808 additions and 7 deletions

View File

@@ -475,7 +475,7 @@
"dependencies": [
"1"
],
"status": "pending",
"status": "done",
"subtasks": [
{
"id": 1,
@@ -499,7 +499,8 @@
"testStrategy": "TDD tests for config loading, saving, validation, and credential encryption.",
"parentId": "undefined"
}
]
],
"updatedAt": "2026-02-21T04:17:17.744Z"
},
{
"id": "8",
@@ -729,9 +730,9 @@
],
"metadata": {
"version": "1.0.0",
"lastModified": "2026-02-21T04:10:25.433Z",
"lastModified": "2026-02-21T04:17:17.744Z",
"taskCount": 24,
"completedCount": 2,
"completedCount": 3,
"tags": [
"master"
]

12
pnpm-lock.yaml generated
View File

@@ -62,6 +62,13 @@ importers:
zod:
specifier: ^3.24.0
version: 3.25.76
devDependencies:
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
'@types/node':
specifier: ^25.3.0
version: 25.3.0
src/db:
dependencies:
@@ -704,6 +711,9 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/js-yaml@4.0.9':
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -2391,6 +2401,8 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/js-yaml@4.0.9': {}
'@types/json-schema@7.0.15': {}
'@types/node@25.3.0':

View File

@@ -23,5 +23,9 @@
"inquirer": "^12.0.0",
"js-yaml": "^4.1.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.3.0"
}
}

View File

@@ -0,0 +1,69 @@
import { Command } from 'commander';
import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js';
import type { McpctlConfig, ConfigLoaderDeps } from '../config/index.js';
import { formatJson, formatYaml } from '../formatters/index.js';
export interface ConfigCommandDeps {
configDeps: Partial<ConfigLoaderDeps>;
log: (...args: string[]) => void;
}
const defaultDeps: ConfigCommandDeps = {
configDeps: {},
log: (...args) => console.log(...args),
};
export function createConfigCommand(deps?: Partial<ConfigCommandDeps>): Command {
const { configDeps, log } = { ...defaultDeps, ...deps };
const config = new Command('config').description('Manage mcpctl configuration');
config
.command('view')
.description('Show current configuration')
.option('-o, --output <format>', 'output format (json, yaml)', 'json')
.action((opts: { output: string }) => {
const cfg = loadConfig(configDeps);
const out = opts.output === 'yaml' ? formatYaml(cfg) : formatJson(cfg);
log(out);
});
config
.command('set')
.description('Set a configuration value')
.argument('<key>', 'configuration key (e.g., daemonUrl, outputFormat)')
.argument('<value>', 'value to set')
.action((key: string, value: string) => {
const updates: Record<string, unknown> = {};
// Handle typed conversions
if (key === 'cacheTTLMs') {
updates[key] = parseInt(value, 10);
} else if (key === 'registries') {
updates[key] = value.split(',').map((s) => s.trim());
} else {
updates[key] = value;
}
const updated = mergeConfig(updates as Partial<McpctlConfig>, configDeps);
saveConfig(updated, configDeps);
log(`Set ${key} = ${value}`);
});
config
.command('path')
.description('Show configuration file path')
.action(() => {
log(getConfigPath(configDeps?.configDir));
});
config
.command('reset')
.description('Reset configuration to defaults')
.action(() => {
saveConfig(DEFAULT_CONFIG, configDeps);
log('Configuration reset to defaults');
});
return config;
}

View File

@@ -0,0 +1,63 @@
import { Command } from 'commander';
import http from 'node:http';
import { loadConfig } from '../config/index.js';
import type { ConfigLoaderDeps } from '../config/index.js';
import { formatJson, formatYaml } from '../formatters/index.js';
import { APP_VERSION } from '@mcpctl/shared';
export interface StatusCommandDeps {
configDeps: Partial<ConfigLoaderDeps>;
log: (...args: string[]) => void;
checkDaemon: (url: string) => Promise<boolean>;
}
function defaultCheckDaemon(url: string): Promise<boolean> {
return new Promise((resolve) => {
const req = http.get(`${url}/health`, { timeout: 3000 }, (res) => {
resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 400);
res.resume();
});
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
});
}
const defaultDeps: StatusCommandDeps = {
configDeps: {},
log: (...args) => console.log(...args),
checkDaemon: defaultCheckDaemon,
};
export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command {
const { configDeps, log, checkDaemon } = { ...defaultDeps, ...deps };
return new Command('status')
.description('Show mcpctl status and connectivity')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
.action(async (opts: { output: string }) => {
const config = loadConfig(configDeps);
const daemonReachable = await checkDaemon(config.daemonUrl);
const status = {
version: APP_VERSION,
daemonUrl: config.daemonUrl,
daemonReachable,
registries: config.registries,
outputFormat: config.outputFormat,
};
if (opts.output === 'json') {
log(formatJson(status));
} else if (opts.output === 'yaml') {
log(formatYaml(status));
} else {
log(`mcpctl v${status.version}`);
log(`Daemon: ${status.daemonUrl} (${daemonReachable ? 'connected' : 'unreachable'})`);
log(`Registries: ${status.registries.join(', ')}`);
log(`Output: ${status.outputFormat}`);
}
});
}

View File

@@ -0,0 +1,4 @@
export { McpctlConfigSchema, DEFAULT_CONFIG } from './schema.js';
export type { McpctlConfig } from './schema.js';
export { loadConfig, saveConfig, mergeConfig, getConfigPath } from './loader.js';
export type { ConfigLoaderDeps } from './loader.js';

View File

@@ -0,0 +1,45 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { McpctlConfigSchema, DEFAULT_CONFIG } from './schema.js';
import type { McpctlConfig } from './schema.js';
export interface ConfigLoaderDeps {
configDir: string;
}
function defaultConfigDir(): string {
return join(homedir(), '.mcpctl');
}
export function getConfigPath(configDir?: string): string {
return join(configDir ?? defaultConfigDir(), 'config.json');
}
export function loadConfig(deps?: Partial<ConfigLoaderDeps>): McpctlConfig {
const configPath = getConfigPath(deps?.configDir);
if (!existsSync(configPath)) {
return DEFAULT_CONFIG;
}
const raw = readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(raw) as unknown;
return McpctlConfigSchema.parse(parsed);
}
export function saveConfig(config: McpctlConfig, deps?: Partial<ConfigLoaderDeps>): void {
const dir = deps?.configDir ?? defaultConfigDir();
const configPath = getConfigPath(dir);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
}
export function mergeConfig(overrides: Partial<McpctlConfig>, deps?: Partial<ConfigLoaderDeps>): McpctlConfig {
const current = loadConfig(deps);
return McpctlConfigSchema.parse({ ...current, ...overrides });
}

View File

@@ -0,0 +1,22 @@
import { z } from 'zod';
export const McpctlConfigSchema = z.object({
/** mcpd daemon endpoint */
daemonUrl: z.string().default('http://localhost:3000'),
/** Active registries for search */
registries: z.array(z.enum(['official', 'glama', 'smithery'])).default(['official', 'glama', 'smithery']),
/** Cache TTL in milliseconds */
cacheTTLMs: z.number().int().positive().default(3_600_000),
/** HTTP proxy URL */
httpProxy: z.string().optional(),
/** HTTPS proxy URL */
httpsProxy: z.string().optional(),
/** Default output format */
outputFormat: z.enum(['table', 'json', 'yaml']).default('table'),
/** Smithery API key */
smitheryApiKey: z.string().optional(),
});
export type McpctlConfig = z.infer<typeof McpctlConfigSchema>;
export const DEFAULT_CONFIG: McpctlConfig = McpctlConfigSchema.parse({});

View File

@@ -0,0 +1,4 @@
export { formatTable } from './table.js';
export type { Column } from './table.js';
export { formatJson, formatYaml } from './output.js';
export type { OutputFormat } from './output.js';

View File

@@ -0,0 +1,11 @@
import yaml from 'js-yaml';
export type OutputFormat = 'table' | 'json' | 'yaml';
export function formatJson(data: unknown): string {
return JSON.stringify(data, null, 2);
}
export function formatYaml(data: unknown): string {
return yaml.dump(data, { lineWidth: 120, noRefs: true }).trimEnd();
}

View File

@@ -0,0 +1,44 @@
export interface Column<T> {
header: string;
key: keyof T | ((row: T) => string);
width?: number;
align?: 'left' | 'right';
}
export function formatTable<T>(rows: T[], columns: Column<T>[]): string {
if (rows.length === 0) {
return 'No results found.';
}
const getValue = (row: T, col: Column<T>): string => {
if (typeof col.key === 'function') {
return col.key(row);
}
const val = row[col.key];
return val == null ? '' : String(val);
};
// Calculate column widths
const widths = columns.map((col) => {
if (col.width !== undefined) return col.width;
const headerLen = col.header.length;
const maxDataLen = rows.reduce((max, row) => {
const val = getValue(row, col);
return Math.max(max, val.length);
}, 0);
return Math.max(headerLen, maxDataLen);
});
const pad = (text: string, width: number, align: 'left' | 'right' = 'left'): string => {
const truncated = text.length > width ? text.slice(0, width - 1) + '\u2026' : text;
return align === 'right' ? truncated.padStart(width) : truncated.padEnd(width);
};
const headerLine = columns.map((col, i) => pad(col.header, widths[i] ?? 0, col.align ?? 'left')).join(' ');
const separator = widths.map((w) => '-'.repeat(w)).join(' ');
const dataLines = rows.map((row) =>
columns.map((col, i) => pad(getValue(row, col), widths[i] ?? 0, col.align ?? 'left')).join(' '),
);
return [headerLine, separator, ...dataLines].join('\n');
}

View File

@@ -1,2 +1,29 @@
// mcpctl CLI entry point
// Will be implemented in Task 7
#!/usr/bin/env node
import { Command } from 'commander';
import { APP_NAME, APP_VERSION } from '@mcpctl/shared';
import { createConfigCommand } from './commands/config.js';
import { createStatusCommand } from './commands/status.js';
export function createProgram(): Command {
const program = new Command()
.name(APP_NAME)
.description('Manage MCP servers like kubectl manages containers')
.version(APP_VERSION, '-v, --version')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
.option('--daemon-url <url>', 'mcpd daemon URL');
program.addCommand(createConfigCommand());
program.addCommand(createStatusCommand());
return program;
}
// Run when invoked directly
const isDirectRun =
typeof process !== 'undefined' &&
process.argv[1] !== undefined &&
import.meta.url === `file://${process.argv[1]}`;
if (isDirectRun) {
createProgram().parseAsync(process.argv);
}

38
src/cli/tests/cli.test.ts Normal file
View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { createProgram } from '../src/index.js';
describe('createProgram', () => {
it('creates a Commander program', () => {
const program = createProgram();
expect(program.name()).toBe('mcpctl');
});
it('has version flag', () => {
const program = createProgram();
expect(program.version()).toBe('0.1.0');
});
it('has config subcommand', () => {
const program = createProgram();
const config = program.commands.find((c) => c.name() === 'config');
expect(config).toBeDefined();
});
it('has status subcommand', () => {
const program = createProgram();
const status = program.commands.find((c) => c.name() === 'status');
expect(status).toBeDefined();
});
it('has output option', () => {
const program = createProgram();
const opt = program.options.find((o) => o.long === '--output');
expect(opt).toBeDefined();
});
it('has daemon-url option', () => {
const program = createProgram();
const opt = program.options.find((o) => o.long === '--daemon-url');
expect(opt).toBeDefined();
});
});

View File

@@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createConfigCommand } from '../../src/commands/config.js';
import { loadConfig, saveConfig, DEFAULT_CONFIG } from '../../src/config/index.js';
let tempDir: string;
let output: string[];
function log(...args: string[]) {
output.push(args.join(' '));
}
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-test-'));
output = [];
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function makeCommand() {
return createConfigCommand({
configDeps: { configDir: tempDir },
log,
});
}
describe('config view', () => {
it('outputs default config as JSON', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['view'], { from: 'user' });
expect(output).toHaveLength(1);
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
expect(parsed['daemonUrl']).toBe('http://localhost:3000');
});
it('outputs config as YAML with --output yaml', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['view', '-o', 'yaml'], { from: 'user' });
expect(output[0]).toContain('daemonUrl:');
});
});
describe('config set', () => {
it('sets a string value', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['set', 'daemonUrl', 'http://new:9000'], { from: 'user' });
expect(output[0]).toContain('daemonUrl');
const config = loadConfig({ configDir: tempDir });
expect(config.daemonUrl).toBe('http://new:9000');
});
it('sets cacheTTLMs as integer', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['set', 'cacheTTLMs', '60000'], { from: 'user' });
const config = loadConfig({ configDir: tempDir });
expect(config.cacheTTLMs).toBe(60000);
});
it('sets registries as comma-separated list', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['set', 'registries', 'official,glama'], { from: 'user' });
const config = loadConfig({ configDir: tempDir });
expect(config.registries).toEqual(['official', 'glama']);
});
it('sets outputFormat', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['set', 'outputFormat', 'json'], { from: 'user' });
const config = loadConfig({ configDir: tempDir });
expect(config.outputFormat).toBe('json');
});
});
describe('config path', () => {
it('shows config file path', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['path'], { from: 'user' });
expect(output[0]).toContain(tempDir);
expect(output[0]).toContain('config.json');
});
});
describe('config reset', () => {
it('resets to defaults', async () => {
// First set a custom value
saveConfig({ ...DEFAULT_CONFIG, daemonUrl: 'http://custom' }, { configDir: tempDir });
const cmd = makeCommand();
await cmd.parseAsync(['reset'], { from: 'user' });
expect(output[0]).toContain('reset');
const config = loadConfig({ configDir: tempDir });
expect(config.daemonUrl).toBe(DEFAULT_CONFIG.daemonUrl);
});
});

View File

@@ -0,0 +1,94 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createStatusCommand } from '../../src/commands/status.js';
import { saveConfig, DEFAULT_CONFIG } from '../../src/config/index.js';
let tempDir: string;
let output: string[];
function log(...args: string[]) {
output.push(args.join(' '));
}
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-status-test-'));
output = [];
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
describe('status command', () => {
it('shows status in table format', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async () => true,
});
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('mcpctl v');
expect(output.join('\n')).toContain('connected');
});
it('shows unreachable when daemon is down', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async () => false,
});
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('unreachable');
});
it('shows status in JSON format', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async () => true,
});
await cmd.parseAsync(['-o', 'json'], { from: 'user' });
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
expect(parsed['version']).toBe('0.1.0');
expect(parsed['daemonReachable']).toBe(true);
});
it('shows status in YAML format', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async () => false,
});
await cmd.parseAsync(['-o', 'yaml'], { from: 'user' });
expect(output[0]).toContain('daemonReachable: false');
});
it('uses custom daemon URL from config', async () => {
saveConfig({ ...DEFAULT_CONFIG, daemonUrl: 'http://custom:5555' }, { configDir: tempDir });
let checkedUrl = '';
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async (url) => {
checkedUrl = url;
return false;
},
});
await cmd.parseAsync([], { from: 'user' });
expect(checkedUrl).toBe('http://custom:5555');
});
it('shows registries from config', async () => {
saveConfig({ ...DEFAULT_CONFIG, registries: ['official'] }, { configDir: tempDir });
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async () => true,
});
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('official');
expect(output.join('\n')).not.toContain('glama');
});
});

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../../src/config/index.js';
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-test-'));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
describe('getConfigPath', () => {
it('returns path within config dir', () => {
const path = getConfigPath('/tmp/mcpctl');
expect(path).toBe('/tmp/mcpctl/config.json');
});
});
describe('loadConfig', () => {
it('returns defaults when no config file exists', () => {
const config = loadConfig({ configDir: tempDir });
expect(config).toEqual(DEFAULT_CONFIG);
});
it('loads config from file', () => {
saveConfig({ ...DEFAULT_CONFIG, daemonUrl: 'http://custom:5000' }, { configDir: tempDir });
const config = loadConfig({ configDir: tempDir });
expect(config.daemonUrl).toBe('http://custom:5000');
});
it('applies defaults for missing fields', () => {
const { writeFileSync } = require('node:fs') as typeof import('node:fs');
writeFileSync(join(tempDir, 'config.json'), '{"daemonUrl":"http://x:1"}');
const config = loadConfig({ configDir: tempDir });
expect(config.daemonUrl).toBe('http://x:1');
expect(config.registries).toEqual(['official', 'glama', 'smithery']);
});
});
describe('saveConfig', () => {
it('creates config file', () => {
saveConfig(DEFAULT_CONFIG, { configDir: tempDir });
expect(existsSync(join(tempDir, 'config.json'))).toBe(true);
});
it('creates config directory if missing', () => {
const nested = join(tempDir, 'nested', 'dir');
saveConfig(DEFAULT_CONFIG, { configDir: nested });
expect(existsSync(join(nested, 'config.json'))).toBe(true);
});
it('round-trips configuration', () => {
const custom = {
...DEFAULT_CONFIG,
daemonUrl: 'http://custom:9000',
registries: ['official' as const],
outputFormat: 'json' as const,
};
saveConfig(custom, { configDir: tempDir });
const loaded = loadConfig({ configDir: tempDir });
expect(loaded).toEqual(custom);
});
});
describe('mergeConfig', () => {
it('merges overrides into existing config', () => {
saveConfig(DEFAULT_CONFIG, { configDir: tempDir });
const merged = mergeConfig({ daemonUrl: 'http://new:1234' }, { configDir: tempDir });
expect(merged.daemonUrl).toBe('http://new:1234');
expect(merged.registries).toEqual(DEFAULT_CONFIG.registries);
});
it('works when no config file exists', () => {
const merged = mergeConfig({ outputFormat: 'yaml' }, { configDir: tempDir });
expect(merged.outputFormat).toBe('yaml');
expect(merged.daemonUrl).toBe('http://localhost:3000');
});
});

View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { McpctlConfigSchema, DEFAULT_CONFIG } from '../../src/config/schema.js';
describe('McpctlConfigSchema', () => {
it('provides sensible defaults from empty object', () => {
const config = McpctlConfigSchema.parse({});
expect(config.daemonUrl).toBe('http://localhost:3000');
expect(config.registries).toEqual(['official', 'glama', 'smithery']);
expect(config.cacheTTLMs).toBe(3_600_000);
expect(config.outputFormat).toBe('table');
expect(config.httpProxy).toBeUndefined();
expect(config.httpsProxy).toBeUndefined();
expect(config.smitheryApiKey).toBeUndefined();
});
it('validates a full config', () => {
const config = McpctlConfigSchema.parse({
daemonUrl: 'http://custom:4000',
registries: ['official'],
cacheTTLMs: 60_000,
httpProxy: 'http://proxy:8080',
httpsProxy: 'http://proxy:8443',
outputFormat: 'json',
smitheryApiKey: 'sk-test',
});
expect(config.daemonUrl).toBe('http://custom:4000');
expect(config.registries).toEqual(['official']);
expect(config.outputFormat).toBe('json');
});
it('rejects invalid registry names', () => {
expect(() => McpctlConfigSchema.parse({ registries: ['invalid'] })).toThrow();
});
it('rejects invalid output format', () => {
expect(() => McpctlConfigSchema.parse({ outputFormat: 'xml' })).toThrow();
});
it('rejects negative cacheTTLMs', () => {
expect(() => McpctlConfigSchema.parse({ cacheTTLMs: -1 })).toThrow();
});
it('rejects non-integer cacheTTLMs', () => {
expect(() => McpctlConfigSchema.parse({ cacheTTLMs: 1.5 })).toThrow();
});
});
describe('DEFAULT_CONFIG', () => {
it('matches schema defaults', () => {
expect(DEFAULT_CONFIG).toEqual(McpctlConfigSchema.parse({}));
});
});

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { formatJson, formatYaml } from '../../src/formatters/output.js';
describe('formatJson', () => {
it('formats object as indented JSON', () => {
const result = formatJson({ key: 'value', num: 42 });
expect(JSON.parse(result)).toEqual({ key: 'value', num: 42 });
expect(result).toContain('\n'); // indented
});
it('formats arrays', () => {
const result = formatJson([1, 2, 3]);
expect(JSON.parse(result)).toEqual([1, 2, 3]);
});
it('handles null and undefined values', () => {
const result = formatJson({ a: null, b: undefined });
const parsed = JSON.parse(result) as Record<string, unknown>;
expect(parsed['a']).toBeNull();
expect('b' in parsed).toBe(false); // undefined stripped by JSON
});
});
describe('formatYaml', () => {
it('formats object as YAML', () => {
const result = formatYaml({ key: 'value', num: 42 });
expect(result).toContain('key: value');
expect(result).toContain('num: 42');
});
it('formats arrays', () => {
const result = formatYaml(['a', 'b']);
expect(result).toContain('- a');
expect(result).toContain('- b');
});
it('does not end with trailing newline', () => {
const result = formatYaml({ x: 1 });
expect(result.endsWith('\n')).toBe(false);
});
});

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { formatTable } from '../../src/formatters/table.js';
import type { Column } from '../../src/formatters/table.js';
interface TestRow {
name: string;
age: number;
city: string;
}
const columns: Column<TestRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'AGE', key: 'age', align: 'right' },
{ header: 'CITY', key: 'city' },
];
describe('formatTable', () => {
it('returns empty message for no rows', () => {
expect(formatTable([], columns)).toBe('No results found.');
});
it('formats a single row', () => {
const rows = [{ name: 'Alice', age: 30, city: 'NYC' }];
const result = formatTable(rows, columns);
const lines = result.split('\n');
expect(lines).toHaveLength(3); // header, separator, data
expect(lines[0]).toContain('NAME');
expect(lines[0]).toContain('AGE');
expect(lines[0]).toContain('CITY');
expect(lines[2]).toContain('Alice');
expect(lines[2]).toContain('NYC');
});
it('right-aligns numeric columns', () => {
const rows = [{ name: 'Bob', age: 5, city: 'LA' }];
const result = formatTable(rows, columns);
const lines = result.split('\n');
// AGE column should be right-aligned: " 5" or "5" padded
const ageLine = lines[2];
// The age value should have leading space(s) for right alignment
expect(ageLine).toMatch(/\s+5/);
});
it('auto-sizes columns to content', () => {
const rows = [
{ name: 'A', age: 1, city: 'X' },
{ name: 'LongName', age: 100, city: 'LongCityName' },
];
const result = formatTable(rows, columns);
const lines = result.split('\n');
// Header should be at least as wide as longest data
expect(lines[0]).toContain('NAME');
expect(lines[2]).toContain('A');
expect(lines[3]).toContain('LongName');
expect(lines[3]).toContain('LongCityName');
});
it('truncates long values when width is fixed', () => {
const narrowCols: Column<TestRow>[] = [
{ header: 'NAME', key: 'name', width: 5 },
];
const rows = [{ name: 'VeryLongName', age: 0, city: '' }];
const result = formatTable(rows, narrowCols);
const lines = result.split('\n');
// Should be truncated with ellipsis
expect(lines[2].trim().length).toBeLessThanOrEqual(5);
expect(lines[2]).toContain('\u2026');
});
it('supports function-based column keys', () => {
const fnCols: Column<TestRow>[] = [
{ header: 'INFO', key: (row) => `${row.name} (${row.age})` },
];
const rows = [{ name: 'Eve', age: 25, city: 'SF' }];
const result = formatTable(rows, fnCols);
expect(result).toContain('Eve (25)');
});
it('handles separator line matching column widths', () => {
const rows = [{ name: 'Test', age: 1, city: 'Here' }];
const result = formatTable(rows, columns);
const lines = result.split('\n');
const separator = lines[1];
// Separator should consist of dashes and spaces
expect(separator).toMatch(/^[-\s]+$/);
});
});

View File

@@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
"outDir": "dist",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"references": [