feat: add apply command for declarative config and interactive setup wizard
Apply reads YAML/JSON config files to sync servers, profiles, and projects to the daemon with create-or-update semantics. Setup provides an interactive wizard for configuring MCP servers with environment variables. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
173
src/cli/src/commands/apply.ts
Normal file
173
src/cli/src/commands/apply.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import yaml from 'js-yaml';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { ApiClient } from '../api-client.js';
|
||||||
|
|
||||||
|
const ServerSpecSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string().default(''),
|
||||||
|
packageName: z.string().optional(),
|
||||||
|
dockerImage: z.string().optional(),
|
||||||
|
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
|
||||||
|
repositoryUrl: z.string().url().optional(),
|
||||||
|
envTemplate: z.array(z.object({
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().default(''),
|
||||||
|
isSecret: z.boolean().default(false),
|
||||||
|
})).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ProfileSpecSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
server: z.string().min(1),
|
||||||
|
permissions: z.array(z.string()).default([]),
|
||||||
|
envOverrides: z.record(z.string()).default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ProjectSpecSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
description: z.string().default(''),
|
||||||
|
profiles: z.array(z.string()).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ApplyConfigSchema = z.object({
|
||||||
|
servers: z.array(ServerSpecSchema).default([]),
|
||||||
|
profiles: z.array(ProfileSpecSchema).default([]),
|
||||||
|
projects: z.array(ProjectSpecSchema).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ApplyConfig = z.infer<typeof ApplyConfigSchema>;
|
||||||
|
|
||||||
|
export interface ApplyCommandDeps {
|
||||||
|
client: ApiClient;
|
||||||
|
log: (...args: unknown[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApplyCommand(deps: ApplyCommandDeps): Command {
|
||||||
|
const { client, log } = deps;
|
||||||
|
|
||||||
|
return new Command('apply')
|
||||||
|
.description('Apply declarative configuration from a YAML or JSON file')
|
||||||
|
.argument('<file>', 'Path to config file (.yaml, .yml, or .json)')
|
||||||
|
.option('--dry-run', 'Validate and show changes without applying')
|
||||||
|
.action(async (file: string, opts: { dryRun?: boolean }) => {
|
||||||
|
const config = loadConfigFile(file);
|
||||||
|
|
||||||
|
if (opts.dryRun) {
|
||||||
|
log('Dry run - would apply:');
|
||||||
|
if (config.servers.length > 0) log(` ${config.servers.length} server(s)`);
|
||||||
|
if (config.profiles.length > 0) log(` ${config.profiles.length} profile(s)`);
|
||||||
|
if (config.projects.length > 0) log(` ${config.projects.length} project(s)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyConfig(client, config, log);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadConfigFile(path: string): ApplyConfig {
|
||||||
|
const raw = readFileSync(path, 'utf-8');
|
||||||
|
let parsed: unknown;
|
||||||
|
|
||||||
|
if (path.endsWith('.json')) {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} else {
|
||||||
|
parsed = yaml.load(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ApplyConfigSchema.parse(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args: unknown[]) => void): Promise<void> {
|
||||||
|
// Apply servers first (profiles depend on servers)
|
||||||
|
for (const server of config.servers) {
|
||||||
|
try {
|
||||||
|
const existing = await findByName(client, 'servers', server.name);
|
||||||
|
if (existing) {
|
||||||
|
await client.put(`/api/v1/servers/${(existing as { id: string }).id}`, server);
|
||||||
|
log(`Updated server: ${server.name}`);
|
||||||
|
} else {
|
||||||
|
await client.post('/api/v1/servers', server);
|
||||||
|
log(`Created server: ${server.name}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log(`Error applying server '${server.name}': ${err instanceof Error ? err.message : err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply profiles (need server IDs)
|
||||||
|
for (const profile of config.profiles) {
|
||||||
|
try {
|
||||||
|
const server = await findByName(client, 'servers', profile.server);
|
||||||
|
if (!server) {
|
||||||
|
log(`Skipping profile '${profile.name}': server '${profile.server}' not found`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const serverId = (server as { id: string }).id;
|
||||||
|
|
||||||
|
const existing = await findProfile(client, serverId, profile.name);
|
||||||
|
if (existing) {
|
||||||
|
await client.put(`/api/v1/profiles/${(existing as { id: string }).id}`, {
|
||||||
|
permissions: profile.permissions,
|
||||||
|
envOverrides: profile.envOverrides,
|
||||||
|
});
|
||||||
|
log(`Updated profile: ${profile.name} (server: ${profile.server})`);
|
||||||
|
} else {
|
||||||
|
await client.post('/api/v1/profiles', {
|
||||||
|
name: profile.name,
|
||||||
|
serverId,
|
||||||
|
permissions: profile.permissions,
|
||||||
|
envOverrides: profile.envOverrides,
|
||||||
|
});
|
||||||
|
log(`Created profile: ${profile.name} (server: ${profile.server})`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log(`Error applying profile '${profile.name}': ${err instanceof Error ? err.message : err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply projects
|
||||||
|
for (const project of config.projects) {
|
||||||
|
try {
|
||||||
|
const existing = await findByName(client, 'projects', project.name);
|
||||||
|
if (existing) {
|
||||||
|
await client.put(`/api/v1/projects/${(existing as { id: string }).id}`, {
|
||||||
|
description: project.description,
|
||||||
|
});
|
||||||
|
log(`Updated project: ${project.name}`);
|
||||||
|
} else {
|
||||||
|
await client.post('/api/v1/projects', {
|
||||||
|
name: project.name,
|
||||||
|
description: project.description,
|
||||||
|
});
|
||||||
|
log(`Created project: ${project.name}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log(`Error applying project '${project.name}': ${err instanceof Error ? err.message : err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findByName(client: ApiClient, resource: string, name: string): Promise<unknown | null> {
|
||||||
|
try {
|
||||||
|
const items = await client.get<Array<{ name: string }>>(`/api/v1/${resource}`);
|
||||||
|
return items.find((item) => item.name === name) ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findProfile(client: ApiClient, serverId: string, name: string): Promise<unknown | null> {
|
||||||
|
try {
|
||||||
|
const profiles = await client.get<Array<{ name: string; serverId: string }>>(
|
||||||
|
`/api/v1/profiles?serverId=${serverId}`,
|
||||||
|
);
|
||||||
|
return profiles.find((p) => p.name === name) ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export for testing
|
||||||
|
export { loadConfigFile, applyConfig };
|
||||||
103
src/cli/src/commands/setup.ts
Normal file
103
src/cli/src/commands/setup.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
import type { ApiClient } from '../api-client.js';
|
||||||
|
|
||||||
|
export interface SetupPromptDeps {
|
||||||
|
input: (message: string) => Promise<string>;
|
||||||
|
password: (message: string) => Promise<string>;
|
||||||
|
select: <T extends string>(message: string, choices: Array<{ name: string; value: T }>) => Promise<T>;
|
||||||
|
confirm: (message: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetupCommandDeps {
|
||||||
|
client: ApiClient;
|
||||||
|
prompt: SetupPromptDeps;
|
||||||
|
log: (...args: unknown[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSetupCommand(deps: SetupCommandDeps): Command {
|
||||||
|
const { client, prompt, log } = deps;
|
||||||
|
|
||||||
|
return new Command('setup')
|
||||||
|
.description('Interactive wizard for configuring an MCP server')
|
||||||
|
.argument('[server-name]', 'Server name to set up (will prompt if not given)')
|
||||||
|
.action(async (serverName?: string) => {
|
||||||
|
log('MCP Server Setup Wizard\n');
|
||||||
|
|
||||||
|
// Step 1: Server name
|
||||||
|
const name = serverName ?? await prompt.input('Server name (lowercase, hyphens allowed):');
|
||||||
|
if (!name) {
|
||||||
|
log('Setup cancelled.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Transport
|
||||||
|
const transport = await prompt.select('Transport type:', [
|
||||||
|
{ name: 'STDIO (command-line process)', value: 'STDIO' as const },
|
||||||
|
{ name: 'SSE (Server-Sent Events over HTTP)', value: 'SSE' as const },
|
||||||
|
{ name: 'Streamable HTTP', value: 'STREAMABLE_HTTP' as const },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Step 3: Package or image
|
||||||
|
const packageName = await prompt.input('NPM package name (or leave empty):');
|
||||||
|
const dockerImage = await prompt.input('Docker image (or leave empty):');
|
||||||
|
|
||||||
|
// Step 4: Description
|
||||||
|
const description = await prompt.input('Description:');
|
||||||
|
|
||||||
|
// Step 5: Create the server
|
||||||
|
const serverData: Record<string, unknown> = {
|
||||||
|
name,
|
||||||
|
transport,
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
if (packageName) serverData.packageName = packageName;
|
||||||
|
if (dockerImage) serverData.dockerImage = dockerImage;
|
||||||
|
|
||||||
|
let server: { id: string; name: string };
|
||||||
|
try {
|
||||||
|
server = await client.post<{ id: string; name: string }>('/api/v1/servers', serverData);
|
||||||
|
log(`\nServer '${server.name}' created.`);
|
||||||
|
} catch (err) {
|
||||||
|
log(`\nFailed to create server: ${err instanceof Error ? err.message : err}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Create a profile with env vars
|
||||||
|
const createProfile = await prompt.confirm('Create a profile with environment variables?');
|
||||||
|
if (!createProfile) {
|
||||||
|
log('\nSetup complete!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileName = await prompt.input('Profile name:') || 'default';
|
||||||
|
|
||||||
|
// Collect env vars
|
||||||
|
const envOverrides: Record<string, string> = {};
|
||||||
|
let addMore = true;
|
||||||
|
while (addMore) {
|
||||||
|
const envName = await prompt.input('Environment variable name (empty to finish):');
|
||||||
|
if (!envName) break;
|
||||||
|
|
||||||
|
const isSecret = await prompt.confirm(`Is '${envName}' a secret (e.g., API key)?`);
|
||||||
|
const envValue = isSecret
|
||||||
|
? await prompt.password(`Value for ${envName}:`)
|
||||||
|
: await prompt.input(`Value for ${envName}:`);
|
||||||
|
|
||||||
|
envOverrides[envName] = envValue;
|
||||||
|
addMore = await prompt.confirm('Add another environment variable?');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.post('/api/v1/profiles', {
|
||||||
|
name: profileName,
|
||||||
|
serverId: server.id,
|
||||||
|
envOverrides,
|
||||||
|
});
|
||||||
|
log(`Profile '${profileName}' created for server '${name}'.`);
|
||||||
|
} catch (err) {
|
||||||
|
log(`Failed to create profile: ${err instanceof Error ? err.message : err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log('\nSetup complete!');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { createStatusCommand } from './commands/status.js';
|
|||||||
import { createGetCommand } from './commands/get.js';
|
import { createGetCommand } from './commands/get.js';
|
||||||
import { createDescribeCommand } from './commands/describe.js';
|
import { createDescribeCommand } from './commands/describe.js';
|
||||||
import { createInstanceCommands } from './commands/instances.js';
|
import { createInstanceCommands } from './commands/instances.js';
|
||||||
|
import { createApplyCommand } from './commands/apply.js';
|
||||||
|
import { createSetupCommand } from './commands/setup.js';
|
||||||
import { ApiClient } from './api-client.js';
|
import { ApiClient } from './api-client.js';
|
||||||
import { loadConfig } from './config/index.js';
|
import { loadConfig } from './config/index.js';
|
||||||
|
|
||||||
@@ -52,6 +54,38 @@ export function createProgram(): Command {
|
|||||||
log: (...args) => console.log(...args),
|
log: (...args) => console.log(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
program.addCommand(createApplyCommand({
|
||||||
|
client,
|
||||||
|
log: (...args) => console.log(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
program.addCommand(createSetupCommand({
|
||||||
|
client,
|
||||||
|
prompt: {
|
||||||
|
async input(message) {
|
||||||
|
const { default: inquirer } = await import('inquirer');
|
||||||
|
const { answer } = await inquirer.prompt([{ type: 'input', name: 'answer', message }]);
|
||||||
|
return answer as string;
|
||||||
|
},
|
||||||
|
async password(message) {
|
||||||
|
const { default: inquirer } = await import('inquirer');
|
||||||
|
const { answer } = await inquirer.prompt([{ type: 'password', name: 'answer', message }]);
|
||||||
|
return answer as string;
|
||||||
|
},
|
||||||
|
async select(message, choices) {
|
||||||
|
const { default: inquirer } = await import('inquirer');
|
||||||
|
const { answer } = await inquirer.prompt([{ type: 'list', name: 'answer', message, choices }]);
|
||||||
|
return answer;
|
||||||
|
},
|
||||||
|
async confirm(message) {
|
||||||
|
const { default: inquirer } = await import('inquirer');
|
||||||
|
const { answer } = await inquirer.prompt([{ type: 'confirm', name: 'answer', message }]);
|
||||||
|
return answer as boolean;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
log: (...args) => console.log(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
return program;
|
return program;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
166
src/cli/tests/commands/apply.test.ts
Normal file
166
src/cli/tests/commands/apply.test.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { createApplyCommand } from '../../src/commands/apply.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 () => ({ id: 'existing-id', name: 'test' })),
|
||||||
|
delete: vi.fn(async () => {}),
|
||||||
|
} as unknown as ApiClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('apply command', () => {
|
||||||
|
let client: ReturnType<typeof mockClient>;
|
||||||
|
let output: string[];
|
||||||
|
let tmpDir: string;
|
||||||
|
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client = mockClient();
|
||||||
|
output = [];
|
||||||
|
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-test-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies servers from YAML file', async () => {
|
||||||
|
const configPath = join(tmpDir, 'config.yaml');
|
||||||
|
writeFileSync(configPath, `
|
||||||
|
servers:
|
||||||
|
- name: slack
|
||||||
|
description: Slack MCP server
|
||||||
|
transport: STDIO
|
||||||
|
packageName: "@anthropic/slack-mcp"
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cmd = createApplyCommand({ client, log });
|
||||||
|
await cmd.parseAsync([configPath], { from: 'user' });
|
||||||
|
|
||||||
|
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({ name: 'slack' }));
|
||||||
|
expect(output.join('\n')).toContain('Created server: slack');
|
||||||
|
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies servers from JSON file', async () => {
|
||||||
|
const configPath = join(tmpDir, 'config.json');
|
||||||
|
writeFileSync(configPath, JSON.stringify({
|
||||||
|
servers: [{ name: 'github', transport: 'STDIO' }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const cmd = createApplyCommand({ client, log });
|
||||||
|
await cmd.parseAsync([configPath], { from: 'user' });
|
||||||
|
|
||||||
|
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({ name: 'github' }));
|
||||||
|
expect(output.join('\n')).toContain('Created server: github');
|
||||||
|
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates existing servers', async () => {
|
||||||
|
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'slack' }]);
|
||||||
|
|
||||||
|
const configPath = join(tmpDir, 'config.yaml');
|
||||||
|
writeFileSync(configPath, `
|
||||||
|
servers:
|
||||||
|
- name: slack
|
||||||
|
description: Updated description
|
||||||
|
transport: STDIO
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cmd = createApplyCommand({ client, log });
|
||||||
|
await cmd.parseAsync([configPath], { from: 'user' });
|
||||||
|
|
||||||
|
expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({ name: 'slack' }));
|
||||||
|
expect(output.join('\n')).toContain('Updated server: slack');
|
||||||
|
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports dry-run mode', async () => {
|
||||||
|
const configPath = join(tmpDir, 'config.yaml');
|
||||||
|
writeFileSync(configPath, `
|
||||||
|
servers:
|
||||||
|
- name: test
|
||||||
|
transport: STDIO
|
||||||
|
profiles:
|
||||||
|
- name: default
|
||||||
|
server: test
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cmd = createApplyCommand({ client, log });
|
||||||
|
await cmd.parseAsync([configPath, '--dry-run'], { from: 'user' });
|
||||||
|
|
||||||
|
expect(client.post).not.toHaveBeenCalled();
|
||||||
|
expect(output.join('\n')).toContain('Dry run');
|
||||||
|
expect(output.join('\n')).toContain('1 server(s)');
|
||||||
|
expect(output.join('\n')).toContain('1 profile(s)');
|
||||||
|
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies profiles with server lookup', async () => {
|
||||||
|
vi.mocked(client.get).mockImplementation(async (url: string) => {
|
||||||
|
if (url === '/api/v1/servers') return [{ id: 'srv-1', name: 'slack' }];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const configPath = join(tmpDir, 'config.yaml');
|
||||||
|
writeFileSync(configPath, `
|
||||||
|
profiles:
|
||||||
|
- name: default
|
||||||
|
server: slack
|
||||||
|
envOverrides:
|
||||||
|
SLACK_TOKEN: "xoxb-test"
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cmd = createApplyCommand({ client, log });
|
||||||
|
await cmd.parseAsync([configPath], { from: 'user' });
|
||||||
|
|
||||||
|
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({
|
||||||
|
name: 'default',
|
||||||
|
serverId: 'srv-1',
|
||||||
|
envOverrides: { SLACK_TOKEN: 'xoxb-test' },
|
||||||
|
}));
|
||||||
|
expect(output.join('\n')).toContain('Created profile: default');
|
||||||
|
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips profiles when server not found', async () => {
|
||||||
|
const configPath = join(tmpDir, 'config.yaml');
|
||||||
|
writeFileSync(configPath, `
|
||||||
|
profiles:
|
||||||
|
- name: default
|
||||||
|
server: nonexistent
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cmd = createApplyCommand({ client, log });
|
||||||
|
await cmd.parseAsync([configPath], { from: 'user' });
|
||||||
|
|
||||||
|
expect(client.post).not.toHaveBeenCalled();
|
||||||
|
expect(output.join('\n')).toContain("Skipping profile 'default'");
|
||||||
|
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies projects', async () => {
|
||||||
|
const configPath = join(tmpDir, 'config.yaml');
|
||||||
|
writeFileSync(configPath, `
|
||||||
|
projects:
|
||||||
|
- name: my-project
|
||||||
|
description: A test project
|
||||||
|
`);
|
||||||
|
|
||||||
|
const cmd = createApplyCommand({ client, log });
|
||||||
|
await cmd.parseAsync([configPath], { from: 'user' });
|
||||||
|
|
||||||
|
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({ name: 'my-project' }));
|
||||||
|
expect(output.join('\n')).toContain('Created project: my-project');
|
||||||
|
|
||||||
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
141
src/cli/tests/commands/setup.test.ts
Normal file
141
src/cli/tests/commands/setup.test.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { createSetupCommand } from '../../src/commands/setup.js';
|
||||||
|
import type { ApiClient } from '../../src/api-client.js';
|
||||||
|
import type { SetupPromptDeps } from '../../src/commands/setup.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockPrompt(answers: Record<string, string | boolean>): SetupPromptDeps {
|
||||||
|
const answersQueue = { ...answers };
|
||||||
|
return {
|
||||||
|
input: vi.fn(async (message: string) => {
|
||||||
|
for (const [key, val] of Object.entries(answersQueue)) {
|
||||||
|
if (message.toLowerCase().includes(key.toLowerCase()) && typeof val === 'string') {
|
||||||
|
delete answersQueue[key];
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}),
|
||||||
|
password: vi.fn(async () => 'secret-value'),
|
||||||
|
select: vi.fn(async () => 'STDIO') as SetupPromptDeps['select'],
|
||||||
|
confirm: vi.fn(async (message: string) => {
|
||||||
|
if (message.includes('profile')) return true;
|
||||||
|
if (message.includes('secret')) return false;
|
||||||
|
if (message.includes('another')) return false;
|
||||||
|
return false;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('setup command', () => {
|
||||||
|
let client: ReturnType<typeof mockClient>;
|
||||||
|
let output: string[];
|
||||||
|
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
client = mockClient();
|
||||||
|
output = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates server with prompted values', async () => {
|
||||||
|
const prompt = mockPrompt({
|
||||||
|
'transport': 'STDIO',
|
||||||
|
'npm package': '@anthropic/slack-mcp',
|
||||||
|
'docker image': '',
|
||||||
|
'description': 'Slack server',
|
||||||
|
'profile name': 'default',
|
||||||
|
'environment variable name': '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const cmd = createSetupCommand({ client, prompt, log });
|
||||||
|
await cmd.parseAsync(['slack'], { from: 'user' });
|
||||||
|
|
||||||
|
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({
|
||||||
|
name: 'slack',
|
||||||
|
transport: 'STDIO',
|
||||||
|
}));
|
||||||
|
expect(output.join('\n')).toContain("Server 'test' created");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates profile with env vars', async () => {
|
||||||
|
vi.mocked(client.post)
|
||||||
|
.mockResolvedValueOnce({ id: 'srv-1', name: 'slack' }) // server create
|
||||||
|
.mockResolvedValueOnce({ id: 'prof-1', name: 'default' }); // profile create
|
||||||
|
|
||||||
|
const prompt = mockPrompt({
|
||||||
|
'transport': 'STDIO',
|
||||||
|
'npm package': '',
|
||||||
|
'docker image': '',
|
||||||
|
'description': '',
|
||||||
|
'profile name': 'default',
|
||||||
|
});
|
||||||
|
// Override confirm to create profile and add one env var
|
||||||
|
let confirmCallCount = 0;
|
||||||
|
vi.mocked(prompt.confirm).mockImplementation(async (msg: string) => {
|
||||||
|
confirmCallCount++;
|
||||||
|
if (msg.includes('profile')) return true;
|
||||||
|
if (msg.includes('secret')) return true;
|
||||||
|
if (msg.includes('another')) return false;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
// Override input to provide env var name then empty to stop
|
||||||
|
let inputCallCount = 0;
|
||||||
|
vi.mocked(prompt.input).mockImplementation(async (msg: string) => {
|
||||||
|
inputCallCount++;
|
||||||
|
if (msg.includes('Profile name')) return 'default';
|
||||||
|
if (msg.includes('variable name') && inputCallCount <= 8) return 'API_KEY';
|
||||||
|
if (msg.includes('variable name')) return '';
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const cmd = createSetupCommand({ client, prompt, log });
|
||||||
|
await cmd.parseAsync(['slack'], { from: 'user' });
|
||||||
|
|
||||||
|
expect(client.post).toHaveBeenCalledTimes(2);
|
||||||
|
const profileCall = vi.mocked(client.post).mock.calls[1];
|
||||||
|
expect(profileCall?.[0]).toBe('/api/v1/profiles');
|
||||||
|
expect(profileCall?.[1]).toEqual(expect.objectContaining({
|
||||||
|
name: 'default',
|
||||||
|
serverId: 'srv-1',
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exits if server creation fails', async () => {
|
||||||
|
vi.mocked(client.post).mockRejectedValue(new Error('conflict'));
|
||||||
|
|
||||||
|
const prompt = mockPrompt({
|
||||||
|
'npm package': '',
|
||||||
|
'docker image': '',
|
||||||
|
'description': '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const cmd = createSetupCommand({ client, prompt, log });
|
||||||
|
await cmd.parseAsync(['slack'], { from: 'user' });
|
||||||
|
|
||||||
|
expect(output.join('\n')).toContain('Failed to create server');
|
||||||
|
expect(client.post).toHaveBeenCalledTimes(1); // Only server create, no profile
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips profile creation when declined', async () => {
|
||||||
|
const prompt = mockPrompt({
|
||||||
|
'npm package': '',
|
||||||
|
'docker image': '',
|
||||||
|
'description': '',
|
||||||
|
});
|
||||||
|
vi.mocked(prompt.confirm).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const cmd = createSetupCommand({ client, prompt, log });
|
||||||
|
await cmd.parseAsync(['test-server'], { from: 'user' });
|
||||||
|
|
||||||
|
expect(client.post).toHaveBeenCalledTimes(1); // Only server create
|
||||||
|
expect(output.join('\n')).toContain('Setup complete');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user