diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 3ff34e4..3f3b267 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -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" ] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 253e672..9112552 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/src/cli/package.json b/src/cli/package.json index 0726d48..c99cd2c 100644 --- a/src/cli/package.json +++ b/src/cli/package.json @@ -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" } } diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts new file mode 100644 index 0000000..cd011f0 --- /dev/null +++ b/src/cli/src/commands/config.ts @@ -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; + log: (...args: string[]) => void; +} + +const defaultDeps: ConfigCommandDeps = { + configDeps: {}, + log: (...args) => console.log(...args), +}; + +export function createConfigCommand(deps?: Partial): 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 ', '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('', 'configuration key (e.g., daemonUrl, outputFormat)') + .argument('', 'value to set') + .action((key: string, value: string) => { + const updates: Record = {}; + + // 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, 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; +} diff --git a/src/cli/src/commands/status.ts b/src/cli/src/commands/status.ts new file mode 100644 index 0000000..24324ec --- /dev/null +++ b/src/cli/src/commands/status.ts @@ -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; + log: (...args: string[]) => void; + checkDaemon: (url: string) => Promise; +} + +function defaultCheckDaemon(url: string): Promise { + 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): Command { + const { configDeps, log, checkDaemon } = { ...defaultDeps, ...deps }; + + return new Command('status') + .description('Show mcpctl status and connectivity') + .option('-o, --output ', '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}`); + } + }); +} diff --git a/src/cli/src/config/index.ts b/src/cli/src/config/index.ts new file mode 100644 index 0000000..8765cf8 --- /dev/null +++ b/src/cli/src/config/index.ts @@ -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'; diff --git a/src/cli/src/config/loader.ts b/src/cli/src/config/loader.ts new file mode 100644 index 0000000..fd79823 --- /dev/null +++ b/src/cli/src/config/loader.ts @@ -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): 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): 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, deps?: Partial): McpctlConfig { + const current = loadConfig(deps); + return McpctlConfigSchema.parse({ ...current, ...overrides }); +} diff --git a/src/cli/src/config/schema.ts b/src/cli/src/config/schema.ts new file mode 100644 index 0000000..5f4d78d --- /dev/null +++ b/src/cli/src/config/schema.ts @@ -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; + +export const DEFAULT_CONFIG: McpctlConfig = McpctlConfigSchema.parse({}); diff --git a/src/cli/src/formatters/index.ts b/src/cli/src/formatters/index.ts new file mode 100644 index 0000000..47e6f4a --- /dev/null +++ b/src/cli/src/formatters/index.ts @@ -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'; diff --git a/src/cli/src/formatters/output.ts b/src/cli/src/formatters/output.ts new file mode 100644 index 0000000..cc3e894 --- /dev/null +++ b/src/cli/src/formatters/output.ts @@ -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(); +} diff --git a/src/cli/src/formatters/table.ts b/src/cli/src/formatters/table.ts new file mode 100644 index 0000000..7a2b8d2 --- /dev/null +++ b/src/cli/src/formatters/table.ts @@ -0,0 +1,44 @@ +export interface Column { + header: string; + key: keyof T | ((row: T) => string); + width?: number; + align?: 'left' | 'right'; +} + +export function formatTable(rows: T[], columns: Column[]): string { + if (rows.length === 0) { + return 'No results found.'; + } + + const getValue = (row: T, col: Column): 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'); +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 4c6ac3f..a8b2b4f 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -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 ', 'output format (table, json, yaml)', 'table') + .option('--daemon-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); +} diff --git a/src/cli/tests/cli.test.ts b/src/cli/tests/cli.test.ts new file mode 100644 index 0000000..2a82ff7 --- /dev/null +++ b/src/cli/tests/cli.test.ts @@ -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(); + }); +}); diff --git a/src/cli/tests/commands/config.test.ts b/src/cli/tests/commands/config.test.ts new file mode 100644 index 0000000..bc029c1 --- /dev/null +++ b/src/cli/tests/commands/config.test.ts @@ -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; + 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); + }); +}); diff --git a/src/cli/tests/commands/status.test.ts b/src/cli/tests/commands/status.test.ts new file mode 100644 index 0000000..fefc48d --- /dev/null +++ b/src/cli/tests/commands/status.test.ts @@ -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; + 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'); + }); +}); diff --git a/src/cli/tests/config/loader.test.ts b/src/cli/tests/config/loader.test.ts new file mode 100644 index 0000000..ce091f5 --- /dev/null +++ b/src/cli/tests/config/loader.test.ts @@ -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'); + }); +}); diff --git a/src/cli/tests/config/schema.test.ts b/src/cli/tests/config/schema.test.ts new file mode 100644 index 0000000..785f48d --- /dev/null +++ b/src/cli/tests/config/schema.test.ts @@ -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({})); + }); +}); diff --git a/src/cli/tests/formatters/output.test.ts b/src/cli/tests/formatters/output.test.ts new file mode 100644 index 0000000..b3b09a2 --- /dev/null +++ b/src/cli/tests/formatters/output.test.ts @@ -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; + 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); + }); +}); diff --git a/src/cli/tests/formatters/table.test.ts b/src/cli/tests/formatters/table.test.ts new file mode 100644 index 0000000..67e0398 --- /dev/null +++ b/src/cli/tests/formatters/table.test.ts @@ -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[] = [ + { 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[] = [ + { 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[] = [ + { 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]+$/); + }); +}); diff --git a/src/cli/tsconfig.json b/src/cli/tsconfig.json index 1d1421c..be275fe 100644 --- a/src/cli/tsconfig.json +++ b/src/cli/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "dist" + "outDir": "dist", + "types": ["node"] }, "include": ["src/**/*.ts"], "references": [