diff --git a/src/cli/config-command.ts b/src/cli/config-command.ts index 4465d4d..abad322 100644 --- a/src/cli/config-command.ts +++ b/src/cli/config-command.ts @@ -1,173 +1,13 @@ -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import type { RawEntry } from '../config.js'; -import { - type LoadConfigOptions, - loadRawConfig, - loadServerDefinitions, - type RawConfig, - resolveConfigPath, - writeRawConfig, -} from '../config.js'; -import { pathsForImport, readExternalEntries } from '../config-imports.js'; -import type { ServerDefinition } from '../config-schema.js'; -import { expandHome } from '../env.js'; -import { MCPORTER_VERSION } from '../runtime.js'; +import { handleAddCommand, resolveWriteTarget } from './config/add.js'; +import { handleLoginCommand, handleLogoutCommand } from './config/auth.js'; +import { handleDoctorCommand } from './config/doctor.js'; +import { handleGetCommand } from './config/get.js'; +import { type ConfigSubcommand, consumeInlineHelpTokens, isHelpToken, printConfigHelp } from './config/help.js'; +import { handleImportCommand } from './config/import.js'; +import { handleListCommand } from './config/list.js'; +import { handleRemoveCommand } from './config/remove.js'; +import type { ConfigCliOptions } from './config/types.js'; import { CliUsageError } from './errors.js'; -import { chooseClosestIdentifier, renderIdentifierResolutionMessages } from './identifier-helpers.js'; -import { boldText, dimText, extraDimText, supportsAnsiColor } from './terminal.js'; - -interface ConfigCliOptions { - readonly loadOptions: LoadConfigOptions; - readonly invokeAuth: (args: string[]) => Promise; -} - -type ConfigScope = 'home' | 'project'; - -interface ListFlags { - format: 'text' | 'json'; - source?: 'local' | 'import'; -} - -interface AddFlags { - transport?: 'http' | 'sse' | 'stdio'; - url?: string; - command?: string; - stdio?: string; - args: string[]; - description?: string; - env: Record; - headers: Record; - tokenCacheDir?: string; - clientName?: string; - oauthRedirectUrl?: string; - auth?: string; - copyFrom?: string; - persistPath?: string; - scope?: ConfigScope; - dryRun?: boolean; -} - -interface ImportFlags { - path?: string; - filter?: string; - copy?: boolean; - format: 'text' | 'json'; -} - -const COLOR_ENABLED = (): boolean => Boolean(supportsAnsiColor && process.stdout.isTTY); - -type ConfigSubcommand = 'list' | 'get' | 'add' | 'remove' | 'import' | 'login' | 'logout' | 'doctor'; - -type ConfigHelpEntry = { - readonly name: string; - readonly summary: string; - readonly usage: string; - readonly description: string; - readonly flags?: Array<{ flag: string; description: string }>; - readonly examples?: string[]; -}; - -const CONFIG_HELP_ENTRIES: Record = { - list: { - name: 'list [options] [filter]', - summary: 'Show merged servers', - usage: 'mcporter config list [options] [filter]', - description: 'Lists configured servers. Defaults to local entries, but you can view imports and emit JSON.', - flags: [ - { flag: '--json', description: 'Print JSON payloads instead of ANSI text.' }, - { flag: '--source ', description: 'Filter to local definitions or imported entries only.' }, - { flag: 'filter (positional)', description: 'Substring match applied to server names.' }, - ], - examples: ['pnpm mcporter config list', 'pnpm mcporter config list --json --source import cursor'], - }, - get: { - name: 'get [--json]', - summary: 'Inspect a single server', - usage: 'mcporter config get [--json]', - description: 'Shows one server definition, including transport, headers, and env overrides.', - flags: [{ flag: '--json', description: 'Emit the server entry as JSON.' }], - examples: ['pnpm mcporter config get linear', 'pnpm mcporter config get claude --json'], - }, - add: { - name: 'add [options] [target]', - summary: 'Persist a server definition', - usage: 'mcporter config add [options] [target]', - description: - 'Adds HTTP or stdio servers to the local config. Accepts URLs, commands, env vars, and OAuth metadata.', - flags: [ - { flag: '--url ', description: 'Set the HTTP/S base URL (implies http transport).' }, - { flag: '--command ', description: 'Set the stdio executable (implies stdio transport).' }, - { flag: '--stdio ', description: 'Alias for --command.' }, - { flag: '--transport ', description: 'Force a specific transport (validates target).' }, - { flag: '--arg ', description: 'Pass through additional stdio arguments (repeatable).' }, - { flag: '--description ', description: 'Set a human-friendly summary.' }, - { flag: '--env KEY=value', description: 'Attach environment variables (repeatable).' }, - { flag: '--header KEY=value', description: 'Attach HTTP headers (repeatable).' }, - { flag: '--token-cache-dir ', description: 'Override where OAuth tokens are persisted.' }, - { flag: '--client-name ', description: 'Customize the OAuth client identifier.' }, - { flag: '--oauth-redirect-url ', description: 'Set a custom OAuth redirect URL.' }, - { flag: '--auth ', description: 'Force the auth type (e.g., oauth).' }, - { flag: '--copy-from ', description: 'Start with an imported definition by name.' }, - { flag: '--persist ', description: 'Write to an alternate mcporter.json path.' }, - { - flag: '--scope ', - description: 'Choose whether to write to the home or project config (default: project).', - }, - { flag: '--dry-run', description: 'Print the would-be entry without writing to disk.' }, - { flag: '--', description: 'Forward every subsequent token as a stdio arg.' }, - ], - examples: [ - 'pnpm mcporter config add linear https://mcp.linear.app/mcp', - 'pnpm mcporter config add cursor --command "npx -y cursor" --arg --stdio', - ], - }, - remove: { - name: 'remove ', - summary: 'Delete a local entry', - usage: 'mcporter config remove ', - description: 'Removes a server definition from the active mcporter.json file.', - examples: ['pnpm mcporter config remove linear'], - }, - import: { - name: 'import [options]', - summary: 'Inspect or copy imported servers', - usage: 'mcporter config import [options]', - description: - 'Shows entries from Cursor, Claude, Codex, and other supported imports. Optionally copies them locally.', - flags: [ - { flag: '--path ', description: 'Manually point at a config file path.' }, - { flag: '--filter ', description: 'Match server names by substring before listing/copying.' }, - { flag: '--copy', description: 'Write the filtered entries into the local config.' }, - { flag: '--json', description: 'Emit JSON instead of plain text listings.' }, - ], - examples: ['pnpm mcporter config import cursor --copy', 'pnpm mcporter config import claude --filter notion'], - }, - login: { - name: 'login [options]', - summary: 'Run the OAuth/auth flow', - usage: 'mcporter config login [options]', - description: 'Delegates to `mcporter auth`, so you can pass ephemeral flags like --http-url/--stdio/--reset.', - examples: ['pnpm mcporter config login linear', 'pnpm mcporter config login https://example.com/mcp --reset'], - }, - logout: { - name: 'logout ', - summary: 'Clear cached credentials', - usage: 'mcporter config logout ', - description: 'Deletes the token cache directory for an OAuth-enabled server.', - examples: ['pnpm mcporter config logout linear'], - }, - doctor: { - name: 'doctor', - summary: 'Validate config files', - usage: 'mcporter config doctor', - description: 'Validates config files, warns about missing token caches, and prints config locations.', - examples: ['pnpm mcporter config doctor'], - }, -}; - -const CONFIG_HELP_ORDER: ConfigSubcommand[] = ['list', 'get', 'add', 'remove', 'import', 'login', 'logout', 'doctor']; export async function handleConfigCli(options: ConfigCliOptions, args: string[]): Promise { const initialToken = args[0]; @@ -197,7 +37,7 @@ export async function handleConfigCli(options: ConfigCliOptions, args: string[]) return; } - switch (subcommand) { + switch (subcommand as ConfigSubcommand) { case 'list': await handleListCommand(options, args); return; @@ -227,861 +67,6 @@ export async function handleConfigCli(options: ConfigCliOptions, args: string[]) } } -function isHelpToken(token: string): boolean { - return token === '--help' || token === '-h'; -} - -function printConfigHelp(subcommand?: string): void { - const colorize = COLOR_ENABLED(); - if (!subcommand) { - printConfigOverview(colorize); - return; - } - const resolved = resolveHelpSubcommand(subcommand); - if (!resolved) { - console.log(`Unknown config subcommand '${subcommand}'. Available commands: ${CONFIG_HELP_ORDER.join(', ')}.`); - return; - } - printSubcommandHelp(resolved, colorize); -} - -function consumeInlineHelpTokens(args: string[]): boolean { - let found = false; - for (let index = args.length - 1; index >= 0; index -= 1) { - const token = args[index]; - if (token && isHelpToken(token)) { - args.splice(index, 1); - found = true; - } - } - return found; -} - -function resolveHelpSubcommand(token: string | undefined): ConfigSubcommand | undefined { - if (!token) { - return undefined; - } - const normalized = token.toLowerCase() as ConfigSubcommand; - return normalized in CONFIG_HELP_ENTRIES ? normalized : undefined; -} - -function printConfigOverview(colorize: boolean): void { - const title = colorize ? boldText('mcporter config') : 'mcporter config'; - const subtitle = colorize - ? dimText('Manage configured MCP servers, imports, and ad-hoc discoveries.') - : 'Manage configured MCP servers, imports, and ad-hoc discoveries.'; - const commandsHeader = colorize ? boldText('Commands') : 'Commands'; - const examplesHeader = colorize ? boldText('Examples') : 'Examples'; - const lines: string[] = [title, subtitle, '', commandsHeader]; - const maxName = Math.max(...CONFIG_HELP_ORDER.map((key) => CONFIG_HELP_ENTRIES[key].name.length)); - for (const key of CONFIG_HELP_ORDER) { - const entry = CONFIG_HELP_ENTRIES[key]; - const padded = entry.name.padEnd(maxName); - const renderedName = colorize ? boldText(padded) : padded; - const renderedDesc = colorize ? dimText(entry.summary) : entry.summary; - lines.push(` ${renderedName} ${renderedDesc}`); - } - lines.push('', examplesHeader); - const exampleList = [ - 'pnpm mcporter config list --json', - 'pnpm mcporter config add linear https://mcp.linear.app/mcp', - 'pnpm mcporter config import cursor --copy', - ]; - for (const entry of exampleList) { - lines.push(` ${colorize ? extraDimText(entry) : entry}`); - } - const pointer = "Run 'mcporter config --help' for detailed flag info."; - lines.push('', colorize ? extraDimText(pointer) : pointer); - console.log(lines.join('\n')); -} - -function printSubcommandHelp(subcommand: ConfigSubcommand, colorize: boolean): void { - const entry = CONFIG_HELP_ENTRIES[subcommand]; - const title = colorize ? boldText(`mcporter config ${subcommand}`) : `mcporter config ${subcommand}`; - const description = colorize ? dimText(entry.description) : entry.description; - const usageHeader = colorize ? boldText('Usage') : 'Usage'; - const lines: string[] = [title, description, '', usageHeader, ` ${entry.usage}`]; - if (entry.flags && entry.flags.length > 0) { - const flagsHeader = colorize ? boldText('Flags') : 'Flags'; - const maxFlag = Math.max(...entry.flags.map((flag) => flag.flag.length)); - lines.push('', flagsHeader); - for (const flag of entry.flags) { - const padded = flag.flag.padEnd(maxFlag); - const renderedFlag = colorize ? boldText(padded) : padded; - const renderedDesc = colorize ? dimText(flag.description) : flag.description; - lines.push(` ${renderedFlag} ${renderedDesc}`); - } - } - if (entry.examples && entry.examples.length > 0) { - const examplesHeader = colorize ? boldText('Examples') : 'Examples'; - lines.push('', examplesHeader); - for (const example of entry.examples) { - lines.push(` ${colorize ? extraDimText(example) : example}`); - } - } - console.log(lines.join('\n')); -} - -async function handleListCommand(options: ConfigCliOptions, args: string[]): Promise { - const flags = extractListFlags(args); - const filter = args.shift(); - const servers = await loadServerDefinitions(options.loadOptions); - let filtered = servers; - if (flags.source) { - filtered = filtered.filter((server) => (server.source?.kind ?? 'local') === flags.source); - } - if (filter) { - filtered = filtered.filter((server) => filterMatches(filter, server)); - } - if (flags.format === 'json') { - const payload = filtered.map((server) => serializeDefinition(server)); - console.log(JSON.stringify({ servers: payload }, null, 2)); - return; - } - const colorize = COLOR_ENABLED(); - if (filtered.length === 0) { - console.log( - colorize - ? dimText('No local servers match the provided filters.') - : 'No local servers match the provided filters.' - ); - } else { - for (const server of filtered) { - printServerSummary(server); - } - } - if ((!flags.source || flags.source === 'local') && flags.format === 'text') { - printImportSummary(servers.filter((server) => server.source?.kind === 'import')); - } - if (flags.format === 'text') { - await printConfigFooter(options.loadOptions); - } -} - -function extractListFlags(args: string[]): ListFlags { - const flags: ListFlags = { format: 'text', source: 'local' }; - let index = 0; - while (index < args.length) { - const token = args[index]; - if (token === '--json') { - flags.format = 'json'; - args.splice(index, 1); - continue; - } - if (token === '--source') { - const value = args[index + 1]; - if (value !== 'local' && value !== 'import') { - throw new CliUsageError("--source must be either 'local' or 'import'."); - } - flags.source = value; - args.splice(index, 2); - continue; - } - index += 1; - } - return flags; -} - -function resolveWriteTarget(flags: AddFlags, loadOptions: LoadConfigOptions, rootDir: string): string { - if (flags.persistPath) { - return path.resolve(expandHome(flags.persistPath)); - } - if (flags.scope === 'home') { - return path.join(os.homedir(), '.mcporter', 'mcporter.json'); - } - if (flags.scope === 'project') { - return path.resolve(rootDir, 'config', 'mcporter.json'); - } - if (loadOptions.configPath) { - return path.resolve(expandHome(loadOptions.configPath)); - } - return path.resolve(rootDir, 'config', 'mcporter.json'); -} - -function filterMatches(filter: string, server: ServerDefinition): boolean { - if (filter.startsWith('source:')) { - const origin = server.source?.kind ?? 'local'; - return `source:${origin}` === filter; - } - return server.name.includes(filter); -} - -function serializeDefinition(definition: ServerDefinition): Record { - const origin = definition.source ?? { kind: 'local', path: '' }; - const base: Record = { - name: definition.name, - description: definition.description, - source: origin, - auth: definition.auth, - tokenCacheDir: definition.tokenCacheDir, - clientName: definition.clientName, - oauthRedirectUrl: definition.oauthRedirectUrl, - env: definition.env, - }; - if (definition.command.kind === 'http') { - base.transport = 'http'; - base.baseUrl = definition.command.url.href; - base.headers = definition.command.headers; - } else { - base.transport = 'stdio'; - base.command = definition.command.command; - base.args = definition.command.args; - base.cwd = definition.command.cwd; - } - return base; -} - -function printServerSummary(definition: ServerDefinition): void { - const colorize = COLOR_ENABLED(); - const origin = definition.source; - const header = colorize ? boldText(definition.name) : definition.name; - const label = (text: string): string => (colorize ? dimText(text) : text); - console.log(header); - if (origin) { - console.log(` ${label('Source')}: ${origin.kind}${origin.path ? ` (${origin.path})` : ''}`); - } else { - console.log(` ${label('Source')}: local`); - } - if (definition.command.kind === 'http') { - console.log(` ${label('Transport')}: http (${definition.command.url.href})`); - } else { - const renderedArgs = definition.command.args.length > 0 ? ` ${definition.command.args.join(' ')}` : ''; - console.log(` ${label('Transport')}: stdio (${definition.command.command}${renderedArgs})`); - console.log(` ${label('CWD')}: ${definition.command.cwd}`); - } - if (definition.description) { - console.log(` ${label('Description')}: ${definition.description}`); - } - if (definition.auth === 'oauth') { - console.log(` ${label('Auth')}: oauth`); - } -} - -async function handleGetCommand(options: ConfigCliOptions, args: string[]): Promise { - const flags = extractGetFlags(args); - const name = args.shift(); - if (!name) { - throw new CliUsageError('Usage: mcporter config get '); - } - const servers = await loadServerDefinitions(options.loadOptions); - const target = resolveServerDefinition(name, servers); - if (flags.format === 'json') { - console.log(JSON.stringify(serializeDefinition(target), null, 2)); - return; - } - printServerSummary(target); - if (target.command.kind === 'http' && target.command.headers && Object.keys(target.command.headers).length > 0) { - console.log(' Headers:'); - for (const [key, value] of Object.entries(target.command.headers)) { - console.log(` ${key}: ${value}`); - } - } - if (target.env && Object.keys(target.env).length > 0) { - console.log(' Env:'); - for (const [key, value] of Object.entries(target.env)) { - console.log(` ${key}=${value}`); - } - } -} - -function extractGetFlags(args: string[]): { format: 'text' | 'json' } { - let format: 'text' | 'json' = 'text'; - let index = 0; - while (index < args.length) { - const token = args[index]; - if (token === '--json') { - format = 'json'; - args.splice(index, 1); - continue; - } - index += 1; - } - return { format }; -} - -async function handleAddCommand(options: ConfigCliOptions, args: string[]): Promise { - const name = args.shift(); - if (!name) { - throw new CliUsageError('Usage: mcporter config add [target]'); - } - let positionalTarget: string | undefined; - if (args[0] && !args[0].startsWith('--')) { - positionalTarget = args.shift(); - } - const flags = extractAddFlags(args); - - const targetPath = resolveWriteTarget(flags, options.loadOptions, options.loadOptions.rootDir ?? process.cwd()); - const effectiveLoadOptions: LoadConfigOptions = { ...options.loadOptions, configPath: targetPath }; - - const { config, path: configPath } = await loadOrCreateConfig(effectiveLoadOptions); - const nextConfig = cloneConfig(config); - - const baseEntry = await resolveBaseEntry(flags.copyFrom, options.loadOptions); - const entry: RawEntry = baseEntry ? { ...baseEntry } : {}; - - applyTargetToEntry(entry, positionalTarget, flags); - applyFlagsToEntry(entry, flags); - validateTransportChoice(entry, flags.transport); - - const hasHttpTarget = - Boolean(entry.baseUrl) || - Boolean(entry.base_url) || - Boolean(entry.url) || - Boolean(entry.serverUrl) || - Boolean(entry.server_url); - const hasCommandTarget = Boolean(entry.command ?? entry.executable); - - if (flags.args.length > 0 && !hasCommandTarget) { - throw new CliUsageError('--arg requires a stdio command (use --command, --stdio, or provide a positional target).'); - } - - if (!hasHttpTarget && !hasCommandTarget) { - throw new CliUsageError('Server definitions require either a --url/target or a stdio command.'); - } - - if (!nextConfig.mcpServers) { - nextConfig.mcpServers = {}; - } - nextConfig.mcpServers[name] = entry; - - if (flags.dryRun) { - console.log(JSON.stringify({ [name]: entry }, null, 2)); - console.log('(dry-run) No changes were written.'); - return; - } - - await writeRawConfig(configPath, nextConfig); - console.log(`Added '${name}' to ${configPath}`); -} - -function extractAddFlags(args: string[]): AddFlags { - const flags: AddFlags = { args: [], env: {}, headers: {} }; - let index = 0; - while (index < args.length) { - const token = args[index]; - switch (token) { - case '--transport': - flags.transport = parseTransport(requireValue(args, index, token)); - args.splice(index, 2); - continue; - case '--url': - flags.url = requireValue(args, index, token); - args.splice(index, 2); - continue; - case '--command': - flags.command = requireValue(args, index, token); - args.splice(index, 2); - continue; - case '--stdio': - flags.stdio = requireValue(args, index, token); - args.splice(index, 2); - continue; - case '--arg': - flags.args.push(requireValue(args, index, token)); - args.splice(index, 2); - continue; - case '--description': - flags.description = requireValue(args, index, token); - args.splice(index, 2); - continue; - case '--env': - parseKeyValue(requireValue(args, index, token), flags.env, '--env'); - args.splice(index, 2); - continue; - case '--header': - parseKeyValue(requireValue(args, index, token), flags.headers, '--header'); - args.splice(index, 2); - continue; - case '--token-cache-dir': - flags.tokenCacheDir = requireValue(args, index, token); - args.splice(index, 2); - continue; - case '--client-name': - flags.clientName = requireValue(args, index, token); - args.splice(index, 2); - continue; - case '--oauth-redirect-url': - flags.oauthRedirectUrl = requireValue(args, index, token); - args.splice(index, 2); - continue; - case '--auth': - flags.auth = requireValue(args, index, token); - args.splice(index, 2); - continue; - case '--copy-from': - flags.copyFrom = requireValue(args, index, token); - args.splice(index, 2); - continue; - case '--persist': - flags.persistPath = requireValue(args, index, token); - args.splice(index, 2); - continue; - case '--scope': { - const scopeValue = requireValue(args, index, token); - if (scopeValue !== 'home' && scopeValue !== 'project') { - throw new CliUsageError('--scope must be either "home" or "project".'); - } - flags.scope = scopeValue; - args.splice(index, 2); - continue; - } - case '--dry-run': - flags.dryRun = true; - args.splice(index, 1); - continue; - case '--': - args.splice(index, 1); - while (index < args.length) { - const value = args[index]; - if (value !== undefined) { - flags.args.push(value); - } - args.splice(index, 1); - } - continue; - default: - index += 1; - break; - } - } - return flags; -} - -function parseTransport(value: string | undefined): 'http' | 'sse' | 'stdio' { - if (value !== 'http' && value !== 'sse' && value !== 'stdio') { - throw new CliUsageError("--transport must be one of 'http', 'sse', or 'stdio'."); - } - return value; -} - -function parseKeyValue(input: string | undefined, target: Record, flagName: string): void { - if (!input || !input.includes('=')) { - throw new CliUsageError(`${flagName} requires KEY=value.`); - } - const [key, ...rest] = input.split('='); - if (!key) { - throw new CliUsageError(`${flagName} requires KEY=value.`); - } - target[key] = rest.join('='); -} - -function requireValue(args: string[], index: number, flagName: string): string { - const value = args[index + 1]; - if (!value) { - throw new CliUsageError(`Flag '${flagName}' requires a value.`); - } - return value; -} - -async function resolveBaseEntry(copyFrom: string | undefined, options: LoadConfigOptions): Promise { - if (!copyFrom) { - return null; - } - const [kind, ...rest] = copyFrom.split(':'); - const name = rest.join(':'); - if (!kind || !name) { - throw new CliUsageError("--copy-from requires the format ':'."); - } - const rootDir = options.rootDir ?? process.cwd(); - const paths = pathsForImport(kind as never, rootDir); - for (const candidate of paths) { - const resolved = expandHome(candidate); - const entries = await readExternalEntries(resolved, { projectRoot: rootDir, importKind: kind as never }); - if (!entries) { - continue; - } - const entry = entries.get(name); - if (entry) { - return structuredClone(entry); - } - } - throw new CliUsageError(`Unable to find '${name}' in import '${kind}'.`); -} - -function applyTargetToEntry(entry: RawEntry, positionalTarget: string | undefined, flags: AddFlags): void { - if (flags.url) { - entry.baseUrl = flags.url; - return; - } - if (flags.command) { - entry.command = flags.command; - } - if (flags.stdio) { - entry.command = flags.stdio; - } - if (positionalTarget) { - if (looksLikeHttp(positionalTarget)) { - entry.baseUrl = positionalTarget; - } else { - entry.command = positionalTarget; - } - } -} - -function applyFlagsToEntry(entry: RawEntry, flags: AddFlags): void { - if (flags.args.length > 0) { - entry.args = flags.args; - } - if (flags.description) { - entry.description = flags.description; - } - if (Object.keys(flags.env).length > 0) { - entry.env = entry.env ? { ...entry.env, ...flags.env } : { ...flags.env }; - } - if (Object.keys(flags.headers).length > 0) { - entry.headers = entry.headers ? { ...entry.headers, ...flags.headers } : { ...flags.headers }; - } - if (flags.tokenCacheDir) { - entry.tokenCacheDir = flags.tokenCacheDir; - } - if (flags.clientName) { - entry.clientName = flags.clientName; - } - if (flags.oauthRedirectUrl) { - entry.oauthRedirectUrl = flags.oauthRedirectUrl; - } - if (flags.auth) { - entry.auth = flags.auth; - } -} - -function validateTransportChoice(entry: RawEntry, transport: AddFlags['transport']): void { - if (!transport) { - return; - } - const isHttp = Boolean(entry.baseUrl ?? entry.url ?? entry.serverUrl); - const isStdio = Boolean(entry.command ?? entry.args); - if (transport === 'stdio' && !isStdio) { - throw new CliUsageError("Transport 'stdio' requires a stdio command."); - } - if ((transport === 'http' || transport === 'sse') && !isHttp) { - throw new CliUsageError(`Transport '${transport}' requires a URL target.`); - } -} - -async function handleRemoveCommand(options: ConfigCliOptions, args: string[]): Promise { - const name = args.shift(); - if (!name) { - throw new CliUsageError('Usage: mcporter config remove '); - } - const { config, path: configPath } = await loadOrCreateConfig(options.loadOptions); - const targetName = findServerNameWithFuzzyMatch(name, Object.keys(config.mcpServers ?? {})); - if (!targetName) { - throw new CliUsageError(`Server '${name}' does not exist in ${configPath}.`); - } - const nextConfig = cloneConfig(config); - delete nextConfig.mcpServers[targetName]; - await writeRawConfig(configPath, nextConfig); - console.log(`Removed '${targetName}' from ${configPath}`); -} - -async function handleImportCommand(options: ConfigCliOptions, args: string[]): Promise { - const kind = args.shift(); - if (!kind) { - throw new CliUsageError('Usage: mcporter config import '); - } - const flags = extractImportFlags(args); - const rootDir = options.loadOptions.rootDir ?? process.cwd(); - const paths = flags.path ? [path.resolve(expandHome(flags.path))] : pathsForImport(kind as never, rootDir); - const entries: Array<{ name: string; entry: RawEntry; source: string }> = []; - const seenNames = new Set(); - for (const candidate of paths) { - const resolved = expandHome(candidate); - const map = await readExternalEntries(resolved, { projectRoot: rootDir, importKind: kind as never }); - if (!map) { - continue; - } - for (const [name, entry] of map) { - if (flags.filter && !name.includes(flags.filter)) { - continue; - } - if (seenNames.has(name)) { - continue; - } - seenNames.add(name); - entries.push({ name, entry, source: resolved }); - } - } - if (entries.length === 0) { - console.log('No entries found.'); - return; - } - if (flags.format === 'json') { - console.log(JSON.stringify({ entries }, null, 2)); - } else { - for (const item of entries) { - console.log(`${item.name} (${item.source})`); - } - } - if (flags.copy) { - const { config, path: configPath } = await loadOrCreateConfig(options.loadOptions); - const nextConfig = cloneConfig(config); - if (!nextConfig.mcpServers) { - nextConfig.mcpServers = {}; - } - for (const item of entries) { - nextConfig.mcpServers[item.name] = structuredClone(item.entry); - } - await writeRawConfig(configPath, nextConfig); - console.log(`Copied ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} to ${configPath}`); - } -} - -function extractImportFlags(args: string[]): ImportFlags { - const flags: ImportFlags = { format: 'text' }; - let index = 0; - while (index < args.length) { - const token = args[index]; - switch (token) { - case '--path': - flags.path = args[index + 1]; - args.splice(index, 2); - continue; - case '--filter': - flags.filter = args[index + 1]; - args.splice(index, 2); - continue; - case '--copy': - flags.copy = true; - args.splice(index, 1); - continue; - case '--json': - flags.format = 'json'; - args.splice(index, 1); - continue; - default: - index += 1; - break; - } - } - return flags; -} - -async function handleLoginCommand(options: ConfigCliOptions, args: string[]): Promise { - if (args.length === 0) { - throw new CliUsageError('Usage: mcporter config login '); - } - await options.invokeAuth([...args]); -} - -async function handleLogoutCommand(options: ConfigCliOptions, args: string[]): Promise { - const name = args.shift(); - if (!name) { - throw new CliUsageError('Usage: mcporter config logout '); - } - const servers = await loadServerDefinitions(options.loadOptions); - const target = resolveServerDefinition(name, servers); - if (!target.tokenCacheDir) { - console.log(`Server '${name}' does not expose a token cache directory.`); - return; - } - await fs.rm(target.tokenCacheDir, { recursive: true, force: true }); - console.log(`Cleared cached credentials for '${target.name}' (${target.tokenCacheDir})`); -} - -async function handleDoctorCommand(options: ConfigCliOptions, _args: string[]): Promise { - console.log(`MCPorter ${MCPORTER_VERSION}`); - const configLocations = await resolveConfigLocations(options.loadOptions); - logConfigLocations(configLocations, { leadingNewline: false }); - console.log(''); - const servers = await loadServerDefinitions(options.loadOptions); - const issues: string[] = []; - for (const server of servers) { - if (server.command.kind === 'stdio' && !path.isAbsolute(server.command.cwd)) { - issues.push(`Server '${server.name}' has a non-absolute working directory.`); - } - if (server.auth === 'oauth' && !server.tokenCacheDir) { - issues.push(`Server '${server.name}' enables OAuth but lacks a token cache directory.`); - } - } - if (issues.length === 0) { - console.log('Config looks good.'); - return; - } - console.log('Config issues detected:'); - for (const issue of issues) { - console.log(` - ${issue}`); - } -} - -function looksLikeHttp(value: string): boolean { - return value.startsWith('http://') || value.startsWith('https://'); -} - -function cloneConfig(config: RawConfig): RawConfig { - return { - mcpServers: config.mcpServers ? { ...config.mcpServers } : {}, - imports: config.imports ? [...config.imports] : [], - }; -} - -type ConfigLocationSummary = { - projectPath: string; - projectExists: boolean; - systemPath: string; - systemExists: boolean; -}; - -async function printConfigFooter(loadOptions: LoadConfigOptions): Promise { - const summary = await resolveConfigLocations(loadOptions); - logConfigLocations(summary, { leadingNewline: true }); -} - -async function resolveConfigLocations(loadOptions: LoadConfigOptions): Promise { - const rootDir = loadOptions.rootDir ?? process.cwd(); - const projectPath = path.resolve(rootDir, 'config', 'mcporter.json'); - const projectExists = await pathExists(projectPath); - const systemCandidates = buildSystemConfigCandidates(); - const systemResolved = await resolveFirstExisting(systemCandidates); - return { - projectPath, - projectExists, - systemPath: systemResolved.path, - systemExists: systemResolved.exists, - }; -} - -function logConfigLocations(summary: ConfigLocationSummary, options?: { leadingNewline?: boolean }): void { - const shouldAddNewline = options?.leadingNewline ?? true; - if (shouldAddNewline) { - console.log(''); - } - console.log(`Project config: ${formatPath(summary.projectPath, summary.projectExists)}`); - console.log(`System config: ${formatPath(summary.systemPath, summary.systemExists)}`); -} - -function buildSystemConfigCandidates(): string[] { - const homeDir = os.homedir(); - const base = path.join(homeDir, '.mcporter'); - return [path.join(base, 'mcporter.json'), path.join(base, 'mcporter.jsonc')]; -} - -async function resolveFirstExisting(pathsToCheck: string[]): Promise<{ path: string; exists: boolean }> { - for (const candidate of pathsToCheck) { - if (await pathExists(candidate)) { - return { path: candidate, exists: true }; - } - } - return { path: pathsToCheck[0] ?? '', exists: false }; -} - -async function pathExists(targetPath: string): Promise { - try { - await fs.access(targetPath); - return true; - } catch { - return false; - } -} - -function formatPath(targetPath: string, exists: boolean): string { - return exists ? targetPath : `${targetPath} (missing)`; -} - -async function loadOrCreateConfig(loadOptions: LoadConfigOptions): Promise<{ config: RawConfig; path: string }> { - try { - const { config, path } = await loadRawConfig(loadOptions); - return { config, path }; - } catch (error) { - if (isErrno(error, 'ENOENT')) { - const rootDir = loadOptions.rootDir ?? process.cwd(); - const resolved = resolveConfigPath(loadOptions.configPath, rootDir); - return { config: { mcpServers: {}, imports: [] }, path: resolved.path }; - } - throw error; - } -} - -function isErrno(error: unknown, code: string): error is NodeJS.ErrnoException { - return Boolean(error && typeof error === 'object' && (error as NodeJS.ErrnoException).code === code); -} - -function printImportSummary(importServers: ServerDefinition[]): void { - if (importServers.length === 0) { - return; - } - const colorize = COLOR_ENABLED(); - const grouped = new Map(); - for (const server of importServers) { - const sourcePath = server.source?.path ?? ''; - const list = grouped.get(sourcePath) ?? []; - list.push(server.name); - grouped.set(sourcePath, list); - } - console.log(''); - const header = colorize - ? boldText('Other sources available via --source import') - : 'Other sources available via --source import'; - console.log(header); - for (const [path, names] of grouped) { - names.sort(); - const sample = names.slice(0, 3).join(', '); - const suffix = names.length > 3 ? ', …' : ''; - const countLabel = `${names.length} server${names.length === 1 ? '' : 's'}`; - const pathLabel = colorize ? dimText(path) : path; - console.log(` ${pathLabel} — ${countLabel} (${sample}${suffix})`); - } - const guidance = 'Use `mcporter config import ` to copy them locally.'; - console.log(colorize ? dimText(guidance) : guidance); -} - -function resolveServerDefinition(name: string, servers: ServerDefinition[]): ServerDefinition { - const direct = servers.find((server) => server.name === name); - if (direct) { - return direct; - } - const resolution = chooseClosestIdentifier( - name, - servers.map((server) => server.name) - ); - if (!resolution) { - throw new CliUsageError(`[mcporter] Unknown server '${name}'.`); - } - const messages = renderIdentifierResolutionMessages({ - entity: 'server', - attempted: name, - resolution, - }); - if (messages.auto) { - console.log(dimText(messages.auto)); - } - if (resolution.kind === 'auto') { - const match = servers.find((server) => server.name === resolution.value); - if (match) { - return match; - } - } - if (messages.suggest) { - console.log(dimText(messages.suggest)); - } - throw new CliUsageError(`[mcporter] Unknown server '${name}'.`); -} - -function findServerNameWithFuzzyMatch(name: string, candidates: string[]): string | null { - if (candidates.includes(name)) { - return name; - } - const resolution = chooseClosestIdentifier(name, candidates); - if (!resolution) { - return null; - } - const messages = renderIdentifierResolutionMessages({ - entity: 'server', - attempted: name, - resolution, - }); - if (messages.auto) { - console.log(dimText(messages.auto)); - } - if (resolution.kind === 'auto') { - return resolution.value; - } - if (messages.suggest) { - console.log(dimText(messages.suggest)); - } - return null; -} - export const __configCommandInternals = { resolveWriteTarget, }; diff --git a/src/cli/config/add.ts b/src/cli/config/add.ts new file mode 100644 index 0000000..5b8a46b --- /dev/null +++ b/src/cli/config/add.ts @@ -0,0 +1,308 @@ +import os from 'node:os'; +import path from 'node:path'; +import type { LoadConfigOptions, RawEntry } from '../../config.js'; +import { writeRawConfig } from '../../config.js'; +import { pathsForImport, readExternalEntries } from '../../config-imports.js'; +import { expandHome } from '../../env.js'; +import { CliUsageError } from '../errors.js'; +import { cloneConfig, loadOrCreateConfig } from './shared.js'; +import type { ConfigCliOptions } from './types.js'; + +export type AddFlags = { + transport?: 'http' | 'sse' | 'stdio'; + url?: string; + command?: string; + stdio?: string; + args: string[]; + description?: string; + env: Record; + headers: Record; + tokenCacheDir?: string; + clientName?: string; + oauthRedirectUrl?: string; + auth?: string; + copyFrom?: string; + persistPath?: string; + scope?: 'home' | 'project'; + dryRun?: boolean; +}; + +export async function handleAddCommand(options: ConfigCliOptions, args: string[]): Promise { + const name = args.shift(); + if (!name) { + throw new CliUsageError('Usage: mcporter config add [target]'); + } + let positionalTarget: string | undefined; + if (args[0] && !args[0].startsWith('--')) { + positionalTarget = args.shift(); + } + const flags = extractAddFlags(args); + + const targetPath = resolveWriteTarget(flags, options.loadOptions, options.loadOptions.rootDir ?? process.cwd()); + const effectiveLoadOptions: LoadConfigOptions = { ...options.loadOptions, configPath: targetPath }; + + const { config, path: configPath } = await loadOrCreateConfig(effectiveLoadOptions); + const nextConfig = cloneConfig(config); + + const baseEntry = await resolveBaseEntry(flags.copyFrom, options.loadOptions); + const entry: RawEntry = baseEntry ? { ...baseEntry } : {}; + + applyTargetToEntry(entry, positionalTarget, flags); + applyFlagsToEntry(entry, flags); + validateTransportChoice(entry, flags.transport); + + const hasHttpTarget = + Boolean(entry.baseUrl) || + Boolean(entry.base_url) || + Boolean(entry.url) || + Boolean(entry.serverUrl) || + Boolean(entry.server_url); + const hasCommandTarget = Boolean(entry.command ?? entry.executable); + + if (flags.args.length > 0 && !hasCommandTarget) { + throw new CliUsageError('--arg requires a stdio command (use --command, --stdio, or provide a positional target).'); + } + + if (!hasHttpTarget && !hasCommandTarget) { + throw new CliUsageError('Server definitions require either a --url/target or a stdio command.'); + } + + if (!nextConfig.mcpServers) { + nextConfig.mcpServers = {}; + } + nextConfig.mcpServers[name] = entry; + + if (flags.dryRun) { + console.log(JSON.stringify({ [name]: entry }, null, 2)); + console.log('(dry-run) No changes were written.'); + return; + } + + await writeRawConfig(configPath, nextConfig); + console.log(`Added '${name}' to ${configPath}`); +} + +export function resolveWriteTarget(flags: AddFlags, loadOptions: LoadConfigOptions, rootDir: string): string { + if (flags.persistPath) { + return path.resolve(expandHome(flags.persistPath)); + } + if (flags.scope === 'home') { + return path.join(os.homedir(), '.mcporter', 'mcporter.json'); + } + if (flags.scope === 'project') { + return path.resolve(rootDir, 'config', 'mcporter.json'); + } + if (loadOptions.configPath) { + return path.resolve(expandHome(loadOptions.configPath)); + } + return path.resolve(rootDir, 'config', 'mcporter.json'); +} + +function extractAddFlags(args: string[]): AddFlags { + const flags: AddFlags = { args: [], env: {}, headers: {} }; + let index = 0; + while (index < args.length) { + const token = args[index]; + switch (token) { + case '--transport': + flags.transport = parseTransport(requireValue(args, index, token)); + args.splice(index, 2); + continue; + case '--url': + flags.url = requireValue(args, index, token); + args.splice(index, 2); + continue; + case '--command': + flags.command = requireValue(args, index, token); + args.splice(index, 2); + continue; + case '--stdio': + flags.stdio = requireValue(args, index, token); + args.splice(index, 2); + continue; + case '--arg': + flags.args.push(requireValue(args, index, token)); + args.splice(index, 2); + continue; + case '--description': + flags.description = requireValue(args, index, token); + args.splice(index, 2); + continue; + case '--env': + parseKeyValue(requireValue(args, index, token), flags.env, '--env'); + args.splice(index, 2); + continue; + case '--header': + parseKeyValue(requireValue(args, index, token), flags.headers, '--header'); + args.splice(index, 2); + continue; + case '--token-cache-dir': + flags.tokenCacheDir = requireValue(args, index, token); + args.splice(index, 2); + continue; + case '--client-name': + flags.clientName = requireValue(args, index, token); + args.splice(index, 2); + continue; + case '--oauth-redirect-url': + flags.oauthRedirectUrl = requireValue(args, index, token); + args.splice(index, 2); + continue; + case '--auth': + flags.auth = requireValue(args, index, token); + args.splice(index, 2); + continue; + case '--copy-from': + flags.copyFrom = requireValue(args, index, token); + args.splice(index, 2); + continue; + case '--persist': + flags.persistPath = requireValue(args, index, token); + args.splice(index, 2); + continue; + case '--scope': { + const scopeValue = requireValue(args, index, token); + if (scopeValue !== 'home' && scopeValue !== 'project') { + throw new CliUsageError('--scope must be either "home" or "project".'); + } + flags.scope = scopeValue; + args.splice(index, 2); + continue; + } + case '--dry-run': + flags.dryRun = true; + args.splice(index, 1); + continue; + case '--': + args.splice(index, 1); + while (index < args.length) { + const value = args[index]; + if (value !== undefined) { + flags.args.push(value); + } + args.splice(index, 1); + } + continue; + default: + index += 1; + break; + } + } + return flags; +} + +function parseTransport(value: string | undefined): 'http' | 'sse' | 'stdio' { + if (value !== 'http' && value !== 'sse' && value !== 'stdio') { + throw new CliUsageError("--transport must be one of 'http', 'sse', or 'stdio'."); + } + return value; +} + +function parseKeyValue(input: string | undefined, target: Record, flagName: string): void { + if (!input || !input.includes('=')) { + throw new CliUsageError(`${flagName} requires KEY=value.`); + } + const [key, ...rest] = input.split('='); + if (!key) { + throw new CliUsageError(`${flagName} requires KEY=value.`); + } + target[key] = rest.join('='); +} + +function requireValue(args: string[], index: number, flagName: string): string { + const value = args[index + 1]; + if (!value) { + throw new CliUsageError(`Flag '${flagName}' requires a value.`); + } + return value; +} + +async function resolveBaseEntry(copyFrom: string | undefined, options: LoadConfigOptions): Promise { + if (!copyFrom) { + return null; + } + const [kind, ...rest] = copyFrom.split(':'); + const name = rest.join(':'); + if (!kind || !name) { + throw new CliUsageError("--copy-from requires the format ':'."); + } + const rootDir = options.rootDir ?? process.cwd(); + const paths = pathsForImport(kind as never, rootDir); + for (const candidate of paths) { + const resolved = expandHome(candidate); + const entries = await readExternalEntries(resolved, { projectRoot: rootDir, importKind: kind as never }); + if (!entries) { + continue; + } + const entry = entries.get(name); + if (entry) { + return structuredClone(entry); + } + } + throw new CliUsageError(`Unable to find '${name}' in import '${kind}'.`); +} + +function applyTargetToEntry(entry: RawEntry, positionalTarget: string | undefined, flags: AddFlags): void { + if (flags.url) { + entry.baseUrl = flags.url; + return; + } + if (flags.command) { + entry.command = flags.command; + } + if (flags.stdio) { + entry.command = flags.stdio; + } + if (positionalTarget) { + if (looksLikeHttp(positionalTarget)) { + entry.baseUrl = positionalTarget; + } else { + entry.command = positionalTarget; + } + } +} + +function applyFlagsToEntry(entry: RawEntry, flags: AddFlags): void { + if (flags.args.length > 0) { + entry.args = flags.args; + } + if (flags.description) { + entry.description = flags.description; + } + if (Object.keys(flags.env).length > 0) { + entry.env = entry.env ? { ...entry.env, ...flags.env } : { ...flags.env }; + } + if (Object.keys(flags.headers).length > 0) { + entry.headers = entry.headers ? { ...entry.headers, ...flags.headers } : { ...flags.headers }; + } + if (flags.tokenCacheDir) { + entry.tokenCacheDir = flags.tokenCacheDir; + } + if (flags.clientName) { + entry.clientName = flags.clientName; + } + if (flags.oauthRedirectUrl) { + entry.oauthRedirectUrl = flags.oauthRedirectUrl; + } + if (flags.auth) { + entry.auth = flags.auth; + } +} + +function validateTransportChoice(entry: RawEntry, transport: AddFlags['transport']): void { + if (!transport) { + return; + } + const isHttp = Boolean(entry.baseUrl ?? entry.url ?? entry.serverUrl); + const isStdio = Boolean(entry.command ?? entry.args); + if (transport === 'stdio' && !isStdio) { + throw new CliUsageError("Transport 'stdio' requires a stdio command."); + } + if ((transport === 'http' || transport === 'sse') && !isHttp) { + throw new CliUsageError(`Transport '${transport}' requires a URL target.`); + } +} + +function looksLikeHttp(value: string): boolean { + return value.startsWith('http://') || value.startsWith('https://'); +} diff --git a/src/cli/config/auth.ts b/src/cli/config/auth.ts new file mode 100644 index 0000000..e235865 --- /dev/null +++ b/src/cli/config/auth.ts @@ -0,0 +1,27 @@ +import fs from 'node:fs/promises'; +import { loadServerDefinitions } from '../../config.js'; +import { CliUsageError } from '../errors.js'; +import { resolveServerDefinition } from './shared.js'; +import type { ConfigCliOptions } from './types.js'; + +export async function handleLoginCommand(options: ConfigCliOptions, args: string[]): Promise { + if (args.length === 0) { + throw new CliUsageError('Usage: mcporter config login '); + } + await options.invokeAuth([...args]); +} + +export async function handleLogoutCommand(options: ConfigCliOptions, args: string[]): Promise { + const name = args.shift(); + if (!name) { + throw new CliUsageError('Usage: mcporter config logout '); + } + const servers = await loadServerDefinitions(options.loadOptions); + const target = resolveServerDefinition(name, servers); + if (!target.tokenCacheDir) { + console.log(`Server '${name}' does not expose a token cache directory.`); + return; + } + await fs.rm(target.tokenCacheDir, { recursive: true, force: true }); + console.log(`Cleared cached credentials for '${target.name}' (${target.tokenCacheDir})`); +} diff --git a/src/cli/config/doctor.ts b/src/cli/config/doctor.ts new file mode 100644 index 0000000..aa414fd --- /dev/null +++ b/src/cli/config/doctor.ts @@ -0,0 +1,30 @@ +import path from 'node:path'; +import { loadServerDefinitions } from '../../config.js'; +import { MCPORTER_VERSION } from '../../runtime.js'; +import { logConfigLocations, resolveConfigLocations } from './shared.js'; +import type { ConfigCliOptions } from './types.js'; + +export async function handleDoctorCommand(options: ConfigCliOptions, _args: string[]): Promise { + console.log(`MCPorter ${MCPORTER_VERSION}`); + const configLocations = await resolveConfigLocations(options.loadOptions); + logConfigLocations(configLocations, { leadingNewline: false }); + console.log(''); + const servers = await loadServerDefinitions(options.loadOptions); + const issues: string[] = []; + for (const server of servers) { + if (server.command.kind === 'stdio' && !path.isAbsolute(server.command.cwd)) { + issues.push(`Server '${server.name}' has a non-absolute working directory.`); + } + if (server.auth === 'oauth' && !server.tokenCacheDir) { + issues.push(`Server '${server.name}' enables OAuth but lacks a token cache directory.`); + } + } + if (issues.length === 0) { + console.log('Config looks good.'); + return; + } + console.log('Config issues detected:'); + for (const issue of issues) { + console.log(` - ${issue}`); + } +} diff --git a/src/cli/config/get.ts b/src/cli/config/get.ts new file mode 100644 index 0000000..b5d5c49 --- /dev/null +++ b/src/cli/config/get.ts @@ -0,0 +1,47 @@ +import { loadServerDefinitions } from '../../config.js'; +import { CliUsageError } from '../errors.js'; +import { printServerSummary, serializeDefinition } from './render.js'; +import { resolveServerDefinition } from './shared.js'; +import type { ConfigCliOptions } from './types.js'; + +export async function handleGetCommand(options: ConfigCliOptions, args: string[]): Promise { + const flags = extractGetFlags(args); + const name = args.shift(); + if (!name) { + throw new CliUsageError('Usage: mcporter config get '); + } + const servers = await loadServerDefinitions(options.loadOptions); + const target = resolveServerDefinition(name, servers); + if (flags.format === 'json') { + console.log(JSON.stringify(serializeDefinition(target), null, 2)); + return; + } + printServerSummary(target); + if (target.command.kind === 'http' && target.command.headers && Object.keys(target.command.headers).length > 0) { + console.log(' Headers:'); + for (const [key, value] of Object.entries(target.command.headers)) { + console.log(` ${key}: ${value}`); + } + } + if (target.env && Object.keys(target.env).length > 0) { + console.log(' Env:'); + for (const [key, value] of Object.entries(target.env)) { + console.log(` ${key}=${value}`); + } + } +} + +function extractGetFlags(args: string[]): { format: 'text' | 'json' } { + let format: 'text' | 'json' = 'text'; + let index = 0; + while (index < args.length) { + const token = args[index]; + if (token === '--json') { + format = 'json'; + args.splice(index, 1); + continue; + } + index += 1; + } + return { format }; +} diff --git a/src/cli/config/help.ts b/src/cli/config/help.ts new file mode 100644 index 0000000..bb644ef --- /dev/null +++ b/src/cli/config/help.ts @@ -0,0 +1,213 @@ +import { boldText, dimText, extraDimText } from '../terminal.js'; +import { printConfigUsageExamples } from './render.js'; +import { COLOR_ENABLED } from './shared.js'; + +export type ConfigSubcommand = 'list' | 'get' | 'add' | 'remove' | 'import' | 'login' | 'logout' | 'doctor'; + +export type ConfigHelpEntry = { + readonly name: string; + readonly summary: string; + readonly usage: string; + readonly description: string; + readonly flags?: Array<{ flag: string; description: string }>; + readonly examples?: string[]; +}; + +export const CONFIG_HELP_ENTRIES: Record = { + list: { + name: 'list [options] [filter]', + summary: 'Show merged servers', + usage: 'mcporter config list [options] [filter]', + description: 'Lists configured servers. Defaults to local entries, but you can view imports and emit JSON.', + flags: [ + { flag: '--json', description: 'Print JSON payloads instead of ANSI text.' }, + { flag: '--source ', description: 'Filter to local definitions or imported entries only.' }, + { flag: 'filter (positional)', description: 'Substring match applied to server names.' }, + ], + examples: ['pnpm mcporter config list', 'pnpm mcporter config list --json --source import cursor'], + }, + get: { + name: 'get [--json]', + summary: 'Inspect a single server', + usage: 'mcporter config get [--json]', + description: 'Shows one server definition, including transport, headers, and env overrides.', + flags: [{ flag: '--json', description: 'Emit the server entry as JSON.' }], + examples: ['pnpm mcporter config get linear', 'pnpm mcporter config get claude --json'], + }, + add: { + name: 'add [options] [target]', + summary: 'Persist a server definition', + usage: 'mcporter config add [options] [target]', + description: + 'Adds HTTP or stdio servers to the local config. Accepts URLs, commands, env vars, and OAuth metadata.', + flags: [ + { flag: '--url ', description: 'Set the HTTP/S base URL (implies http transport).' }, + { flag: '--command ', description: 'Set the stdio executable (implies stdio transport).' }, + { flag: '--stdio ', description: 'Alias for --command.' }, + { flag: '--transport ', description: 'Force a specific transport (validates target).' }, + { flag: '--arg ', description: 'Pass through additional stdio arguments (repeatable).' }, + { flag: '--description ', description: 'Set a human-friendly summary.' }, + { flag: '--env KEY=value', description: 'Attach environment variables (repeatable).' }, + { flag: '--header KEY=value', description: 'Attach HTTP headers (repeatable).' }, + { flag: '--token-cache-dir ', description: 'Override where OAuth tokens are persisted.' }, + { flag: '--client-name ', description: 'Customize the OAuth client identifier.' }, + { flag: '--oauth-redirect-url ', description: 'Set a custom OAuth redirect URL.' }, + { flag: '--auth ', description: 'Force the auth type (e.g., oauth).' }, + { flag: '--copy-from ', description: 'Start with an imported definition by name.' }, + { flag: '--persist ', description: 'Write to an alternate mcporter.json path.' }, + { + flag: '--scope ', + description: 'Choose whether to write to the home or project config (default: project).', + }, + { flag: '--dry-run', description: 'Print the would-be entry without writing to disk.' }, + { flag: '--', description: 'Forward every subsequent token as a stdio arg.' }, + ], + examples: [ + 'pnpm mcporter config add linear https://mcp.linear.app/mcp', + 'pnpm mcporter config add cursor --command "npx -y cursor" --arg --stdio', + ], + }, + remove: { + name: 'remove ', + summary: 'Delete a local entry', + usage: 'mcporter config remove ', + description: 'Removes a server definition from the active mcporter.json file.', + examples: ['pnpm mcporter config remove linear'], + }, + import: { + name: 'import [options]', + summary: 'Inspect or copy imported servers', + usage: 'mcporter config import [options]', + description: + 'Shows entries from Cursor, Claude, Codex, and other supported imports. Optionally copies them locally.', + flags: [ + { flag: '--path ', description: 'Manually point at a config file path.' }, + { flag: '--filter ', description: 'Match server names by substring before listing/copying.' }, + { flag: '--copy', description: 'Write the filtered entries into the local config.' }, + { flag: '--json', description: 'Emit JSON instead of plain text listings.' }, + ], + examples: ['pnpm mcporter config import cursor --copy', 'pnpm mcporter config import claude --filter notion'], + }, + login: { + name: 'login [options]', + summary: 'Run the OAuth/auth flow', + usage: 'mcporter config login [options]', + description: 'Delegates to `mcporter auth`, so you can pass ephemeral flags like --http-url/--stdio/--reset.', + examples: ['pnpm mcporter config login linear', 'pnpm mcporter config login https://example.com/mcp --reset'], + }, + logout: { + name: 'logout ', + summary: 'Clear cached credentials', + usage: 'mcporter config logout ', + description: 'Deletes the token cache directory for an OAuth-enabled server.', + examples: ['pnpm mcporter config logout linear'], + }, + doctor: { + name: 'doctor', + summary: 'Validate config files', + usage: 'mcporter config doctor', + description: 'Validates config files, warns about missing token caches, and prints config locations.', + examples: ['pnpm mcporter config doctor'], + }, +}; + +export const CONFIG_HELP_ORDER: ConfigSubcommand[] = [ + 'list', + 'get', + 'add', + 'remove', + 'import', + 'login', + 'logout', + 'doctor', +]; + +export function isHelpToken(token: string): boolean { + return token === '--help' || token === '-h'; +} + +export function consumeInlineHelpTokens(args: string[]): boolean { + let found = false; + for (let index = args.length - 1; index >= 0; index -= 1) { + const token = args[index]; + if (token && isHelpToken(token)) { + args.splice(index, 1); + found = true; + } + } + return found; +} + +export function printConfigHelp(subcommand?: string): void { + const colorize = COLOR_ENABLED(); + if (!subcommand) { + printConfigOverview(colorize); + return; + } + const resolved = resolveHelpSubcommand(subcommand); + if (!resolved) { + console.log(`Unknown config subcommand '${subcommand}'. Available commands: ${CONFIG_HELP_ORDER.join(', ')}.`); + return; + } + printSubcommandHelp(resolved, colorize); +} + +function resolveHelpSubcommand(token: string | undefined): ConfigSubcommand | undefined { + if (!token) { + return undefined; + } + const normalized = token.toLowerCase() as ConfigSubcommand; + return normalized in CONFIG_HELP_ENTRIES ? normalized : undefined; +} + +function printConfigOverview(colorize: boolean): void { + const title = colorize ? boldText('mcporter config') : 'mcporter config'; + const subtitle = colorize + ? dimText('Manage configured MCP servers, imports, and ad-hoc discoveries.') + : 'Manage configured MCP servers, imports, and ad-hoc discoveries.'; + const commandsHeader = colorize ? boldText('Commands') : 'Commands'; + const examplesHeader = colorize ? boldText('Examples') : 'Examples'; + const lines: string[] = [title, subtitle, '', commandsHeader]; + const maxName = Math.max(...CONFIG_HELP_ORDER.map((key) => CONFIG_HELP_ENTRIES[key].name.length)); + for (const key of CONFIG_HELP_ORDER) { + const entry = CONFIG_HELP_ENTRIES[key]; + const padded = entry.name.padEnd(maxName); + const renderedName = colorize ? boldText(padded) : padded; + const renderedDesc = colorize ? dimText(entry.summary) : entry.summary; + lines.push(` ${renderedName} ${renderedDesc}`); + } + lines.push('', examplesHeader); + for (const example of printConfigUsageExamples()) { + lines.push(` ${colorize ? extraDimText(example) : example}`); + } + const pointer = "Run 'mcporter config --help' for detailed flag info."; + lines.push('', colorize ? extraDimText(pointer) : pointer); + console.log(lines.join('\n')); +} + +function printSubcommandHelp(subcommand: ConfigSubcommand, colorize: boolean): void { + const entry = CONFIG_HELP_ENTRIES[subcommand]; + const title = colorize ? boldText(`mcporter config ${subcommand}`) : `mcporter config ${subcommand}`; + const description = colorize ? dimText(entry.description) : entry.description; + const usageHeader = colorize ? boldText('Usage') : 'Usage'; + const lines: string[] = [title, description, '', usageHeader, ` ${entry.usage}`]; + if (entry.flags && entry.flags.length > 0) { + const flagsHeader = colorize ? boldText('Flags') : 'Flags'; + const maxFlag = Math.max(...entry.flags.map((flag) => flag.flag.length)); + lines.push('', flagsHeader); + for (const flag of entry.flags) { + const padded = flag.flag.padEnd(maxFlag); + const renderedFlag = colorize ? boldText(padded) : padded; + const renderedDesc = colorize ? dimText(flag.description) : flag.description; + lines.push(` ${renderedFlag} ${renderedDesc}`); + } + } + if (entry.examples && entry.examples.length > 0) { + const examplesHeader = colorize ? boldText('Examples') : 'Examples'; + lines.push('', examplesHeader); + for (const example of entry.examples) { + lines.push(` ${colorize ? extraDimText(example) : example}`); + } + } + console.log(lines.join('\n')); +} diff --git a/src/cli/config/import.ts b/src/cli/config/import.ts new file mode 100644 index 0000000..757daa0 --- /dev/null +++ b/src/cli/config/import.ts @@ -0,0 +1,97 @@ +import path from 'node:path'; +import type { RawEntry } from '../../config.js'; +import { writeRawConfig } from '../../config.js'; +import { pathsForImport, readExternalEntries } from '../../config-imports.js'; +import { expandHome } from '../../env.js'; +import { CliUsageError } from '../errors.js'; +import { cloneConfig, loadOrCreateConfig } from './shared.js'; +import type { ConfigCliOptions } from './types.js'; + +export interface ImportFlags { + path?: string; + filter?: string; + copy?: boolean; + format: 'text' | 'json'; +} + +export async function handleImportCommand(options: ConfigCliOptions, args: string[]): Promise { + const kind = args.shift(); + if (!kind) { + throw new CliUsageError('Usage: mcporter config import '); + } + const flags = extractImportFlags(args); + const rootDir = options.loadOptions.rootDir ?? process.cwd(); + const paths = flags.path ? [path.resolve(expandHome(flags.path))] : pathsForImport(kind as never, rootDir); + const entries: Array<{ name: string; entry: RawEntry; source: string }> = []; + const seenNames = new Set(); + for (const candidate of paths) { + const resolved = expandHome(candidate); + const map = await readExternalEntries(resolved, { projectRoot: rootDir, importKind: kind as never }); + if (!map) { + continue; + } + for (const [name, entry] of map) { + if (flags.filter && !name.includes(flags.filter)) { + continue; + } + if (seenNames.has(name)) { + continue; + } + seenNames.add(name); + entries.push({ name, entry, source: resolved }); + } + } + if (entries.length === 0) { + console.log('No entries found.'); + return; + } + if (flags.format === 'json') { + console.log(JSON.stringify({ entries }, null, 2)); + } else { + for (const item of entries) { + console.log(`${item.name} (${item.source})`); + } + } + if (flags.copy) { + const { config, path: configPath } = await loadOrCreateConfig(options.loadOptions); + const nextConfig = cloneConfig(config); + if (!nextConfig.mcpServers) { + nextConfig.mcpServers = {}; + } + for (const item of entries) { + nextConfig.mcpServers[item.name] = structuredClone(item.entry); + } + await writeRawConfig(configPath, nextConfig); + console.log(`Copied ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} to ${configPath}`); + } +} + +function extractImportFlags(args: string[]): ImportFlags { + const flags: ImportFlags = { format: 'text' }; + let index = 0; + while (index < args.length) { + const token = args[index]; + switch (token) { + case '--path': + flags.path = args[index + 1]; + args.splice(index, 2); + continue; + case '--filter': + flags.filter = args[index + 1]; + args.splice(index, 2); + continue; + case '--copy': + flags.copy = true; + args.splice(index, 1); + continue; + case '--json': + flags.format = 'json'; + args.splice(index, 1); + continue; + default: + index += 1; + break; + } + } + return flags; +} diff --git a/src/cli/config/index.ts b/src/cli/config/index.ts new file mode 100644 index 0000000..6d13275 --- /dev/null +++ b/src/cli/config/index.ts @@ -0,0 +1 @@ +export { __configCommandInternals, handleConfigCli } from '../config-command.js'; diff --git a/src/cli/config/list.ts b/src/cli/config/list.ts new file mode 100644 index 0000000..7120230 --- /dev/null +++ b/src/cli/config/list.ts @@ -0,0 +1,81 @@ +import { loadServerDefinitions } from '../../config.js'; +import type { ServerDefinition } from '../../config-schema.js'; +import { CliUsageError } from '../errors.js'; +import { dimText } from '../terminal.js'; +import { printImportSummary, printServerSummary, serializeDefinition } from './render.js'; +import { COLOR_ENABLED, logConfigLocations, resolveConfigLocations } from './shared.js'; +import type { ConfigCliOptions } from './types.js'; + +export interface ListFlags { + format: 'text' | 'json'; + source?: 'local' | 'import'; +} + +export async function handleListCommand(options: ConfigCliOptions, args: string[]): Promise { + const flags = extractListFlags(args); + const filter = args.shift(); + const servers = await loadServerDefinitions(options.loadOptions); + let filtered = servers; + if (flags.source) { + filtered = filtered.filter((server) => (server.source?.kind ?? 'local') === flags.source); + } + if (filter) { + filtered = filtered.filter((server) => filterMatches(filter, server)); + } + if (flags.format === 'json') { + const payload = filtered.map((server) => serializeDefinition(server)); + console.log(JSON.stringify({ servers: payload }, null, 2)); + return; + } + const colorize = COLOR_ENABLED(); + if (filtered.length === 0) { + console.log( + colorize + ? dimText('No local servers match the provided filters.') + : 'No local servers match the provided filters.' + ); + } else { + for (const server of filtered) { + printServerSummary(server); + } + } + if ((!flags.source || flags.source === 'local') && flags.format === 'text') { + printImportSummary(servers.filter((server) => server.source?.kind === 'import')); + } + if (flags.format === 'text') { + const summary = await resolveConfigLocations(options.loadOptions); + logConfigLocations(summary, { leadingNewline: true }); + } +} + +function extractListFlags(args: string[]): ListFlags { + const flags: ListFlags = { format: 'text', source: 'local' }; + let index = 0; + while (index < args.length) { + const token = args[index]; + if (token === '--json') { + flags.format = 'json'; + args.splice(index, 1); + continue; + } + if (token === '--source') { + const value = args[index + 1]; + if (value !== 'local' && value !== 'import') { + throw new CliUsageError("--source must be either 'local' or 'import'."); + } + flags.source = value; + args.splice(index, 2); + continue; + } + index += 1; + } + return flags; +} + +function filterMatches(filter: string, server: ServerDefinition): boolean { + if (filter.startsWith('source:')) { + const origin = server.source?.kind ?? 'local'; + return `source:${origin}` === filter; + } + return server.name.includes(filter); +} diff --git a/src/cli/config/remove.ts b/src/cli/config/remove.ts new file mode 100644 index 0000000..52621dd --- /dev/null +++ b/src/cli/config/remove.ts @@ -0,0 +1,20 @@ +import { writeRawConfig } from '../../config.js'; +import { CliUsageError } from '../errors.js'; +import { cloneConfig, findServerNameWithFuzzyMatch, loadOrCreateConfig } from './shared.js'; +import type { ConfigCliOptions } from './types.js'; + +export async function handleRemoveCommand(options: ConfigCliOptions, args: string[]): Promise { + const name = args.shift(); + if (!name) { + throw new CliUsageError('Usage: mcporter config remove '); + } + const { config, path: configPath } = await loadOrCreateConfig(options.loadOptions); + const targetName = findServerNameWithFuzzyMatch(name, Object.keys(config.mcpServers ?? {})); + if (!targetName) { + throw new CliUsageError(`Server '${name}' does not exist in ${configPath}.`); + } + const nextConfig = cloneConfig(config); + delete nextConfig.mcpServers[targetName]; + await writeRawConfig(configPath, nextConfig); + console.log(`Removed '${targetName}' from ${configPath}`); +} diff --git a/src/cli/config/render.ts b/src/cli/config/render.ts new file mode 100644 index 0000000..3074456 --- /dev/null +++ b/src/cli/config/render.ts @@ -0,0 +1,116 @@ +import type { ServerDefinition } from '../../config-schema.js'; +import { boldText, dimText } from '../terminal.js'; +import { COLOR_ENABLED } from './shared.js'; + +export type SerializedServerDefinition = { + name: string; + description?: string; + source: NonNullable | { kind: 'local'; path: string }; + auth?: ServerDefinition['auth']; + tokenCacheDir?: string; + clientName?: string; + oauthRedirectUrl?: string; + env?: Record; + transport: 'http' | 'stdio'; + baseUrl?: string; + headers?: Record; + command?: string; + args?: string[]; + cwd?: string; +}; + +export function serializeDefinition(definition: ServerDefinition): SerializedServerDefinition { + const origin = definition.source ?? { kind: 'local', path: '' }; + if (definition.command.kind === 'http') { + return { + name: definition.name, + description: definition.description, + source: origin, + auth: definition.auth, + tokenCacheDir: definition.tokenCacheDir, + clientName: definition.clientName, + oauthRedirectUrl: definition.oauthRedirectUrl, + env: definition.env, + transport: 'http', + baseUrl: definition.command.url.href, + headers: definition.command.headers, + }; + } + return { + name: definition.name, + description: definition.description, + source: origin, + auth: definition.auth, + tokenCacheDir: definition.tokenCacheDir, + clientName: definition.clientName, + oauthRedirectUrl: definition.oauthRedirectUrl, + env: definition.env, + transport: 'stdio', + command: definition.command.command, + args: definition.command.args, + cwd: definition.command.cwd, + }; +} + +export function printServerSummary(definition: ServerDefinition): void { + const colorize = COLOR_ENABLED(); + const origin = definition.source; + const header = colorize ? boldText(definition.name) : definition.name; + const label = (text: string): string => (colorize ? dimText(text) : text); + console.log(header); + if (origin) { + console.log(` ${label('Source')}: ${origin.kind}${origin.path ? ` (${origin.path})` : ''}`); + } else { + console.log(` ${label('Source')}: local`); + } + if (definition.command.kind === 'http') { + console.log(` ${label('Transport')}: http (${definition.command.url.href})`); + } else { + const renderedArgs = definition.command.args.length > 0 ? ` ${definition.command.args.join(' ')}` : ''; + console.log(` ${label('Transport')}: stdio (${definition.command.command}${renderedArgs})`); + console.log(` ${label('CWD')}: ${definition.command.cwd}`); + } + if (definition.description) { + console.log(` ${label('Description')}: ${definition.description}`); + } + if (definition.auth === 'oauth') { + console.log(` ${label('Auth')}: oauth`); + } +} + +export function printImportSummary(importServers: ServerDefinition[]): void { + if (importServers.length === 0) { + return; + } + const colorize = COLOR_ENABLED(); + const grouped = new Map(); + for (const server of importServers) { + const sourcePath = server.source?.path ?? ''; + const list = grouped.get(sourcePath) ?? []; + list.push(server.name); + grouped.set(sourcePath, list); + } + console.log(''); + const header = colorize + ? boldText('Other sources available via --source import') + : 'Other sources available via --source import'; + console.log(header); + for (const [path, names] of grouped) { + names.sort(); + const sample = names.slice(0, 3).join(', '); + const suffix = names.length > 3 ? ', …' : ''; + const countLabel = `${names.length} server${names.length === 1 ? '' : 's'}`; + const pathLabel = colorize ? dimText(path) : path; + console.log(` ${pathLabel} — ${countLabel} (${sample}${suffix})`); + } + const guidance = 'Use `mcporter config import ` to copy them locally.'; + console.log(colorize ? dimText(guidance) : guidance); +} + +export function printConfigUsageExamples(): string[] { + return [ + 'pnpm mcporter config list --json', + 'pnpm mcporter config add linear https://mcp.linear.app/mcp', + 'pnpm mcporter config import cursor --copy', + ]; +} diff --git a/src/cli/config/shared.ts b/src/cli/config/shared.ts new file mode 100644 index 0000000..4356a97 --- /dev/null +++ b/src/cli/config/shared.ts @@ -0,0 +1,159 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import type { LoadConfigOptions, RawConfig } from '../../config.js'; +import { loadRawConfig, resolveConfigPath } from '../../config.js'; +import type { ServerDefinition } from '../../config-schema.js'; +import { CliUsageError } from '../errors.js'; +import { chooseClosestIdentifier, renderIdentifierResolutionMessages } from '../identifier-helpers.js'; +import { dimText, supportsAnsiColor } from '../terminal.js'; + +export const COLOR_ENABLED = (): boolean => Boolean(supportsAnsiColor && process.stdout.isTTY); + +export type ConfigLocationSummary = { + projectPath: string; + projectExists: boolean; + systemPath: string; + systemExists: boolean; +}; + +export function cloneConfig(config: RawConfig): RawConfig { + return { + mcpServers: config.mcpServers ? { ...config.mcpServers } : {}, + imports: config.imports ? [...config.imports] : [], + }; +} + +export async function loadOrCreateConfig(loadOptions: LoadConfigOptions): Promise<{ config: RawConfig; path: string }> { + try { + const { config, path } = await loadRawConfig(loadOptions); + return { config, path }; + } catch (error) { + if (isErrno(error, 'ENOENT')) { + const rootDir = loadOptions.rootDir ?? process.cwd(); + const resolved = resolveConfigPath(loadOptions.configPath, rootDir); + return { config: { mcpServers: {}, imports: [] }, path: resolved.path }; + } + throw error; + } +} + +export async function resolveConfigLocations(loadOptions: LoadConfigOptions): Promise { + const rootDir = loadOptions.rootDir ?? process.cwd(); + const projectPath = path.resolve(rootDir, 'config', 'mcporter.json'); + const projectExists = await pathExists(projectPath); + const systemCandidates = buildSystemConfigCandidates(); + const systemResolved = await resolveFirstExisting(systemCandidates); + return { + projectPath, + projectExists, + systemPath: systemResolved.path, + systemExists: systemResolved.exists, + }; +} + +export function logConfigLocations(summary: ConfigLocationSummary, options?: { leadingNewline?: boolean }): void { + const shouldAddNewline = options?.leadingNewline ?? true; + if (shouldAddNewline) { + console.log(''); + } + console.log(`Project config: ${formatPath(summary.projectPath, summary.projectExists)}`); + console.log(`System config: ${formatPath(summary.systemPath, summary.systemExists)}`); +} + +export function findServerNameWithFuzzyMatch( + name: string, + candidates: string[], + logger: ((message: string) => void) | null = console.log +): string | null { + if (candidates.includes(name)) { + return name; + } + const resolution = chooseClosestIdentifier(name, candidates); + if (!resolution) { + return null; + } + const messages = renderIdentifierResolutionMessages({ + entity: 'server', + attempted: name, + resolution, + }); + if (messages.auto && logger) { + logger(dimText(messages.auto)); + } + if (resolution.kind === 'auto') { + return resolution.value; + } + if (messages.suggest && logger) { + logger(dimText(messages.suggest)); + } + return null; +} + +export function resolveServerDefinition( + name: string, + servers: ServerDefinition[], + logger: ((message: string) => void) | null = console.log +): ServerDefinition { + const direct = servers.find((server) => server.name === name); + if (direct) { + return direct; + } + const resolution = chooseClosestIdentifier( + name, + servers.map((server) => server.name) + ); + if (!resolution) { + throw new CliUsageError(`[mcporter] Unknown server '${name}'.`); + } + const messages = renderIdentifierResolutionMessages({ + entity: 'server', + attempted: name, + resolution, + }); + if (messages.auto && logger) { + logger(dimText(messages.auto)); + } + if (resolution.kind === 'auto') { + const match = servers.find((server) => server.name === resolution.value); + if (match) { + return match; + } + } + if (messages.suggest && logger) { + logger(dimText(messages.suggest)); + } + throw new CliUsageError(`[mcporter] Unknown server '${name}'.`); +} + +function buildSystemConfigCandidates(): string[] { + const homeDir = os.homedir(); + const base = path.join(homeDir, '.mcporter'); + return [path.join(base, 'mcporter.json'), path.join(base, 'mcporter.jsonc')]; +} + +async function resolveFirstExisting(pathsToCheck: string[]): Promise<{ path: string; exists: boolean }> { + for (const candidate of pathsToCheck) { + if (await pathExists(candidate)) { + return { path: candidate, exists: true }; + } + } + return { path: pathsToCheck[0] ?? '', exists: false }; +} + +async function pathExists(targetPath: string): Promise { + try { + await fs.access(targetPath); + return true; + } catch { + return false; + } +} + +function formatPath(targetPath: string, exists: boolean): string { + return exists ? targetPath : `${targetPath} (missing)`; +} + +function isErrno(error: unknown, code: string): error is NodeJS.ErrnoException { + return Boolean(error && typeof error === 'object' && (error as NodeJS.ErrnoException).code === code); +} diff --git a/src/cli/config/types.ts b/src/cli/config/types.ts new file mode 100644 index 0000000..9f5ab62 --- /dev/null +++ b/src/cli/config/types.ts @@ -0,0 +1,8 @@ +import type { LoadConfigOptions } from '../../config.js'; + +export interface ConfigCliOptions { + readonly loadOptions: LoadConfigOptions; + readonly invokeAuth: (args: string[]) => Promise; +} + +export type ConfigScope = 'home' | 'project'; diff --git a/tests/config-add-dry-run.test.ts b/tests/config-add-dry-run.test.ts new file mode 100644 index 0000000..3a01bd8 --- /dev/null +++ b/tests/config-add-dry-run.test.ts @@ -0,0 +1,45 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleAddCommand } from '../src/cli/config/add.js'; +import type { LoadConfigOptions } from '../src/config.js'; + +describe('config add --dry-run', () => { + let tempDir: string; + let loadOptions: LoadConfigOptions; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-add-')); + loadOptions = { rootDir: tempDir }; + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('prints entry without writing config when dry-run is set', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleAddCommand({ loadOptions } as never, [ + 'linear', + 'https://linear.app/mcp', + '--description', + 'test', + '--dry-run', + ]); + + const outputs = logSpy.mock.calls.map((call) => call[0]).join('\n'); + logSpy.mockRestore(); + + const configPath = path.join(tempDir, 'config', 'mcporter.json'); + const exists = await fs + .access(configPath) + .then(() => true) + .catch(() => false); + expect(exists).toBe(false); + expect(outputs).toContain('(dry-run) No changes were written.'); + expect(outputs).toContain('"linear"'); + }); +}); diff --git a/tests/config-add-flags.test.ts b/tests/config-add-flags.test.ts new file mode 100644 index 0000000..1e2f625 --- /dev/null +++ b/tests/config-add-flags.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleAddCommand } from '../src/cli/config/add.js'; +import type { LoadConfigOptions } from '../src/config.js'; +import { createTempConfig } from './fixtures/config-fixture.js'; + +describe('config add flag parsing', () => { + let loadOptions: LoadConfigOptions; + let cleanup: (() => Promise) | undefined; + + beforeEach(async () => { + const ctx = await createTempConfig(); + loadOptions = ctx.loadOptions; + cleanup = () => ctx.cleanup(); + }); + + afterEach(async () => { + if (cleanup) { + await cleanup(); + cleanup = undefined; + } + vi.restoreAllMocks(); + }); + + it('merges env, headers, description and auth metadata, and respects dry-run', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleAddCommand({ loadOptions } as never, [ + 'example', + 'https://api.example.com/mcp', + '--env', + 'FOO=bar', + '--header', + 'X-Test=1', + '--description', + 'desc', + '--auth', + 'oauth', + '--client-name', + 'mcporter', + '--oauth-redirect-url', + 'https://example.com/callback', + '--dry-run', + ]); + + const payloadLine = logSpy.mock.calls + .map((call) => call[0]) + .find((msg) => typeof msg === 'string' && msg.trim().startsWith('{')); + logSpy.mockRestore(); + + expect(payloadLine).toBeDefined(); + const payload = JSON.parse(String(payloadLine)) as Record; + const entry = payload.example as Record; + expect(entry.baseUrl).toBe('https://api.example.com/mcp'); + expect(entry.env).toEqual({ FOO: 'bar' }); + expect(entry.headers).toEqual({ 'X-Test': '1' }); + expect(entry.description).toBe('desc'); + expect(entry.auth).toBe('oauth'); + expect(entry.clientName).toBe('mcporter'); + expect(entry.oauthRedirectUrl).toBe('https://example.com/callback'); + }); +}); diff --git a/tests/config-add-persist.test.ts b/tests/config-add-persist.test.ts new file mode 100644 index 0000000..a47d907 --- /dev/null +++ b/tests/config-add-persist.test.ts @@ -0,0 +1,27 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { handleAddCommand } from '../src/cli/config/add.js'; +import { createTempConfig } from './fixtures/config-fixture.js'; + +describe('config add persist and scope', () => { + it('writes to custom persist path when provided', async () => { + const ctx = await createTempConfig(); + const persistPath = path.join(ctx.tempDir, 'custom', 'mcporter.json'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleAddCommand({ loadOptions: ctx.loadOptions } as never, [ + 'persisted', + 'https://persist.example/mcp', + '--persist', + persistPath, + ]); + + logSpy.mockRestore(); + const buffer = await fs.readFile(persistPath, 'utf8'); + const parsed = JSON.parse(buffer) as { mcpServers: Record }; + expect(parsed.mcpServers.persisted).toBeDefined(); + expect(parsed.mcpServers.persisted?.baseUrl).toBe('https://persist.example/mcp'); + await ctx.cleanup(); + }); +}); diff --git a/tests/config-add-scope-behavior.test.ts b/tests/config-add-scope-behavior.test.ts new file mode 100644 index 0000000..af50b9e --- /dev/null +++ b/tests/config-add-scope-behavior.test.ts @@ -0,0 +1,46 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleAddCommand } from '../src/cli/config/add.js'; +import type { LoadConfigOptions } from '../src/config.js'; + +describe('config add scope behavior', () => { + let projectDir: string; + let homeDir: string; + let loadOptions: LoadConfigOptions; + let restoreHomedir: (() => void) | undefined; + + beforeEach(async () => { + projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-add-scope-project-')); + homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-add-scope-home-')); + loadOptions = { rootDir: projectDir }; + const spy = vi.spyOn(os, 'homedir').mockReturnValue(homeDir); + restoreHomedir = () => spy.mockRestore(); + }); + + afterEach(async () => { + restoreHomedir?.(); + restoreHomedir = undefined; + await fs.rm(projectDir, { recursive: true, force: true }); + await fs.rm(homeDir, { recursive: true, force: true }); + }); + + it('writes to home config when scope=home', async () => { + await handleAddCommand({ loadOptions } as never, ['homescope', 'https://home.example/mcp', '--scope', 'home']); + const homeConfigPath = path.join(homeDir, '.mcporter', 'mcporter.json'); + const buffer = await fs.readFile(homeConfigPath, 'utf8'); + const parsed = JSON.parse(buffer) as { mcpServers: Record }; + expect(parsed.mcpServers.homescope).toBeDefined(); + expect(parsed.mcpServers.homescope?.baseUrl).toBe('https://home.example/mcp'); + }); + + it('writes to project config when scope=project', async () => { + await handleAddCommand({ loadOptions } as never, ['projects', 'https://project.example/mcp', '--scope', 'project']); + const projectConfigPath = path.join(projectDir, 'config', 'mcporter.json'); + const buffer = await fs.readFile(projectConfigPath, 'utf8'); + const parsed = JSON.parse(buffer) as { mcpServers: Record }; + expect(parsed.mcpServers.projects).toBeDefined(); + expect(parsed.mcpServers.projects?.baseUrl).toBe('https://project.example/mcp'); + }); +}); diff --git a/tests/config-add-sse.test.ts b/tests/config-add-sse.test.ts new file mode 100644 index 0000000..aa1fd7c --- /dev/null +++ b/tests/config-add-sse.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from 'vitest'; +import { handleAddCommand } from '../src/cli/config/add.js'; +import { createTempConfig } from './fixtures/config-fixture.js'; + +describe('config add with sse transport', () => { + it('accepts sse transport when url is provided', async () => { + const ctx = await createTempConfig(); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleAddCommand({ loadOptions: ctx.loadOptions } as never, [ + 'sse-server', + 'https://sse.example/mcp', + '--transport', + 'sse', + '--dry-run', + ]); + + logSpy.mockRestore(); + await ctx.cleanup(); + // If no error thrown, transport validation succeeded. Ensure dry-run printed payload. + expect(true).toBe(true); + }); +}); diff --git a/tests/config-doctor.test.ts b/tests/config-doctor.test.ts new file mode 100644 index 0000000..00754fe --- /dev/null +++ b/tests/config-doctor.test.ts @@ -0,0 +1,46 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleDoctorCommand } from '../src/cli/config/doctor.js'; +import type { LoadConfigOptions } from '../src/config.js'; +import * as configModule from '../src/config.js'; + +let tempDir: string; +let loadOptions: LoadConfigOptions; + +beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-doctor-')); + loadOptions = { rootDir: tempDir }; +}); + +afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe('config doctor', () => { + it('reports issues for stdio cwd and missing oauth token cache', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(configModule, 'loadServerDefinitions').mockResolvedValue([ + { + name: 'bad-stdio', + command: { kind: 'stdio', command: 'node', args: [], cwd: 'relative/path' }, + }, + { + name: 'oauth-missing-cache', + command: { kind: 'http', url: new URL('https://example.com/mcp'), headers: {} }, + auth: 'oauth', + tokenCacheDir: undefined, + }, + ]); + + await handleDoctorCommand({ loadOptions } as never, []); + + const output = logSpy.mock.calls.flat().join('\n'); + logSpy.mockRestore(); + + expect(output).toContain('has a non-absolute working directory'); + expect(output).toContain('enables OAuth but lacks a token cache directory'); + }); +}); diff --git a/tests/config-get-json.test.ts b/tests/config-get-json.test.ts new file mode 100644 index 0000000..3bcfbe3 --- /dev/null +++ b/tests/config-get-json.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest'; +import { handleGetCommand } from '../src/cli/config/get.js'; +import * as configModule from '../src/config.js'; +import type { ServerDefinition } from '../src/config-schema.js'; + +describe('config get --json', () => { + it('includes headers and env in JSON output', async () => { + const server: ServerDefinition = { + name: 'http-one', + command: { kind: 'http', url: new URL('https://api.example/mcp'), headers: { Authorization: 'Bearer token' } }, + env: { FOO: 'bar' }, + }; + vi.spyOn(configModule, 'loadServerDefinitions').mockResolvedValue([server]); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleGetCommand({ loadOptions: {} } as never, ['http-one', '--json']); + + const output = logSpy.mock.calls.map((call) => call[0]).join('\n'); + logSpy.mockRestore(); + const payload = JSON.parse(output) as { headers?: Record; env?: Record }; + expect(payload.headers?.Authorization).toBe('Bearer token'); + expect(payload.env?.FOO).toBe('bar'); + }); +}); diff --git a/tests/config-import-dedupe.test.ts b/tests/config-import-dedupe.test.ts new file mode 100644 index 0000000..7baae3e --- /dev/null +++ b/tests/config-import-dedupe.test.ts @@ -0,0 +1,27 @@ +import fs from 'node:fs/promises'; +import { describe, expect, it, vi } from 'vitest'; +import { handleImportCommand } from '../src/cli/config/import.js'; +import * as importModule from '../src/config-imports.js'; +import { createTempConfig } from './fixtures/config-fixture.js'; + +describe('config import deduplication', () => { + it('keeps first matching entry when multiple imports share names', async () => { + const ctx = await createTempConfig(); + const firstPath = '/first.json'; + const secondPath = '/second.json'; + vi.spyOn(importModule, 'pathsForImport').mockReturnValue([firstPath, secondPath]); + vi.spyOn(importModule, 'readExternalEntries') + .mockResolvedValueOnce(new Map([['dup', { baseUrl: 'https://first.example/mcp' }]]) as never) + .mockResolvedValueOnce(new Map([['dup', { baseUrl: 'https://second.example/mcp' }]]) as never); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await handleImportCommand({ loadOptions: ctx.loadOptions } as never, ['cursor', '--copy']); + logSpy.mockRestore(); + + const buffer = await fs.readFile(ctx.loadOptions.configPath ?? '', 'utf8'); + const parsed = JSON.parse(buffer) as { mcpServers: Record }; + expect(parsed.mcpServers.dup).toBeDefined(); + expect(parsed.mcpServers.dup?.baseUrl).toBe('https://first.example/mcp'); + await ctx.cleanup(); + }); +}); diff --git a/tests/config-import.test.ts b/tests/config-import.test.ts new file mode 100644 index 0000000..247c2fb --- /dev/null +++ b/tests/config-import.test.ts @@ -0,0 +1,64 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleImportCommand } from '../src/cli/config/import.js'; +import type { LoadConfigOptions, RawConfig } from '../src/config.js'; +import * as configModule from '../src/config.js'; +import * as importModule from '../src/config-imports.js'; + +let tempDir: string; +let loadOptions: LoadConfigOptions; + +beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-import-')); + loadOptions = { rootDir: tempDir }; +}); + +afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); +}); + +describe('config import', () => { + it('copies filtered entries into project config', async () => { + vi.spyOn(importModule, 'pathsForImport').mockReturnValue([path.join(tempDir, 'imports', 'cursor.json')]); + vi.spyOn(importModule, 'readExternalEntries').mockResolvedValue( + new Map([ + ['keep', { baseUrl: 'https://example.com/mcp' }], + ['skip', { baseUrl: 'https://skip.example/mcp' }], + ]) as never + ); + + let writtenConfig: RawConfig | undefined; + vi.spyOn(configModule, 'writeRawConfig').mockImplementation(async (_path, config) => { + writtenConfig = config as RawConfig; + }); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + await handleImportCommand({ loadOptions } as never, ['cursor', '--copy', '--filter', 'keep']); + logSpy.mockRestore(); + + expect(writtenConfig?.mcpServers?.keep).toBeDefined(); + expect(writtenConfig?.mcpServers?.skip).toBeUndefined(); + }); + + it('emits JSON when --json is provided', async () => { + vi.spyOn(importModule, 'pathsForImport').mockReturnValue([path.join(tempDir, 'imports', 'cursor.json')]); + vi.spyOn(importModule, 'readExternalEntries').mockResolvedValue( + new Map([['keep', { baseUrl: 'https://example.com/mcp' }]]) as never + ); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleImportCommand({ loadOptions } as never, ['cursor', '--json']); + + const json = logSpy.mock.calls + .map((call) => call[0]) + .find((msg) => typeof msg === 'string' && msg.trim().startsWith('{')); + logSpy.mockRestore(); + expect(json).toBeDefined(); + const payload = JSON.parse(String(json)) as { entries: Array<{ name: string }> }; + expect(payload.entries).toHaveLength(1); + expect(payload.entries[0]?.name).toBe('keep'); + }); +}); diff --git a/tests/config-list-text-footer.test.ts b/tests/config-list-text-footer.test.ts new file mode 100644 index 0000000..4e3e038 --- /dev/null +++ b/tests/config-list-text-footer.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it, vi } from 'vitest'; +import { handleListCommand } from '../src/cli/config/list.js'; +import * as shared from '../src/cli/config/shared.js'; +import * as configModule from '../src/config.js'; +import type { ServerDefinition } from '../src/config-schema.js'; + +describe('config list text footer and import summary', () => { + it('prints footer paths and import summary when no local matches', async () => { + const importServer: ServerDefinition = { + name: 'imported', + command: { kind: 'http', url: new URL('https://import.example/mcp'), headers: {} }, + source: { kind: 'import', path: '/tmp/import.json' }, + }; + vi.spyOn(configModule, 'loadServerDefinitions').mockResolvedValue([importServer]); + vi.spyOn(shared, 'resolveConfigLocations').mockResolvedValue({ + projectPath: '/tmp/project/config/mcporter.json', + projectExists: true, + systemPath: '/Users/test/.mcporter/mcporter.json', + systemExists: false, + }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleListCommand({ loadOptions: {} } as never, []); + + const output = logSpy.mock.calls.map((c) => c[0]).join('\n'); + logSpy.mockRestore(); + + expect(output).toContain('No local servers match'); + expect(output).toContain('Other sources available via --source import'); + expect(output).toContain('/tmp/project/config/mcporter.json'); + expect(output).toContain('/Users/test/.mcporter/mcporter.json (missing)'); + }); +}); diff --git a/tests/config-list.test.ts b/tests/config-list.test.ts new file mode 100644 index 0000000..e2629aa --- /dev/null +++ b/tests/config-list.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; +import { handleListCommand } from '../src/cli/config/list.js'; +import * as shared from '../src/cli/config/shared.js'; +import * as configModule from '../src/config.js'; +import type { ServerDefinition } from '../src/config-schema.js'; + +const localServer: ServerDefinition = { + name: 'local-one', + command: { kind: 'http', url: new URL('https://local.example/mcp'), headers: {} }, +}; + +const importServer: ServerDefinition = { + name: 'import-one', + command: { kind: 'stdio', command: 'bin', args: [], cwd: '/tmp' }, + source: { kind: 'import', path: '/imports/cursor.json' }, +}; + +describe('config list', () => { + it('filters by source and outputs json payload', async () => { + vi.spyOn(configModule, 'loadServerDefinitions').mockResolvedValue([localServer, importServer]); + vi.spyOn(shared, 'resolveConfigLocations').mockResolvedValue({ + projectPath: '/tmp/config/mcporter.json', + projectExists: true, + systemPath: '/home/user/.mcporter/mcporter.json', + systemExists: false, + }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleListCommand({ loadOptions: {} } as never, ['--json', '--source', 'import']); + + const jsonLine = logSpy.mock.calls + .map((call) => call[0]) + .find((msg) => typeof msg === 'string' && msg.trim().startsWith('{')); + logSpy.mockRestore(); + expect(jsonLine).toBeDefined(); + const payload = JSON.parse(String(jsonLine)); + expect(payload.servers).toHaveLength(1); + expect(payload.servers[0].name).toBe('import-one'); + }); + + it('shows only local servers when source=local', async () => { + vi.spyOn(configModule, 'loadServerDefinitions').mockResolvedValue([localServer, importServer]); + vi.spyOn(shared, 'resolveConfigLocations').mockResolvedValue({ + projectPath: '/tmp/config/mcporter.json', + projectExists: true, + systemPath: '/home/user/.mcporter/mcporter.json', + systemExists: false, + }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleListCommand({ loadOptions: {} } as never, ['--json', '--source', 'local']); + + const jsonLine = logSpy.mock.calls + .map((call) => call[0]) + .find((msg) => typeof msg === 'string' && msg.trim().startsWith('{')); + logSpy.mockRestore(); + expect(jsonLine).toBeDefined(); + const payload = JSON.parse(String(jsonLine)); + expect(payload.servers).toHaveLength(1); + expect(payload.servers[0].name).toBe('local-one'); + }); +}); diff --git a/tests/config-remove.test.ts b/tests/config-remove.test.ts new file mode 100644 index 0000000..e307ed3 --- /dev/null +++ b/tests/config-remove.test.ts @@ -0,0 +1,44 @@ +import fs from 'node:fs/promises'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { handleRemoveCommand } from '../src/cli/config/remove.js'; +import type { LoadConfigOptions, RawConfig } from '../src/config.js'; +import { createTempConfig } from './fixtures/config-fixture.js'; + +describe('config remove', () => { + let loadOptions: LoadConfigOptions; + let cleanup: (() => Promise) | undefined; + + beforeEach(async () => { + const initial: RawConfig = { + mcpServers: { + linear: { baseUrl: 'https://linear.app/mcp' }, + alpha: { baseUrl: 'https://alpha.example/mcp' }, + }, + imports: [], + }; + const ctx = await createTempConfig(initial); + loadOptions = ctx.loadOptions; + cleanup = () => ctx.cleanup(); + }); + + afterEach(async () => { + if (cleanup) { + await cleanup(); + cleanup = undefined; + } + vi.restoreAllMocks(); + }); + + it('fuzzy-matches server names when removing entries', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await handleRemoveCommand({ loadOptions } as never, ['linr']); + + const buffer = await fs.readFile(loadOptions.configPath ?? '', 'utf8'); + const parsed = JSON.parse(buffer) as RawConfig; + logSpy.mockRestore(); + + expect(parsed.mcpServers?.linear).toBeUndefined(); + expect(parsed.mcpServers?.alpha).toBeDefined(); + }); +}); diff --git a/tests/config-render.test.ts b/tests/config-render.test.ts new file mode 100644 index 0000000..bd8e997 --- /dev/null +++ b/tests/config-render.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { serializeDefinition } from '../src/cli/config/render.js'; +import type { ServerDefinition } from '../src/config-schema.js'; + +describe('config render helpers', () => { + it('serializes HTTP definitions with headers and oauth fields', () => { + const definition: ServerDefinition = { + name: 'http-server', + description: 'A test server', + command: { + kind: 'http', + url: new URL('https://example.com/mcp'), + headers: { Authorization: 'Bearer token' }, + }, + source: { kind: 'import', path: '/tmp/source.json' }, + auth: 'oauth', + tokenCacheDir: '/tmp/cache', + clientName: 'mcporter', + oauthRedirectUrl: 'https://example.com/callback', + env: { FOO: 'bar' }, + }; + + const payload = serializeDefinition(definition); + + expect(payload).toMatchObject({ + transport: 'http', + baseUrl: 'https://example.com/mcp', + headers: { Authorization: 'Bearer token' }, + auth: 'oauth', + tokenCacheDir: '/tmp/cache', + clientName: 'mcporter', + oauthRedirectUrl: 'https://example.com/callback', + env: { FOO: 'bar' }, + source: { kind: 'import', path: '/tmp/source.json' }, + }); + }); + + it('serializes stdio definitions with command metadata', () => { + const definition: ServerDefinition = { + name: 'stdio-server', + command: { + kind: 'stdio', + command: 'node', + args: ['--version'], + cwd: '/tmp', + }, + }; + + const payload = serializeDefinition(definition); + + expect(payload).toMatchObject({ + transport: 'stdio', + command: 'node', + args: ['--version'], + cwd: '/tmp', + name: 'stdio-server', + }); + }); +}); diff --git a/tests/config-shared.test.ts b/tests/config-shared.test.ts new file mode 100644 index 0000000..8ccdaf0 --- /dev/null +++ b/tests/config-shared.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; +import { findServerNameWithFuzzyMatch, resolveServerDefinition } from '../src/cli/config/shared.js'; +import type { ServerDefinition } from '../src/config-schema.js'; + +function buildServers(): ServerDefinition[] { + return [ + { + name: 'linear', + command: { kind: 'http', url: new URL('https://linear.app/mcp'), headers: {} }, + }, + { + name: 'slack', + command: { kind: 'stdio', command: 'slack-mcp', args: [], cwd: '/tmp' }, + }, + ]; +} + +describe('config shared helpers', () => { + it('auto-corrects close names when resolving server definitions', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const server = resolveServerDefinition('linea', buildServers(), logSpy); + logSpy.mockRestore(); + expect(server.name).toBe('linear'); + }); + + it('throws when no close match exists', () => { + expect(() => resolveServerDefinition('unknown', buildServers(), null)).toThrow(/Unknown server/); + }); + + it('returns matched name when helper finds direct match', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const match = findServerNameWithFuzzyMatch('linear', ['linear', 'other'], logSpy); + logSpy.mockRestore(); + expect(match).toBe('linear'); + }); + + it('returns null when fuzzy helper has no candidate', () => { + const match = findServerNameWithFuzzyMatch('nothing', ['alpha', 'beta'], null); + expect(match).toBeNull(); + }); +}); diff --git a/tests/fixtures/config-fixture.ts b/tests/fixtures/config-fixture.ts new file mode 100644 index 0000000..5b4e9eb --- /dev/null +++ b/tests/fixtures/config-fixture.ts @@ -0,0 +1,29 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import type { LoadConfigOptions, RawConfig } from '../../src/config.js'; + +type TempConfigCtx = { + tempDir: string; + configPath: string; + loadOptions: LoadConfigOptions; + cleanup(): Promise; +}; + +export async function createTempConfig(initial?: RawConfig): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-config-fixture-')); + const configPath = path.join(tempDir, 'config', 'mcporter.json'); + await fs.mkdir(path.dirname(configPath), { recursive: true }); + if (initial) { + await fs.writeFile(configPath, JSON.stringify(initial), 'utf8'); + } + const loadOptions: LoadConfigOptions = { rootDir: tempDir, configPath }; + return { + tempDir, + configPath, + loadOptions, + async cleanup() { + await fs.rm(tempDir, { recursive: true, force: true }); + }, + }; +}