feat: replace profiles with kubernetes-style secrets
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled

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:
Michal
2026-02-22 18:40:58 +00:00
parent 02254f2aac
commit ca02340a4c
77 changed files with 1014 additions and 1931 deletions

View File

@@ -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 });
});

View File

@@ -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: {},
});
});
});

View File

@@ -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',

View File

@@ -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' },
}));
});
});

View File

@@ -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 },

View File

@@ -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' });

View File

@@ -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');
});
});

View File

@@ -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');
});
});