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:
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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user