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