From e3aba76cc87ad624e3bea345e9e45c84b082a582 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 22 Feb 2026 14:33:25 +0000 Subject: [PATCH] feat: add create/edit commands, apply-compatible output, better describe - `create server/profile/project` with all CLI flags (kubectl parity) - `edit server/profile/project` opens $EDITOR for in-flight editing - `get -o yaml/json` now outputs apply-compatible format (strips internal fields, wraps in resource key) - `describe` shows visually clean sectioned output with aligned columns - Extract shared utilities (resolveResource, resolveNameOrId, stripInternalFields) - Instances are immutable (no create/edit, like pods) - Full test coverage for create, edit, and updated describe/get Co-Authored-By: Claude Opus 4.6 --- src/cli/src/commands/create.ts | 114 +++++++++++++ src/cli/src/commands/delete.ts | 35 +--- src/cli/src/commands/describe.ts | 218 +++++++++++++++++++----- src/cli/src/commands/edit.ts | 114 +++++++++++++ src/cli/src/commands/get.ts | 46 ++--- src/cli/src/commands/project.ts | 14 +- src/cli/src/commands/shared.ts | 42 +++++ src/cli/src/index.ts | 13 ++ src/cli/tests/commands/create.test.ts | 144 ++++++++++++++++ src/cli/tests/commands/describe.test.ts | 82 ++++++--- src/cli/tests/commands/edit.test.ts | 180 +++++++++++++++++++ src/cli/tests/commands/get.test.ts | 24 ++- src/cli/tests/commands/project.test.ts | 12 -- src/cli/tests/e2e/cli-commands.test.ts | 8 +- 14 files changed, 905 insertions(+), 141 deletions(-) create mode 100644 src/cli/src/commands/create.ts create mode 100644 src/cli/src/commands/edit.ts create mode 100644 src/cli/src/commands/shared.ts create mode 100644 src/cli/tests/commands/create.test.ts create mode 100644 src/cli/tests/commands/edit.test.ts diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts new file mode 100644 index 0000000..f29a79f --- /dev/null +++ b/src/cli/src/commands/create.ts @@ -0,0 +1,114 @@ +import { Command } from 'commander'; +import type { ApiClient } from '../api-client.js'; +import { resolveNameOrId } from './shared.js'; + +export interface CreateCommandDeps { + client: ApiClient; + log: (...args: unknown[]) => void; +} + +function collect(value: string, prev: string[]): string[] { + return [...prev, value]; +} + +function parseEnvTemplate(entries: string[]): Array<{ name: string; description: string; isSecret: boolean }> { + return entries.map((entry) => { + const parts = entry.split(':'); + if (parts.length < 2) { + throw new Error(`Invalid env-template format '${entry}'. Expected NAME:description[:isSecret]`); + } + return { + name: parts[0]!, + description: parts[1]!, + isSecret: parts[2] === 'true', + }; + }); +} + +function parseEnvEntries(entries: string[]): Record { + const result: Record = {}; + for (const entry of entries) { + const eqIdx = entry.indexOf('='); + if (eqIdx === -1) { + throw new Error(`Invalid env format '${entry}'. Expected KEY=value`); + } + result[entry.slice(0, eqIdx)] = entry.slice(eqIdx + 1); + } + return result; +} + +export function createCreateCommand(deps: CreateCommandDeps): Command { + const { client, log } = deps; + + const cmd = new Command('create') + .description('Create a resource (server, profile, project)'); + + // --- create server --- + cmd.command('server') + .description('Create an MCP server definition') + .argument('', 'Server name (lowercase, hyphens allowed)') + .option('-d, --description ', 'Server description', '') + .option('--package-name ', 'NPM package name') + .option('--docker-image ', 'Docker image') + .option('--transport ', 'Transport type (STDIO, SSE, STREAMABLE_HTTP)', 'STDIO') + .option('--repository-url ', 'Source repository URL') + .option('--external-url ', 'External endpoint URL') + .option('--command ', 'Command argument (repeat for multiple)', collect, []) + .option('--container-port ', 'Container port number') + .option('--replicas ', 'Number of replicas', '1') + .option('--env-template ', 'Env template (NAME:description[:isSecret], repeat for multiple)', collect, []) + .action(async (name: string, opts) => { + const body: Record = { + name, + description: opts.description, + transport: opts.transport, + replicas: parseInt(opts.replicas, 10), + }; + if (opts.packageName) body.packageName = opts.packageName; + if (opts.dockerImage) body.dockerImage = opts.dockerImage; + if (opts.repositoryUrl) body.repositoryUrl = opts.repositoryUrl; + if (opts.externalUrl) body.externalUrl = opts.externalUrl; + if (opts.command.length > 0) body.command = opts.command; + if (opts.containerPort) body.containerPort = parseInt(opts.containerPort, 10); + if (opts.envTemplate.length > 0) body.envTemplate = parseEnvTemplate(opts.envTemplate); + + const server = await client.post<{ id: string; name: string }>('/api/v1/servers', body); + log(`server '${server.name}' created (id: ${server.id})`); + }); + + // --- create profile --- + cmd.command('profile') + .description('Create a profile for an MCP server') + .argument('', 'Profile name') + .requiredOption('--server ', 'Server name or ID') + .option('--permissions ', 'Permission (repeat for multiple)', collect, []) + .option('--env ', 'Environment override KEY=value (repeat for multiple)', collect, []) + .action(async (name: string, opts) => { + const serverId = await resolveNameOrId(client, 'servers', opts.server); + + const body: Record = { + name, + serverId, + }; + if (opts.permissions.length > 0) body.permissions = opts.permissions; + if (opts.env.length > 0) body.envOverrides = parseEnvEntries(opts.env); + + const profile = await client.post<{ id: string; name: string }>('/api/v1/profiles', body); + log(`profile '${profile.name}' created (id: ${profile.id})`); + }); + + // --- create project --- + cmd.command('project') + .description('Create a project') + .argument('', 'Project name') + .option('-d, --description ', 'Project description', '') + .action(async (name: string, opts) => { + const project = await client.post<{ id: string; name: string }>('/api/v1/projects', { + name, + description: opts.description, + }); + log(`project '${project.name}' created (id: ${project.id})`); + }); + + return cmd; +} diff --git a/src/cli/src/commands/delete.ts b/src/cli/src/commands/delete.ts index 719f6d4..5bbed69 100644 --- a/src/cli/src/commands/delete.ts +++ b/src/cli/src/commands/delete.ts @@ -1,21 +1,6 @@ import { Command } from 'commander'; import type { ApiClient } from '../api-client.js'; - -const RESOURCE_ALIASES: Record = { - server: 'servers', - srv: 'servers', - profile: 'profiles', - prof: 'profiles', - project: 'projects', - proj: 'projects', - instance: 'instances', - inst: 'instances', -}; - -function resolveResource(name: string): string { - const lower = name.toLowerCase(); - return RESOURCE_ALIASES[lower] ?? lower; -} +import { resolveResource, resolveNameOrId } from './shared.js'; export interface DeleteCommandDeps { client: ApiClient; @@ -32,18 +17,12 @@ export function createDeleteCommand(deps: DeleteCommandDeps): Command { .action(async (resourceArg: string, idOrName: string) => { const resource = resolveResource(resourceArg); - // Try to resolve name → ID for servers - let id = idOrName; - if (resource === 'servers' && !idOrName.match(/^c[a-z0-9]{24}/)) { - try { - const servers = await client.get>(`/api/v1/${resource}`); - const match = servers.find((s) => s.name === idOrName); - if (match) { - id = match.id; - } - } catch { - // Fall through with original id - } + // Resolve name → ID for any resource type + let id: string; + try { + id = await resolveNameOrId(client, resource, idOrName); + } catch { + id = idOrName; // Fall through with original } await client.delete(`/api/v1/${resource}/${id}`); diff --git a/src/cli/src/commands/describe.ts b/src/cli/src/commands/describe.ts index 675c348..79114fc 100644 --- a/src/cli/src/commands/describe.ts +++ b/src/cli/src/commands/describe.ts @@ -1,54 +1,166 @@ import { Command } from 'commander'; import { formatJson, formatYaml } from '../formatters/output.js'; +import { resolveResource, resolveNameOrId } from './shared.js'; +import type { ApiClient } from '../api-client.js'; export interface DescribeCommandDeps { + client: ApiClient; fetchResource: (resource: string, id: string) => Promise; fetchInspect?: (id: string) => Promise; log: (...args: string[]) => void; } -const RESOURCE_ALIASES: Record = { - server: 'servers', - srv: 'servers', - profile: 'profiles', - prof: 'profiles', - project: 'projects', - proj: 'projects', - instance: 'instances', - inst: 'instances', -}; - -function resolveResource(name: string): string { - const lower = name.toLowerCase(); - return RESOURCE_ALIASES[lower] ?? lower; +function pad(label: string, width = 18): string { + return label.padEnd(width); } -function formatDetail(obj: Record, indent = 0): string { - const pad = ' '.repeat(indent); +function formatServerDetail(server: Record): string { const lines: string[] = []; + lines.push(`=== Server: ${server.name} ===`); + lines.push(`${pad('Name:')}${server.name}`); + lines.push(`${pad('Transport:')}${server.transport ?? '-'}`); + lines.push(`${pad('Replicas:')}${server.replicas ?? 1}`); + if (server.dockerImage) lines.push(`${pad('Docker Image:')}${server.dockerImage}`); + if (server.packageName) lines.push(`${pad('Package:')}${server.packageName}`); + if (server.externalUrl) lines.push(`${pad('External URL:')}${server.externalUrl}`); + if (server.repositoryUrl) lines.push(`${pad('Repository:')}${server.repositoryUrl}`); + if (server.containerPort) lines.push(`${pad('Container Port:')}${server.containerPort}`); + if (server.description) lines.push(`${pad('Description:')}${server.description}`); - for (const [key, value] of Object.entries(obj)) { - if (value === null || value === undefined) { - lines.push(`${pad}${key}: -`); - } else if (Array.isArray(value)) { - if (value.length === 0) { - lines.push(`${pad}${key}: []`); - } else if (typeof value[0] === 'object') { - lines.push(`${pad}${key}:`); - for (const item of value) { - lines.push(`${pad} - ${JSON.stringify(item)}`); - } - } else { - lines.push(`${pad}${key}: ${value.join(', ')}`); - } - } else if (typeof value === 'object') { - lines.push(`${pad}${key}:`); - lines.push(formatDetail(value as Record, indent + 1)); - } else { - lines.push(`${pad}${key}: ${String(value)}`); + const command = server.command as string[] | null; + if (command && command.length > 0) { + lines.push(''); + lines.push('Command:'); + lines.push(` ${command.join(' ')}`); + } + + const envTemplate = server.envTemplate as Array<{ name: string; description: string; isSecret?: boolean }> | undefined; + if (envTemplate && envTemplate.length > 0) { + lines.push(''); + lines.push('Environment Template:'); + const nameW = Math.max(6, ...envTemplate.map((e) => e.name.length)) + 2; + const descW = Math.max(12, ...envTemplate.map((e) => e.description.length)) + 2; + lines.push(` ${'NAME'.padEnd(nameW)}${'DESCRIPTION'.padEnd(descW)}SECRET`); + for (const env of envTemplate) { + lines.push(` ${env.name.padEnd(nameW)}${env.description.padEnd(descW)}${env.isSecret ? 'yes' : 'no'}`); } } + lines.push(''); + lines.push('Metadata:'); + lines.push(` ${pad('ID:', 12)}${server.id}`); + if (server.createdAt) lines.push(` ${pad('Created:', 12)}${server.createdAt}`); + if (server.updatedAt) lines.push(` ${pad('Updated:', 12)}${server.updatedAt}`); + + return lines.join('\n'); +} + +function formatInstanceDetail(instance: Record, inspect?: Record): string { + const lines: string[] = []; + lines.push(`=== Instance: ${instance.id} ===`); + lines.push(`${pad('Status:')}${instance.status}`); + lines.push(`${pad('Server ID:')}${instance.serverId}`); + lines.push(`${pad('Container ID:')}${instance.containerId ?? '-'}`); + lines.push(`${pad('Port:')}${instance.port ?? '-'}`); + + const metadata = instance.metadata as Record | undefined; + if (metadata && Object.keys(metadata).length > 0) { + lines.push(''); + lines.push('Metadata:'); + for (const [key, value] of Object.entries(metadata)) { + lines.push(` ${pad(key + ':', 16)}${String(value)}`); + } + } + + if (inspect) { + lines.push(''); + lines.push('Container:'); + for (const [key, value] of Object.entries(inspect)) { + if (typeof value === 'object' && value !== null) { + lines.push(` ${key}: ${JSON.stringify(value)}`); + } else { + lines.push(` ${pad(key + ':', 16)}${String(value)}`); + } + } + } + + lines.push(''); + lines.push(` ${pad('ID:', 12)}${instance.id}`); + if (instance.createdAt) lines.push(` ${pad('Created:', 12)}${instance.createdAt}`); + if (instance.updatedAt) lines.push(` ${pad('Updated:', 12)}${instance.updatedAt}`); + + return lines.join('\n'); +} + +function formatProfileDetail(profile: Record): string { + const lines: string[] = []; + lines.push(`=== Profile: ${profile.name} ===`); + lines.push(`${pad('Name:')}${profile.name}`); + lines.push(`${pad('Server ID:')}${profile.serverId}`); + + const permissions = profile.permissions as string[] | undefined; + if (permissions && permissions.length > 0) { + lines.push(`${pad('Permissions:')}${permissions.join(', ')}`); + } + + const envOverrides = profile.envOverrides as Record | undefined; + if (envOverrides && Object.keys(envOverrides).length > 0) { + lines.push(''); + lines.push('Environment Overrides:'); + const keyW = Math.max(4, ...Object.keys(envOverrides).map((k) => k.length)) + 2; + for (const [key, value] of Object.entries(envOverrides)) { + lines.push(` ${key.padEnd(keyW)}${value}`); + } + } + + lines.push(''); + lines.push('Metadata:'); + lines.push(` ${pad('ID:', 12)}${profile.id}`); + if (profile.createdAt) lines.push(` ${pad('Created:', 12)}${profile.createdAt}`); + if (profile.updatedAt) lines.push(` ${pad('Updated:', 12)}${profile.updatedAt}`); + + return lines.join('\n'); +} + +function formatProjectDetail(project: Record): string { + const lines: string[] = []; + lines.push(`=== Project: ${project.name} ===`); + lines.push(`${pad('Name:')}${project.name}`); + if (project.description) lines.push(`${pad('Description:')}${project.description}`); + if (project.ownerId) lines.push(`${pad('Owner:')}${project.ownerId}`); + + lines.push(''); + lines.push('Metadata:'); + lines.push(` ${pad('ID:', 12)}${project.id}`); + if (project.createdAt) lines.push(` ${pad('Created:', 12)}${project.createdAt}`); + if (project.updatedAt) lines.push(` ${pad('Updated:', 12)}${project.updatedAt}`); + + return lines.join('\n'); +} + +function formatGenericDetail(obj: Record): string { + const lines: string[] = []; + for (const [key, value] of Object.entries(obj)) { + if (value === null || value === undefined) { + lines.push(`${pad(key + ':')} -`); + } else if (Array.isArray(value)) { + if (value.length === 0) { + lines.push(`${pad(key + ':')} []`); + } else { + lines.push(`${key}:`); + for (const item of value) { + lines.push(` - ${typeof item === 'object' ? JSON.stringify(item) : String(item)}`); + } + } + } else if (typeof value === 'object') { + lines.push(`${key}:`); + for (const [k, v] of Object.entries(value as Record)) { + lines.push(` ${pad(k + ':')}${String(v)}`); + } + } else { + lines.push(`${pad(key + ':')}${String(value)}`); + } + } return lines.join('\n'); } @@ -56,16 +168,26 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command { return new Command('describe') .description('Show detailed information about a resource') .argument('', 'resource type (server, profile, project, instance)') - .argument('', 'resource ID') + .argument('', 'resource ID or name') .option('-o, --output ', 'output format (detail, json, yaml)', 'detail') - .action(async (resourceArg: string, id: string, opts: { output: string }) => { + .action(async (resourceArg: string, idOrName: string, opts: { output: string }) => { const resource = resolveResource(resourceArg); + + // Resolve name → ID + let id: string; + try { + id = await resolveNameOrId(deps.client, resource, idOrName); + } catch { + id = idOrName; + } + const item = await deps.fetchResource(resource, id) as Record; // Enrich instances with container inspect data + let inspect: Record | undefined; if (resource === 'instances' && deps.fetchInspect && item.containerId) { try { - const inspect = await deps.fetchInspect(id); + inspect = await deps.fetchInspect(id) as Record; item.containerInspect = inspect; } catch { // Container may not be available @@ -77,9 +199,23 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command { } else if (opts.output === 'yaml') { deps.log(formatYaml(item)); } else { - const typeName = resource.replace(/s$/, '').charAt(0).toUpperCase() + resource.replace(/s$/, '').slice(1); - deps.log(`--- ${typeName} ---`); - deps.log(formatDetail(item)); + // Visually clean sectioned output + switch (resource) { + case 'servers': + deps.log(formatServerDetail(item)); + break; + case 'instances': + deps.log(formatInstanceDetail(item, inspect)); + break; + case 'profiles': + deps.log(formatProfileDetail(item)); + break; + case 'projects': + deps.log(formatProjectDetail(item)); + break; + default: + deps.log(formatGenericDetail(item)); + } } }); } diff --git a/src/cli/src/commands/edit.ts b/src/cli/src/commands/edit.ts new file mode 100644 index 0000000..3673dcd --- /dev/null +++ b/src/cli/src/commands/edit.ts @@ -0,0 +1,114 @@ +import { Command } from 'commander'; +import { writeFileSync, readFileSync, unlinkSync, mkdtempSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execSync } from 'node:child_process'; +import yaml from 'js-yaml'; +import type { ApiClient } from '../api-client.js'; +import { resolveResource, resolveNameOrId, stripInternalFields } from './shared.js'; + +export interface EditCommandDeps { + client: ApiClient; + log: (...args: unknown[]) => void; + /** Override for testing — return editor binary name. */ + getEditor?: () => string; + /** Override for testing — simulate opening the editor. */ + openEditor?: (filePath: string, editor: string) => void; +} + +function getEditor(deps: EditCommandDeps): string { + if (deps.getEditor) return deps.getEditor(); + return process.env.VISUAL ?? process.env.EDITOR ?? 'vi'; +} + +function openEditor(filePath: string, editor: string, deps: EditCommandDeps): void { + if (deps.openEditor) { + deps.openEditor(filePath, editor); + return; + } + execSync(`${editor} "${filePath}"`, { stdio: 'inherit' }); +} + +export function createEditCommand(deps: EditCommandDeps): Command { + const { client, log } = deps; + + return new Command('edit') + .description('Edit a resource in your default editor (server, profile, project)') + .argument('', 'Resource type (server, profile, project)') + .argument('', 'Resource name or ID') + .action(async (resourceArg: string, nameOrId: string) => { + const resource = resolveResource(resourceArg); + + // Instances are immutable + if (resource === 'instances') { + log('Error: instances are immutable and cannot be edited.'); + log('To change an instance, update the server definition and let reconciliation handle it.'); + process.exitCode = 1; + return; + } + + const validResources = ['servers', 'profiles', 'projects']; + if (!validResources.includes(resource)) { + log(`Error: unknown resource type '${resourceArg}'`); + process.exitCode = 1; + return; + } + + // Resolve name → ID + const id = await resolveNameOrId(client, resource, nameOrId); + + // Fetch current state + const current = await client.get>(`/api/v1/${resource}/${id}`); + + // Strip read-only fields for editor + const editable = stripInternalFields(current); + + // Serialize to YAML + const singular = resource.replace(/s$/, ''); + const header = `# Editing ${singular}: ${nameOrId}\n# Save and close to apply changes. Clear the file to cancel.\n`; + const originalYaml = yaml.dump(editable, { lineWidth: 120, noRefs: true }); + const content = header + originalYaml; + + // Write to temp file + const tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-edit-')); + const tmpFile = join(tmpDir, `${singular}-${nameOrId}.yaml`); + writeFileSync(tmpFile, content, 'utf-8'); + + try { + // Open editor + const editor = getEditor(deps); + openEditor(tmpFile, editor, deps); + + // Read back + const modified = readFileSync(tmpFile, 'utf-8'); + + // Strip comments for comparison + const modifiedClean = modified + .split('\n') + .filter((line) => !line.startsWith('#')) + .join('\n') + .trim(); + + if (!modifiedClean) { + log('Edit cancelled (empty file).'); + return; + } + + if (modifiedClean === originalYaml.trim()) { + log(`${singular} '${nameOrId}' unchanged.`); + return; + } + + // Parse and apply + const updates = yaml.load(modifiedClean) as Record; + await client.put(`/api/v1/${resource}/${id}`, updates); + log(`${singular} '${nameOrId}' updated.`); + } finally { + try { + unlinkSync(tmpFile); + } catch { + // Ignore cleanup errors + } + } + }); +} diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index 6cee2ce..e6754f3 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -2,6 +2,7 @@ import { Command } from 'commander'; import { formatTable } from '../formatters/table.js'; import { formatJson, formatYaml } from '../formatters/output.js'; import type { Column } from '../formatters/table.js'; +import { resolveResource, stripInternalFields } from './shared.js'; export interface GetCommandDeps { fetchResource: (resource: string, id?: string) => Promise; @@ -37,22 +38,6 @@ interface InstanceRow { port: number | null; } -const RESOURCE_ALIASES: Record = { - server: 'servers', - srv: 'servers', - profile: 'profiles', - prof: 'profiles', - project: 'projects', - proj: 'projects', - instance: 'instances', - inst: 'instances', -}; - -function resolveResource(name: string): string { - const lower = name.toLowerCase(); - return RESOURCE_ALIASES[lower] ?? lower; -} - const serverColumns: Column[] = [ { header: 'NAME', key: 'name' }, { header: 'TRANSPORT', key: 'transport', width: 16 }, @@ -100,21 +85,44 @@ function getColumnsForResource(resource: string): Column } } +/** + * Transform API response items into apply-compatible format. + * Strips internal fields and wraps in the resource key. + */ +function toApplyFormat(resource: string, items: unknown[]): Record { + const cleaned = items.map((item) => { + const obj = stripInternalFields(item as Record); + // For profiles: convert serverId → server (name) for apply compat + // We can't resolve the name here without an API call, so keep serverId + // but also remove it's not in the apply schema. Actually profiles use + // "server" (name) in apply format but serverId from API. Keep serverId + // since it can still be used with apply (the apply command resolves names). + return obj; + }); + return { [resource]: cleaned }; +} + export function createGetCommand(deps: GetCommandDeps): Command { return new Command('get') .description('List resources (servers, profiles, projects, instances)') .argument('', 'resource type (servers, profiles, projects, instances)') - .argument('[id]', 'specific resource ID') + .argument('[id]', 'specific resource ID or name') .option('-o, --output ', 'output format (table, json, yaml)', 'table') .action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => { const resource = resolveResource(resourceArg); const items = await deps.fetchResource(resource, id); if (opts.output === 'json') { - deps.log(formatJson(items.length === 1 ? items[0] : items)); + // Apply-compatible JSON wrapped in resource key + deps.log(formatJson(toApplyFormat(resource, items))); } else if (opts.output === 'yaml') { - deps.log(formatYaml(items.length === 1 ? items[0] : items)); + // Apply-compatible YAML wrapped in resource key + deps.log(formatYaml(toApplyFormat(resource, items))); } else { + if (items.length === 0) { + deps.log(`No ${resource} found.`); + return; + } const columns = getColumnsForResource(resource); deps.log(formatTable(items as Record[], columns)); } diff --git a/src/cli/src/commands/project.ts b/src/cli/src/commands/project.ts index 49ae894..6f5a3a2 100644 --- a/src/cli/src/commands/project.ts +++ b/src/cli/src/commands/project.ts @@ -25,19 +25,7 @@ export function createProjectCommand(deps: ProjectCommandDeps): Command { const cmd = new Command('project') .alias('proj') - .description('Project-specific actions (use "get projects" to list, "delete project" to remove)'); - - cmd - .command('create ') - .description('Create a new project') - .option('-d, --description ', 'Project description', '') - .action(async (name: string, opts: { description: string }) => { - const project = await client.post('/api/v1/projects', { - name, - description: opts.description, - }); - log(`Project '${project.name}' created (id: ${project.id})`); - }); + .description('Project-specific actions (create with "create project", list with "get projects")'); cmd .command('profiles ') diff --git a/src/cli/src/commands/shared.ts b/src/cli/src/commands/shared.ts new file mode 100644 index 0000000..ed73589 --- /dev/null +++ b/src/cli/src/commands/shared.ts @@ -0,0 +1,42 @@ +import type { ApiClient } from '../api-client.js'; + +export const RESOURCE_ALIASES: Record = { + server: 'servers', + srv: 'servers', + profile: 'profiles', + prof: 'profiles', + project: 'projects', + proj: 'projects', + instance: 'instances', + inst: 'instances', +}; + +export function resolveResource(name: string): string { + const lower = name.toLowerCase(); + return RESOURCE_ALIASES[lower] ?? lower; +} + +/** Resolve a name-or-ID to an ID. CUIDs pass through; names are looked up. */ +export async function resolveNameOrId( + client: ApiClient, + resource: string, + nameOrId: string, +): Promise { + // CUIDs start with 'c' followed by 24+ alphanumeric chars + if (/^c[a-z0-9]{24}/.test(nameOrId)) { + return nameOrId; + } + const items = await client.get>(`/api/v1/${resource}`); + const match = items.find((item) => item.name === nameOrId); + if (match) return match.id; + throw new Error(`${resource.replace(/s$/, '')} '${nameOrId}' not found`); +} + +/** Strip internal/read-only fields from an API response to make it apply-compatible. */ +export function stripInternalFields(obj: Record): Record { + const result = { ...obj }; + for (const key of ['id', 'createdAt', 'updatedAt', 'version', 'ownerId']) { + delete result[key]; + } + return result; +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 793cfe6..35c1dc7 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -8,6 +8,8 @@ import { createDescribeCommand } from './commands/describe.js'; import { createDeleteCommand } from './commands/delete.js'; import { createLogsCommand } from './commands/logs.js'; import { createApplyCommand } from './commands/apply.js'; +import { createCreateCommand } from './commands/create.js'; +import { createEditCommand } from './commands/edit.js'; import { createSetupCommand } from './commands/setup.js'; import { createClaudeCommand } from './commands/claude.js'; import { createProjectCommand } from './commands/project.js'; @@ -64,6 +66,7 @@ export function createProgram(): Command { })); program.addCommand(createDescribeCommand({ + client, fetchResource: fetchSingleResource, fetchInspect: async (id: string) => client.get(`/api/v1/instances/${id}/inspect`), log: (...args) => console.log(...args), @@ -79,6 +82,16 @@ export function createProgram(): Command { log: (...args) => console.log(...args), })); + program.addCommand(createCreateCommand({ + client, + log: (...args) => console.log(...args), + })); + + program.addCommand(createEditCommand({ + client, + log: (...args) => console.log(...args), + })); + program.addCommand(createApplyCommand({ client, log: (...args) => console.log(...args), diff --git a/src/cli/tests/commands/create.test.ts b/src/cli/tests/commands/create.test.ts new file mode 100644 index 0000000..20b9b6f --- /dev/null +++ b/src/cli/tests/commands/create.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createCreateCommand } from '../../src/commands/create.js'; +import type { ApiClient } from '../../src/api-client.js'; + +function mockClient(): ApiClient { + return { + get: vi.fn(async () => []), + post: vi.fn(async () => ({ id: 'new-id', name: 'test' })), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('create command', () => { + let client: ReturnType; + let output: string[]; + const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); + + beforeEach(() => { + client = mockClient(); + output = []; + }); + + describe('create server', () => { + it('creates a server with minimal flags', async () => { + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync(['server', 'my-server'], { from: 'user' }); + expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({ + name: 'my-server', + transport: 'STDIO', + replicas: 1, + })); + expect(output.join('\n')).toContain("server 'test' created"); + }); + + it('creates a server with all flags', async () => { + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync([ + 'server', 'ha-mcp', + '-d', 'Home Assistant MCP', + '--docker-image', 'ghcr.io/ha-mcp:latest', + '--transport', 'STREAMABLE_HTTP', + '--external-url', 'http://localhost:8086/mcp', + '--container-port', '3000', + '--replicas', '2', + '--command', 'python', + '--command', '-c', + '--command', 'print("hello")', + '--env-template', 'API_KEY:API key:true', + '--env-template', 'BASE_URL:Base URL:false', + ], { from: 'user' }); + + expect(client.post).toHaveBeenCalledWith('/api/v1/servers', { + name: 'ha-mcp', + description: 'Home Assistant MCP', + dockerImage: 'ghcr.io/ha-mcp:latest', + transport: 'STREAMABLE_HTTP', + externalUrl: 'http://localhost:8086/mcp', + containerPort: 3000, + replicas: 2, + command: ['python', '-c', 'print("hello")'], + envTemplate: [ + { name: 'API_KEY', description: 'API key', isSecret: true }, + { name: 'BASE_URL', description: 'Base URL', isSecret: false }, + ], + }); + }); + + it('defaults transport to STDIO', async () => { + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync(['server', 'test'], { from: 'user' }); + expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({ + transport: 'STDIO', + })); + }); + }); + + describe('create profile', () => { + it('creates a profile resolving server name', async () => { + vi.mocked(client.get).mockResolvedValue([ + { id: 'srv-abc', name: 'ha-mcp' }, + ]); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync(['profile', 'production', '--server', 'ha-mcp'], { from: 'user' }); + expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({ + name: 'production', + serverId: 'srv-abc', + })); + }); + + it('parses --env KEY=value entries', async () => { + vi.mocked(client.get).mockResolvedValue([ + { id: 'srv-1', name: 'test' }, + ]); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync([ + 'profile', 'dev', + '--server', 'test', + '--env', 'FOO=bar', + '--env', 'SECRET=s3cr3t', + ], { from: 'user' }); + expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({ + envOverrides: { FOO: 'bar', SECRET: 's3cr3t' }, + })); + }); + + it('passes permissions', async () => { + vi.mocked(client.get).mockResolvedValue([ + { id: 'srv-1', name: 'test' }, + ]); + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync([ + 'profile', 'admin', + '--server', 'test', + '--permissions', 'read', + '--permissions', 'write', + ], { from: 'user' }); + expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({ + permissions: ['read', 'write'], + })); + }); + }); + + describe('create project', () => { + it('creates a project', async () => { + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync(['project', 'my-project', '-d', 'A test project'], { from: 'user' }); + expect(client.post).toHaveBeenCalledWith('/api/v1/projects', { + name: 'my-project', + description: 'A test project', + }); + expect(output.join('\n')).toContain("project 'test' created"); + }); + + it('creates a project with no description', async () => { + const cmd = createCreateCommand({ client, log }); + await cmd.parseAsync(['project', 'minimal'], { from: 'user' }); + expect(client.post).toHaveBeenCalledWith('/api/v1/projects', { + name: 'minimal', + description: '', + }); + }); + }); +}); diff --git a/src/cli/tests/commands/describe.test.ts b/src/cli/tests/commands/describe.test.ts index 5162a88..d4adf0a 100644 --- a/src/cli/tests/commands/describe.test.ts +++ b/src/cli/tests/commands/describe.test.ts @@ -1,18 +1,29 @@ import { describe, it, expect, vi } from 'vitest'; import { createDescribeCommand } from '../../src/commands/describe.js'; import type { DescribeCommandDeps } from '../../src/commands/describe.js'; +import type { ApiClient } from '../../src/api-client.js'; + +function mockClient(): ApiClient { + return { + get: vi.fn(async () => []), + post: vi.fn(async () => ({})), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} function makeDeps(item: unknown = {}): DescribeCommandDeps & { output: string[] } { const output: string[] = []; return { output, + client: mockClient(), fetchResource: vi.fn(async () => item), log: (...args: string[]) => output.push(args.join(' ')), }; } describe('describe command', () => { - it('shows detailed server info', async () => { + it('shows detailed server info with sections', async () => { const deps = makeDeps({ id: 'srv-1', name: 'slack', @@ -20,16 +31,22 @@ describe('describe command', () => { packageName: '@slack/mcp', dockerImage: null, envTemplate: [], + createdAt: '2025-01-01', }); const cmd = createDescribeCommand(deps); await cmd.parseAsync(['node', 'test', 'server', 'srv-1']); expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1'); const text = deps.output.join('\n'); - expect(text).toContain('--- Server ---'); - expect(text).toContain('name: slack'); - expect(text).toContain('transport: STDIO'); - expect(text).toContain('dockerImage: -'); + expect(text).toContain('=== Server: slack ==='); + expect(text).toContain('Name:'); + expect(text).toContain('slack'); + expect(text).toContain('Transport:'); + expect(text).toContain('STDIO'); + expect(text).toContain('Package:'); + expect(text).toContain('@slack/mcp'); + expect(text).toContain('Metadata:'); + expect(text).toContain('ID:'); }); it('resolves resource aliases', async () => { @@ -55,31 +72,58 @@ describe('describe command', () => { expect(deps.output[0]).toContain('name: slack'); }); - it('formats nested objects', async () => { + it('shows profile with permissions and env overrides', async () => { const deps = makeDeps({ - id: 'srv-1', - name: 'slack', - metadata: { version: '1.0', nested: { deep: true } }, + id: 'p1', + name: 'production', + serverId: 'srv-1', + permissions: ['read', 'write'], + envOverrides: { FOO: 'bar', SECRET: 's3cr3t' }, + createdAt: '2025-01-01', }); const cmd = createDescribeCommand(deps); - await cmd.parseAsync(['node', 'test', 'server', 'srv-1']); + await cmd.parseAsync(['node', 'test', 'profile', 'p1']); const text = deps.output.join('\n'); - expect(text).toContain('metadata:'); - expect(text).toContain('version: 1.0'); + expect(text).toContain('=== Profile: production ==='); + expect(text).toContain('read, write'); + expect(text).toContain('Environment Overrides:'); + expect(text).toContain('FOO'); + expect(text).toContain('bar'); }); - it('formats arrays correctly', async () => { + it('shows project detail', async () => { const deps = makeDeps({ - id: 'srv-1', - permissions: ['read', 'write'], - envTemplate: [], + id: 'proj-1', + name: 'my-project', + description: 'A test project', + ownerId: 'user-1', + createdAt: '2025-01-01', }); const cmd = createDescribeCommand(deps); - await cmd.parseAsync(['node', 'test', 'server', 'srv-1']); + await cmd.parseAsync(['node', 'test', 'project', 'proj-1']); const text = deps.output.join('\n'); - expect(text).toContain('permissions: read, write'); - expect(text).toContain('envTemplate: []'); + expect(text).toContain('=== Project: my-project ==='); + expect(text).toContain('A test project'); + expect(text).toContain('user-1'); + }); + + it('shows instance detail with container info', async () => { + const deps = makeDeps({ + id: 'inst-1', + serverId: 'srv-1', + status: 'RUNNING', + containerId: 'abc123', + port: 3000, + createdAt: '2025-01-01', + }); + const cmd = createDescribeCommand(deps); + await cmd.parseAsync(['node', 'test', 'instance', 'inst-1']); + + const text = deps.output.join('\n'); + expect(text).toContain('=== Instance: inst-1 ==='); + expect(text).toContain('RUNNING'); + expect(text).toContain('abc123'); }); }); diff --git a/src/cli/tests/commands/edit.test.ts b/src/cli/tests/commands/edit.test.ts new file mode 100644 index 0000000..8fb1f45 --- /dev/null +++ b/src/cli/tests/commands/edit.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { readFileSync, writeFileSync } from 'node:fs'; +import yaml from 'js-yaml'; +import { createEditCommand } from '../../src/commands/edit.js'; +import type { ApiClient } from '../../src/api-client.js'; + +function mockClient(): ApiClient { + return { + get: vi.fn(async () => ({})), + post: vi.fn(async () => ({})), + put: vi.fn(async () => ({})), + delete: vi.fn(async () => {}), + } as unknown as ApiClient; +} + +describe('edit command', () => { + let client: ReturnType; + let output: string[]; + const log = (...args: unknown[]) => output.push(args.map(String).join(' ')); + + beforeEach(() => { + client = mockClient(); + output = []; + }); + + it('fetches server, opens editor, applies changes on save', async () => { + // GET /api/v1/servers returns list for resolveNameOrId + vi.mocked(client.get).mockImplementation(async (path: string) => { + if (path === '/api/v1/servers') { + return [{ id: 'srv-1', name: 'ha-mcp' }]; + } + // GET /api/v1/servers/srv-1 returns full server + return { + id: 'srv-1', + name: 'ha-mcp', + description: 'Old desc', + transport: 'STDIO', + replicas: 1, + createdAt: '2025-01-01', + updatedAt: '2025-01-01', + version: 1, + }; + }); + + const cmd = createEditCommand({ + client, + log, + getEditor: () => 'vi', + openEditor: (filePath) => { + // Simulate user editing the file + const content = readFileSync(filePath, 'utf-8'); + const modified = content + .replace('Old desc', 'New desc') + .replace('replicas: 1', 'replicas: 3'); + writeFileSync(filePath, modified, 'utf-8'); + }, + }); + + await cmd.parseAsync(['server', 'ha-mcp'], { from: 'user' }); + + expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({ + description: 'New desc', + replicas: 3, + })); + expect(output.join('\n')).toContain("server 'ha-mcp' updated"); + }); + + it('detects no changes and skips PUT', async () => { + vi.mocked(client.get).mockImplementation(async (path: string) => { + if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }]; + return { + id: 'srv-1', name: 'test', description: '', transport: 'STDIO', + createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1, + }; + }); + + const cmd = createEditCommand({ + client, + log, + getEditor: () => 'vi', + openEditor: () => { + // Don't modify the file + }, + }); + + await cmd.parseAsync(['server', 'test'], { from: 'user' }); + + expect(client.put).not.toHaveBeenCalled(); + expect(output.join('\n')).toContain("unchanged"); + }); + + it('handles empty file as cancel', async () => { + vi.mocked(client.get).mockImplementation(async (path: string) => { + if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }]; + return { id: 'srv-1', name: 'test', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 }; + }); + + const cmd = createEditCommand({ + client, + log, + getEditor: () => 'vi', + openEditor: (filePath) => { + writeFileSync(filePath, '', 'utf-8'); + }, + }); + + await cmd.parseAsync(['server', 'test'], { from: 'user' }); + + expect(client.put).not.toHaveBeenCalled(); + expect(output.join('\n')).toContain('cancelled'); + }); + + it('strips read-only fields from editor content', async () => { + vi.mocked(client.get).mockImplementation(async (path: string) => { + if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }]; + return { + id: 'srv-1', name: 'test', description: '', transport: 'STDIO', + createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1, + }; + }); + + let editorContent = ''; + const cmd = createEditCommand({ + client, + log, + getEditor: () => 'vi', + openEditor: (filePath) => { + editorContent = readFileSync(filePath, 'utf-8'); + }, + }); + + await cmd.parseAsync(['server', 'test'], { from: 'user' }); + + // The editor content should NOT contain read-only fields + expect(editorContent).not.toContain('id:'); + expect(editorContent).not.toContain('createdAt'); + expect(editorContent).not.toContain('updatedAt'); + expect(editorContent).not.toContain('version'); + // But should contain editable fields + expect(editorContent).toContain('name:'); + }); + + it('rejects edit instance with error message', async () => { + const cmd = createEditCommand({ client, log }); + + await cmd.parseAsync(['instance', 'inst-1'], { from: 'user' }); + + expect(client.get).not.toHaveBeenCalled(); + expect(client.put).not.toHaveBeenCalled(); + expect(output.join('\n')).toContain('immutable'); + }); + + it('edits a profile', async () => { + vi.mocked(client.get).mockImplementation(async (path: string) => { + if (path === '/api/v1/profiles') return [{ id: 'prof-1', name: 'production' }]; + return { + id: 'prof-1', name: 'production', serverId: 'srv-1', + permissions: ['read'], envOverrides: { FOO: 'bar' }, + createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1, + }; + }); + + const cmd = createEditCommand({ + client, + log, + getEditor: () => 'vi', + openEditor: (filePath) => { + const content = readFileSync(filePath, 'utf-8'); + const modified = content.replace('FOO: bar', 'FOO: baz'); + writeFileSync(filePath, modified, 'utf-8'); + }, + }); + + await cmd.parseAsync(['profile', 'production'], { from: 'user' }); + + expect(client.put).toHaveBeenCalledWith('/api/v1/profiles/prof-1', expect.objectContaining({ + envOverrides: { FOO: 'baz' }, + })); + }); +}); diff --git a/src/cli/tests/commands/get.test.ts b/src/cli/tests/commands/get.test.ts index c02a997..2a4f46b 100644 --- a/src/cli/tests/commands/get.test.ts +++ b/src/cli/tests/commands/get.test.ts @@ -41,20 +41,30 @@ describe('get command', () => { expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1'); }); - it('outputs JSON format', async () => { - const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]); + it('outputs apply-compatible JSON format', async () => { + const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 }]); const cmd = createGetCommand(deps); await cmd.parseAsync(['node', 'test', 'servers', '-o', 'json']); const parsed = JSON.parse(deps.output[0] ?? ''); - expect(parsed).toEqual({ id: 'srv-1', name: 'slack' }); + // Wrapped in resource key, internal fields stripped + expect(parsed).toHaveProperty('servers'); + expect(parsed.servers[0].name).toBe('slack'); + expect(parsed.servers[0]).not.toHaveProperty('id'); + expect(parsed.servers[0]).not.toHaveProperty('createdAt'); + expect(parsed.servers[0]).not.toHaveProperty('updatedAt'); + expect(parsed.servers[0]).not.toHaveProperty('version'); }); - it('outputs YAML format', async () => { - const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]); + it('outputs apply-compatible YAML format', async () => { + const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01' }]); const cmd = createGetCommand(deps); await cmd.parseAsync(['node', 'test', 'servers', '-o', 'yaml']); - expect(deps.output[0]).toContain('name: slack'); + const text = deps.output[0]; + expect(text).toContain('servers:'); + expect(text).toContain('name: slack'); + expect(text).not.toContain('id:'); + expect(text).not.toContain('createdAt:'); }); it('lists profiles with correct columns', async () => { @@ -81,6 +91,6 @@ describe('get command', () => { const deps = makeDeps([]); const cmd = createGetCommand(deps); await cmd.parseAsync(['node', 'test', 'servers']); - expect(deps.output[0]).toContain('No results'); + expect(deps.output[0]).toContain('No servers found'); }); }); diff --git a/src/cli/tests/commands/project.test.ts b/src/cli/tests/commands/project.test.ts index 9e665a5..ed2e0cf 100644 --- a/src/cli/tests/commands/project.test.ts +++ b/src/cli/tests/commands/project.test.ts @@ -21,18 +21,6 @@ describe('project command', () => { output = []; }); - describe('create', () => { - it('creates a project', async () => { - const cmd = createProjectCommand({ client, log }); - await cmd.parseAsync(['create', 'my-project', '-d', 'A test project'], { from: 'user' }); - expect(client.post).toHaveBeenCalledWith('/api/v1/projects', { - name: 'my-project', - description: 'A test project', - }); - expect(output.join('\n')).toContain("Project 'my-project' created"); - }); - }); - describe('profiles', () => { it('lists profiles for a project', async () => { vi.mocked(client.get).mockResolvedValue([ diff --git a/src/cli/tests/e2e/cli-commands.test.ts b/src/cli/tests/e2e/cli-commands.test.ts index 1a604e6..bdc348e 100644 --- a/src/cli/tests/e2e/cli-commands.test.ts +++ b/src/cli/tests/e2e/cli-commands.test.ts @@ -19,9 +19,13 @@ describe('CLI command registration (e2e)', () => { expect(commandNames).toContain('delete'); expect(commandNames).toContain('logs'); expect(commandNames).toContain('apply'); + expect(commandNames).toContain('create'); + expect(commandNames).toContain('edit'); expect(commandNames).toContain('setup'); expect(commandNames).toContain('claude'); expect(commandNames).toContain('project'); + expect(commandNames).toContain('backup'); + expect(commandNames).toContain('restore'); }); it('instance command is removed (use get/delete/logs instead)', () => { @@ -48,10 +52,10 @@ describe('CLI command registration (e2e)', () => { expect(project).toBeDefined(); const subcommands = project!.commands.map((c) => c.name()); - expect(subcommands).toContain('create'); expect(subcommands).toContain('profiles'); expect(subcommands).toContain('set-profiles'); - // list, show, delete are now top-level (get, describe, delete) + // create is now top-level (mcpctl create project) + expect(subcommands).not.toContain('create'); expect(subcommands).not.toContain('list'); expect(subcommands).not.toContain('show'); expect(subcommands).not.toContain('delete');