feat: replace profiles with kubernetes-style secrets
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).
- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,14 @@ import yaml from 'js-yaml';
|
||||
import { z } from 'zod';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
|
||||
const ServerEnvEntrySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
value: z.string().optional(),
|
||||
valueFrom: z.object({
|
||||
secretRef: z.object({ name: z.string(), key: z.string() }),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
const ServerSpecSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().default(''),
|
||||
@@ -15,29 +23,22 @@ const ServerSpecSchema = z.object({
|
||||
command: z.array(z.string()).optional(),
|
||||
containerPort: z.number().int().min(1).max(65535).optional(),
|
||||
replicas: z.number().int().min(0).max(10).default(1),
|
||||
envTemplate: z.array(z.object({
|
||||
name: z.string(),
|
||||
description: z.string().default(''),
|
||||
isSecret: z.boolean().default(false),
|
||||
})).default([]),
|
||||
env: z.array(ServerEnvEntrySchema).default([]),
|
||||
});
|
||||
|
||||
const ProfileSpecSchema = z.object({
|
||||
const SecretSpecSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
server: z.string().min(1),
|
||||
permissions: z.array(z.string()).default([]),
|
||||
envOverrides: z.record(z.string()).default({}),
|
||||
data: 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([]),
|
||||
secrets: z.array(SecretSpecSchema).default([]),
|
||||
projects: z.array(ProjectSpecSchema).default([]),
|
||||
});
|
||||
|
||||
@@ -61,7 +62,7 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command {
|
||||
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.secrets.length > 0) log(` ${config.secrets.length} secret(s)`);
|
||||
if (config.projects.length > 0) log(` ${config.projects.length} project(s)`);
|
||||
return;
|
||||
}
|
||||
@@ -84,7 +85,7 @@ function loadConfigFile(path: string): ApplyConfig {
|
||||
}
|
||||
|
||||
async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args: unknown[]) => void): Promise<void> {
|
||||
// Apply servers first (profiles depend on servers)
|
||||
// Apply servers first
|
||||
for (const server of config.servers) {
|
||||
try {
|
||||
const existing = await findByName(client, 'servers', server.name);
|
||||
@@ -100,34 +101,19 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
|
||||
}
|
||||
}
|
||||
|
||||
// Apply profiles (need server IDs)
|
||||
for (const profile of config.profiles) {
|
||||
// Apply secrets
|
||||
for (const secret of config.secrets) {
|
||||
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);
|
||||
const existing = await findByName(client, 'secrets', secret.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})`);
|
||||
await client.put(`/api/v1/secrets/${(existing as { id: string }).id}`, { data: secret.data });
|
||||
log(`Updated secret: ${secret.name}`);
|
||||
} 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})`);
|
||||
await client.post('/api/v1/secrets', secret);
|
||||
log(`Created secret: ${secret.name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Error applying profile '${profile.name}': ${err instanceof Error ? err.message : err}`);
|
||||
log(`Error applying secret '${secret.name}': ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,16 +148,5 @@ async function findByName(client: ApiClient, resource: string, name: string): Pr
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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;
|
||||
@@ -11,17 +9,33 @@ function collect(value: string, prev: string[]): string[] {
|
||||
return [...prev, value];
|
||||
}
|
||||
|
||||
function parseEnvTemplate(entries: string[]): Array<{ name: string; description: string; isSecret: boolean }> {
|
||||
interface ServerEnvEntry {
|
||||
name: string;
|
||||
value?: string;
|
||||
valueFrom?: { secretRef: { name: string; key: string } };
|
||||
}
|
||||
|
||||
function parseServerEnv(entries: string[]): ServerEnvEntry[] {
|
||||
return entries.map((entry) => {
|
||||
const parts = entry.split(':');
|
||||
if (parts.length < 2) {
|
||||
throw new Error(`Invalid env-template format '${entry}'. Expected NAME:description[:isSecret]`);
|
||||
const eqIdx = entry.indexOf('=');
|
||||
if (eqIdx === -1) {
|
||||
throw new Error(`Invalid env format '${entry}'. Expected KEY=value or KEY=secretRef:SECRET:KEY`);
|
||||
}
|
||||
return {
|
||||
name: parts[0]!,
|
||||
description: parts[1]!,
|
||||
isSecret: parts[2] === 'true',
|
||||
};
|
||||
const envName = entry.slice(0, eqIdx);
|
||||
const rhs = entry.slice(eqIdx + 1);
|
||||
|
||||
if (rhs.startsWith('secretRef:')) {
|
||||
const parts = rhs.split(':');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error(`Invalid secretRef format '${entry}'. Expected KEY=secretRef:SECRET_NAME:SECRET_KEY`);
|
||||
}
|
||||
return {
|
||||
name: envName,
|
||||
valueFrom: { secretRef: { name: parts[1]!, key: parts[2]! } },
|
||||
};
|
||||
}
|
||||
|
||||
return { name: envName, value: rhs };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,7 +55,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
const cmd = new Command('create')
|
||||
.description('Create a resource (server, profile, project)');
|
||||
.description('Create a resource (server, project)');
|
||||
|
||||
// --- create server ---
|
||||
cmd.command('server')
|
||||
@@ -56,7 +70,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
.option('--command <arg>', 'Command argument (repeat for multiple)', collect, [])
|
||||
.option('--container-port <port>', 'Container port number')
|
||||
.option('--replicas <count>', 'Number of replicas', '1')
|
||||
.option('--env-template <entry>', 'Env template (NAME:description[:isSecret], repeat for multiple)', collect, [])
|
||||
.option('--env <entry>', 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)', collect, [])
|
||||
.action(async (name: string, opts) => {
|
||||
const body: Record<string, unknown> = {
|
||||
name,
|
||||
@@ -70,31 +84,24 @@ export function createCreateCommand(deps: CreateCommandDeps): Command {
|
||||
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);
|
||||
if (opts.env.length > 0) body.env = parseServerEnv(opts.env);
|
||||
|
||||
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('<name>', 'Profile name')
|
||||
.requiredOption('--server <name-or-id>', 'Server name or ID')
|
||||
.option('--permissions <perm>', 'Permission (repeat for multiple)', collect, [])
|
||||
.option('--env <entry>', 'Environment override KEY=value (repeat for multiple)', collect, [])
|
||||
// --- create secret ---
|
||||
cmd.command('secret')
|
||||
.description('Create a secret')
|
||||
.argument('<name>', 'Secret name (lowercase, hyphens allowed)')
|
||||
.option('--data <entry>', 'Secret data KEY=value (repeat for multiple)', collect, [])
|
||||
.action(async (name: string, opts) => {
|
||||
const serverId = await resolveNameOrId(client, 'servers', opts.server);
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
const data = parseEnvEntries(opts.data);
|
||||
const secret = await client.post<{ id: string; name: string }>('/api/v1/secrets', {
|
||||
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})`);
|
||||
data,
|
||||
});
|
||||
log(`secret '${secret.name}' created (id: ${secret.id})`);
|
||||
});
|
||||
|
||||
// --- create project ---
|
||||
|
||||
@@ -34,15 +34,19 @@ function formatServerDetail(server: Record<string, unknown>): string {
|
||||
lines.push(` ${command.join(' ')}`);
|
||||
}
|
||||
|
||||
const envTemplate = server.envTemplate as Array<{ name: string; description: string; isSecret?: boolean }> | undefined;
|
||||
if (envTemplate && envTemplate.length > 0) {
|
||||
const env = server.env as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }> | undefined;
|
||||
if (env && env.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('Environment:');
|
||||
const nameW = Math.max(6, ...env.map((e) => e.name.length)) + 2;
|
||||
lines.push(` ${'NAME'.padEnd(nameW)}SOURCE`);
|
||||
for (const e of env) {
|
||||
if (e.value !== undefined) {
|
||||
lines.push(` ${e.name.padEnd(nameW)}${e.value}`);
|
||||
} else if (e.valueFrom?.secretRef) {
|
||||
const ref = e.valueFrom.secretRef;
|
||||
lines.push(` ${e.name.padEnd(nameW)}secret:${ref.name}/${ref.key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,36 +96,6 @@ function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Recor
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatProfileDetail(profile: Record<string, unknown>): 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<string, string> | 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, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== Project: ${project.name} ===`);
|
||||
@@ -138,6 +112,37 @@ function formatProjectDetail(project: Record<string, unknown>): string {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatSecretDetail(secret: Record<string, unknown>, showValues: boolean): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`=== Secret: ${secret.name} ===`);
|
||||
lines.push(`${pad('Name:')}${secret.name}`);
|
||||
|
||||
const data = secret.data as Record<string, string> | undefined;
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Data:');
|
||||
const keyW = Math.max(4, ...Object.keys(data).map((k) => k.length)) + 2;
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const display = showValues ? value : '***';
|
||||
lines.push(` ${key.padEnd(keyW)}${display}`);
|
||||
}
|
||||
if (!showValues) {
|
||||
lines.push('');
|
||||
lines.push(' (use --show-values to reveal)');
|
||||
}
|
||||
} else {
|
||||
lines.push(`${pad('Data:')}(empty)`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('Metadata:');
|
||||
lines.push(` ${pad('ID:', 12)}${secret.id}`);
|
||||
if (secret.createdAt) lines.push(` ${pad('Created:', 12)}${secret.createdAt}`);
|
||||
if (secret.updatedAt) lines.push(` ${pad('Updated:', 12)}${secret.updatedAt}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatGenericDetail(obj: Record<string, unknown>): string {
|
||||
const lines: string[] = [];
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
@@ -167,10 +172,11 @@ function formatGenericDetail(obj: Record<string, unknown>): string {
|
||||
export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
||||
return new Command('describe')
|
||||
.description('Show detailed information about a resource')
|
||||
.argument('<resource>', 'resource type (server, profile, project, instance)')
|
||||
.argument('<resource>', 'resource type (server, project, instance)')
|
||||
.argument('<id>', 'resource ID or name')
|
||||
.option('-o, --output <format>', 'output format (detail, json, yaml)', 'detail')
|
||||
.action(async (resourceArg: string, idOrName: string, opts: { output: string }) => {
|
||||
.option('--show-values', 'Show secret values (default: masked)')
|
||||
.action(async (resourceArg: string, idOrName: string, opts: { output: string; showValues?: boolean }) => {
|
||||
const resource = resolveResource(resourceArg);
|
||||
|
||||
// Resolve name → ID
|
||||
@@ -207,8 +213,8 @@ export function createDescribeCommand(deps: DescribeCommandDeps): Command {
|
||||
case 'instances':
|
||||
deps.log(formatInstanceDetail(item, inspect));
|
||||
break;
|
||||
case 'profiles':
|
||||
deps.log(formatProfileDetail(item));
|
||||
case 'secrets':
|
||||
deps.log(formatSecretDetail(item, opts.showValues === true));
|
||||
break;
|
||||
case 'projects':
|
||||
deps.log(formatProjectDetail(item));
|
||||
|
||||
@@ -33,8 +33,8 @@ 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>', 'Resource type (server, profile, project)')
|
||||
.description('Edit a resource in your default editor (server, project)')
|
||||
.argument('<resource>', 'Resource type (server, project)')
|
||||
.argument('<name-or-id>', 'Resource name or ID')
|
||||
.action(async (resourceArg: string, nameOrId: string) => {
|
||||
const resource = resolveResource(resourceArg);
|
||||
@@ -47,7 +47,7 @@ export function createEditCommand(deps: EditCommandDeps): Command {
|
||||
return;
|
||||
}
|
||||
|
||||
const validResources = ['servers', 'profiles', 'projects'];
|
||||
const validResources = ['servers', 'secrets', 'projects'];
|
||||
if (!validResources.includes(resource)) {
|
||||
log(`Error: unknown resource type '${resourceArg}'`);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -17,12 +17,6 @@ interface ServerRow {
|
||||
dockerImage: string | null;
|
||||
}
|
||||
|
||||
interface ProfileRow {
|
||||
id: string;
|
||||
name: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
interface ProjectRow {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -30,6 +24,12 @@ interface ProjectRow {
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
interface SecretRow {
|
||||
id: string;
|
||||
name: string;
|
||||
data: Record<string, string>;
|
||||
}
|
||||
|
||||
interface InstanceRow {
|
||||
id: string;
|
||||
serverId: string;
|
||||
@@ -46,12 +46,6 @@ const serverColumns: Column<ServerRow>[] = [
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
const profileColumns: Column<ProfileRow>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'SERVER ID', key: 'serverId' },
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
const projectColumns: Column<ProjectRow>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'DESCRIPTION', key: 'description', width: 40 },
|
||||
@@ -59,6 +53,12 @@ const projectColumns: Column<ProjectRow>[] = [
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
const secretColumns: Column<SecretRow>[] = [
|
||||
{ header: 'NAME', key: 'name' },
|
||||
{ header: 'KEYS', key: (r) => Object.keys(r.data).join(', ') || '-', width: 40 },
|
||||
{ header: 'ID', key: 'id' },
|
||||
];
|
||||
|
||||
const instanceColumns: Column<InstanceRow>[] = [
|
||||
{ header: 'STATUS', key: 'status', width: 10 },
|
||||
{ header: 'SERVER ID', key: 'serverId' },
|
||||
@@ -71,10 +71,10 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
|
||||
switch (resource) {
|
||||
case 'servers':
|
||||
return serverColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'profiles':
|
||||
return profileColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'projects':
|
||||
return projectColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'secrets':
|
||||
return secretColumns as unknown as Column<Record<string, unknown>>[];
|
||||
case 'instances':
|
||||
return instanceColumns as unknown as Column<Record<string, unknown>>[];
|
||||
default:
|
||||
@@ -91,21 +91,15 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
|
||||
*/
|
||||
function toApplyFormat(resource: string, items: unknown[]): Record<string, unknown[]> {
|
||||
const cleaned = items.map((item) => {
|
||||
const obj = stripInternalFields(item as Record<string, unknown>);
|
||||
// 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 stripInternalFields(item as Record<string, unknown>);
|
||||
});
|
||||
return { [resource]: cleaned };
|
||||
}
|
||||
|
||||
export function createGetCommand(deps: GetCommandDeps): Command {
|
||||
return new Command('get')
|
||||
.description('List resources (servers, profiles, projects, instances)')
|
||||
.argument('<resource>', 'resource type (servers, profiles, projects, instances)')
|
||||
.description('List resources (servers, projects, instances)')
|
||||
.argument('<resource>', 'resource type (servers, projects, instances)')
|
||||
.argument('[id]', 'specific resource ID or name')
|
||||
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
|
||||
.action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => {
|
||||
|
||||
@@ -1,52 +1,15 @@
|
||||
import { Command } from 'commander';
|
||||
import type { ApiClient } from '../api-client.js';
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
name: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export interface ProjectCommandDeps {
|
||||
client: ApiClient;
|
||||
log: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export function createProjectCommand(deps: ProjectCommandDeps): Command {
|
||||
const { client, log } = deps;
|
||||
|
||||
const cmd = new Command('project')
|
||||
.alias('proj')
|
||||
.description('Project-specific actions (create with "create project", list with "get projects")');
|
||||
|
||||
cmd
|
||||
.command('profiles <id>')
|
||||
.description('List profiles assigned to a project')
|
||||
.option('-o, --output <format>', 'Output format (table, json)', 'table')
|
||||
.action(async (id: string, opts: { output: string }) => {
|
||||
const profiles = await client.get<Profile[]>(`/api/v1/projects/${id}/profiles`);
|
||||
if (opts.output === 'json') {
|
||||
log(JSON.stringify(profiles, null, 2));
|
||||
return;
|
||||
}
|
||||
if (profiles.length === 0) {
|
||||
log('No profiles assigned.');
|
||||
return;
|
||||
}
|
||||
log('ID\tNAME\tSERVER');
|
||||
for (const p of profiles) {
|
||||
log(`${p.id}\t${p.name}\t${p.serverId}`);
|
||||
}
|
||||
});
|
||||
|
||||
cmd
|
||||
.command('set-profiles <id>')
|
||||
.description('Set the profiles assigned to a project')
|
||||
.argument('<profileIds...>', 'Profile IDs to assign')
|
||||
.action(async (id: string, profileIds: string[]) => {
|
||||
await client.put(`/api/v1/projects/${id}/profiles`, { profileIds });
|
||||
log(`Set ${profileIds.length} profile(s) for project '${id}'.`);
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
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!');
|
||||
});
|
||||
}
|
||||
@@ -3,12 +3,12 @@ import type { ApiClient } from '../api-client.js';
|
||||
export const RESOURCE_ALIASES: Record<string, string> = {
|
||||
server: 'servers',
|
||||
srv: 'servers',
|
||||
profile: 'profiles',
|
||||
prof: 'profiles',
|
||||
project: 'projects',
|
||||
proj: 'projects',
|
||||
instance: 'instances',
|
||||
inst: 'instances',
|
||||
secret: 'secrets',
|
||||
sec: 'secrets',
|
||||
};
|
||||
|
||||
export function resolveResource(name: string): string {
|
||||
|
||||
@@ -10,7 +10,6 @@ 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';
|
||||
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
|
||||
@@ -110,33 +109,6 @@ export function createProgram(): Command {
|
||||
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),
|
||||
}));
|
||||
|
||||
program.addCommand(createClaudeCommand({
|
||||
client,
|
||||
log: (...args) => console.log(...args),
|
||||
|
||||
@@ -86,9 +86,6 @@ servers:
|
||||
servers:
|
||||
- name: test
|
||||
transport: STDIO
|
||||
profiles:
|
||||
- name: default
|
||||
server: test
|
||||
`);
|
||||
|
||||
const cmd = createApplyCommand({ client, log });
|
||||
@@ -97,52 +94,51 @@ profiles:
|
||||
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 () => {
|
||||
it('applies secrets', async () => {
|
||||
const configPath = join(tmpDir, 'config.yaml');
|
||||
writeFileSync(configPath, `
|
||||
secrets:
|
||||
- name: ha-creds
|
||||
data:
|
||||
TOKEN: abc123
|
||||
URL: https://ha.local
|
||||
`);
|
||||
|
||||
const cmd = createApplyCommand({ client, log });
|
||||
await cmd.parseAsync([configPath], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', expect.objectContaining({
|
||||
name: 'ha-creds',
|
||||
data: { TOKEN: 'abc123', URL: 'https://ha.local' },
|
||||
}));
|
||||
expect(output.join('\n')).toContain('Created secret: ha-creds');
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('updates existing secrets', async () => {
|
||||
vi.mocked(client.get).mockImplementation(async (url: string) => {
|
||||
if (url === '/api/v1/servers') return [{ id: 'srv-1', name: 'slack' }];
|
||||
if (url === '/api/v1/secrets') return [{ id: 'sec-1', name: 'ha-creds' }];
|
||||
return [];
|
||||
});
|
||||
|
||||
const configPath = join(tmpDir, 'config.yaml');
|
||||
writeFileSync(configPath, `
|
||||
profiles:
|
||||
- name: default
|
||||
server: slack
|
||||
envOverrides:
|
||||
SLACK_TOKEN: "xoxb-test"
|
||||
secrets:
|
||||
- name: ha-creds
|
||||
data:
|
||||
TOKEN: new-token
|
||||
`);
|
||||
|
||||
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'");
|
||||
expect(client.put).toHaveBeenCalledWith('/api/v1/secrets/sec-1', { data: { TOKEN: 'new-token' } });
|
||||
expect(output.join('\n')).toContain('Updated secret: ha-creds');
|
||||
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -46,8 +46,8 @@ describe('create command', () => {
|
||||
'--command', 'python',
|
||||
'--command', '-c',
|
||||
'--command', 'print("hello")',
|
||||
'--env-template', 'API_KEY:API key:true',
|
||||
'--env-template', 'BASE_URL:Base URL:false',
|
||||
'--env', 'API_KEY=secretRef:creds:API_KEY',
|
||||
'--env', 'BASE_URL=http://localhost',
|
||||
], { from: 'user' });
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', {
|
||||
@@ -59,9 +59,9 @@ describe('create command', () => {
|
||||
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 },
|
||||
env: [
|
||||
{ name: 'API_KEY', valueFrom: { secretRef: { name: 'creds', key: 'API_KEY' } } },
|
||||
{ name: 'BASE_URL', value: 'http://localhost' },
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -75,49 +75,28 @@ describe('create command', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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' },
|
||||
]);
|
||||
describe('create secret', () => {
|
||||
it('creates a secret with --data flags', async () => {
|
||||
const cmd = createCreateCommand({ client, log });
|
||||
await cmd.parseAsync([
|
||||
'profile', 'dev',
|
||||
'--server', 'test',
|
||||
'--env', 'FOO=bar',
|
||||
'--env', 'SECRET=s3cr3t',
|
||||
'secret', 'ha-creds',
|
||||
'--data', 'TOKEN=abc123',
|
||||
'--data', 'URL=https://ha.local',
|
||||
], { from: 'user' });
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({
|
||||
envOverrides: { FOO: 'bar', SECRET: 's3cr3t' },
|
||||
}));
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', {
|
||||
name: 'ha-creds',
|
||||
data: { TOKEN: 'abc123', URL: 'https://ha.local' },
|
||||
});
|
||||
expect(output.join('\n')).toContain("secret 'test' created");
|
||||
});
|
||||
|
||||
it('passes permissions', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([
|
||||
{ id: 'srv-1', name: 'test' },
|
||||
]);
|
||||
it('creates a secret with empty data', async () => {
|
||||
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'],
|
||||
}));
|
||||
await cmd.parseAsync(['secret', 'empty-secret'], { from: 'user' });
|
||||
expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', {
|
||||
name: 'empty-secret',
|
||||
data: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('describe command', () => {
|
||||
transport: 'STDIO',
|
||||
packageName: '@slack/mcp',
|
||||
dockerImage: null,
|
||||
envTemplate: [],
|
||||
env: [],
|
||||
createdAt: '2025-01-01',
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
@@ -50,10 +50,10 @@ describe('describe command', () => {
|
||||
});
|
||||
|
||||
it('resolves resource aliases', async () => {
|
||||
const deps = makeDeps({ id: 'p1' });
|
||||
const deps = makeDeps({ id: 's1' });
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'prof', 'p1']);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('profiles', 'p1');
|
||||
await cmd.parseAsync(['node', 'test', 'sec', 's1']);
|
||||
expect(deps.fetchResource).toHaveBeenCalledWith('secrets', 's1');
|
||||
});
|
||||
|
||||
it('outputs JSON format', async () => {
|
||||
@@ -72,26 +72,6 @@ describe('describe command', () => {
|
||||
expect(deps.output[0]).toContain('name: slack');
|
||||
});
|
||||
|
||||
it('shows profile with permissions and env overrides', async () => {
|
||||
const deps = makeDeps({
|
||||
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', 'profile', 'p1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== Profile: production ===');
|
||||
expect(text).toContain('read, write');
|
||||
expect(text).toContain('Environment Overrides:');
|
||||
expect(text).toContain('FOO');
|
||||
expect(text).toContain('bar');
|
||||
});
|
||||
|
||||
it('shows project detail', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'proj-1',
|
||||
@@ -109,6 +89,39 @@ describe('describe command', () => {
|
||||
expect(text).toContain('user-1');
|
||||
});
|
||||
|
||||
it('shows secret detail with masked values', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'sec-1',
|
||||
name: 'ha-creds',
|
||||
data: { TOKEN: 'abc123', URL: 'https://ha.local' },
|
||||
createdAt: '2025-01-01',
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'secret', 'sec-1']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('=== Secret: ha-creds ===');
|
||||
expect(text).toContain('TOKEN');
|
||||
expect(text).toContain('***');
|
||||
expect(text).not.toContain('abc123');
|
||||
expect(text).toContain('use --show-values to reveal');
|
||||
});
|
||||
|
||||
it('shows secret detail with revealed values when --show-values', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'sec-1',
|
||||
name: 'ha-creds',
|
||||
data: { TOKEN: 'abc123' },
|
||||
createdAt: '2025-01-01',
|
||||
});
|
||||
const cmd = createDescribeCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'secret', 'sec-1', '--show-values']);
|
||||
|
||||
const text = deps.output.join('\n');
|
||||
expect(text).toContain('abc123');
|
||||
expect(text).not.toContain('***');
|
||||
});
|
||||
|
||||
it('shows instance detail with container info', async () => {
|
||||
const deps = makeDeps({
|
||||
id: 'inst-1',
|
||||
|
||||
@@ -150,31 +150,4 @@ describe('edit command', () => {
|
||||
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' },
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,16 +67,6 @@ describe('get command', () => {
|
||||
expect(text).not.toContain('createdAt:');
|
||||
});
|
||||
|
||||
it('lists profiles with correct columns', async () => {
|
||||
const deps = makeDeps([
|
||||
{ id: 'p1', name: 'default', serverId: 'srv-1' },
|
||||
]);
|
||||
const cmd = createGetCommand(deps);
|
||||
await cmd.parseAsync(['node', 'test', 'profiles']);
|
||||
expect(deps.output[0]).toContain('NAME');
|
||||
expect(deps.output[0]).toContain('SERVER ID');
|
||||
});
|
||||
|
||||
it('lists instances with correct columns', async () => {
|
||||
const deps = makeDeps([
|
||||
{ id: 'inst-1', serverId: 'srv-1', status: 'RUNNING', containerId: 'abc123def456', port: 3000 },
|
||||
|
||||
@@ -45,12 +45,6 @@ describe('delete command', () => {
|
||||
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-abc');
|
||||
});
|
||||
|
||||
it('deletes a profile', async () => {
|
||||
const cmd = createDeleteCommand({ client, log });
|
||||
await cmd.parseAsync(['profile', 'prof-1'], { from: 'user' });
|
||||
expect(client.delete).toHaveBeenCalledWith('/api/v1/profiles/prof-1');
|
||||
});
|
||||
|
||||
it('deletes a project', async () => {
|
||||
const cmd = createDeleteCommand({ client, log });
|
||||
await cmd.parseAsync(['project', 'proj-1'], { from: 'user' });
|
||||
|
||||
@@ -21,32 +21,9 @@ describe('project command', () => {
|
||||
output = [];
|
||||
});
|
||||
|
||||
describe('profiles', () => {
|
||||
it('lists profiles for a project', async () => {
|
||||
vi.mocked(client.get).mockResolvedValue([
|
||||
{ id: 'prof-1', name: 'default', serverId: 'srv-1' },
|
||||
]);
|
||||
const cmd = createProjectCommand({ client, log });
|
||||
await cmd.parseAsync(['profiles', 'proj-1'], { from: 'user' });
|
||||
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/profiles');
|
||||
expect(output.join('\n')).toContain('default');
|
||||
});
|
||||
|
||||
it('shows empty message when no profiles', async () => {
|
||||
const cmd = createProjectCommand({ client, log });
|
||||
await cmd.parseAsync(['profiles', 'proj-1'], { from: 'user' });
|
||||
expect(output.join('\n')).toContain('No profiles assigned');
|
||||
});
|
||||
});
|
||||
|
||||
describe('set-profiles', () => {
|
||||
it('sets profiles for a project', async () => {
|
||||
const cmd = createProjectCommand({ client, log });
|
||||
await cmd.parseAsync(['set-profiles', 'proj-1', 'prof-1', 'prof-2'], { from: 'user' });
|
||||
expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1/profiles', {
|
||||
profileIds: ['prof-1', 'prof-2'],
|
||||
});
|
||||
expect(output.join('\n')).toContain('2 profile(s)');
|
||||
});
|
||||
it('creates command with alias', () => {
|
||||
const cmd = createProjectCommand({ client, log });
|
||||
expect(cmd.name()).toBe('project');
|
||||
expect(cmd.alias()).toBe('proj');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,6 @@ describe('CLI command registration (e2e)', () => {
|
||||
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');
|
||||
@@ -46,19 +45,11 @@ describe('CLI command registration (e2e)', () => {
|
||||
expect(subcommands).toContain('remove');
|
||||
});
|
||||
|
||||
it('project command has action subcommands only', () => {
|
||||
it('project command exists with alias', () => {
|
||||
const program = createProgram();
|
||||
const project = program.commands.find((c) => c.name() === 'project');
|
||||
expect(project).toBeDefined();
|
||||
|
||||
const subcommands = project!.commands.map((c) => c.name());
|
||||
expect(subcommands).toContain('profiles');
|
||||
expect(subcommands).toContain('set-profiles');
|
||||
// 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');
|
||||
expect(project!.alias()).toBe('proj');
|
||||
});
|
||||
|
||||
it('displays version', () => {
|
||||
|
||||
Reference in New Issue
Block a user