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>
154 lines
4.7 KiB
TypeScript
154 lines
4.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
import yaml from 'js-yaml';
|
|
import { createEditCommand } from '../../src/commands/edit.js';
|
|
import type { ApiClient } from '../../src/api-client.js';
|
|
|
|
function mockClient(): ApiClient {
|
|
return {
|
|
get: vi.fn(async () => ({})),
|
|
post: vi.fn(async () => ({})),
|
|
put: vi.fn(async () => ({})),
|
|
delete: vi.fn(async () => {}),
|
|
} as unknown as ApiClient;
|
|
}
|
|
|
|
describe('edit command', () => {
|
|
let client: ReturnType<typeof mockClient>;
|
|
let output: string[];
|
|
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
|
|
|
|
beforeEach(() => {
|
|
client = mockClient();
|
|
output = [];
|
|
});
|
|
|
|
it('fetches server, opens editor, applies changes on save', async () => {
|
|
// GET /api/v1/servers returns list for resolveNameOrId
|
|
vi.mocked(client.get).mockImplementation(async (path: string) => {
|
|
if (path === '/api/v1/servers') {
|
|
return [{ id: 'srv-1', name: 'ha-mcp' }];
|
|
}
|
|
// GET /api/v1/servers/srv-1 returns full server
|
|
return {
|
|
id: 'srv-1',
|
|
name: 'ha-mcp',
|
|
description: 'Old desc',
|
|
transport: 'STDIO',
|
|
replicas: 1,
|
|
createdAt: '2025-01-01',
|
|
updatedAt: '2025-01-01',
|
|
version: 1,
|
|
};
|
|
});
|
|
|
|
const cmd = createEditCommand({
|
|
client,
|
|
log,
|
|
getEditor: () => 'vi',
|
|
openEditor: (filePath) => {
|
|
// Simulate user editing the file
|
|
const content = readFileSync(filePath, 'utf-8');
|
|
const modified = content
|
|
.replace('Old desc', 'New desc')
|
|
.replace('replicas: 1', 'replicas: 3');
|
|
writeFileSync(filePath, modified, 'utf-8');
|
|
},
|
|
});
|
|
|
|
await cmd.parseAsync(['server', 'ha-mcp'], { from: 'user' });
|
|
|
|
expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({
|
|
description: 'New desc',
|
|
replicas: 3,
|
|
}));
|
|
expect(output.join('\n')).toContain("server 'ha-mcp' updated");
|
|
});
|
|
|
|
it('detects no changes and skips PUT', async () => {
|
|
vi.mocked(client.get).mockImplementation(async (path: string) => {
|
|
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
|
|
return {
|
|
id: 'srv-1', name: 'test', description: '', transport: 'STDIO',
|
|
createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1,
|
|
};
|
|
});
|
|
|
|
const cmd = createEditCommand({
|
|
client,
|
|
log,
|
|
getEditor: () => 'vi',
|
|
openEditor: () => {
|
|
// Don't modify the file
|
|
},
|
|
});
|
|
|
|
await cmd.parseAsync(['server', 'test'], { from: 'user' });
|
|
|
|
expect(client.put).not.toHaveBeenCalled();
|
|
expect(output.join('\n')).toContain("unchanged");
|
|
});
|
|
|
|
it('handles empty file as cancel', async () => {
|
|
vi.mocked(client.get).mockImplementation(async (path: string) => {
|
|
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
|
|
return { id: 'srv-1', name: 'test', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 };
|
|
});
|
|
|
|
const cmd = createEditCommand({
|
|
client,
|
|
log,
|
|
getEditor: () => 'vi',
|
|
openEditor: (filePath) => {
|
|
writeFileSync(filePath, '', 'utf-8');
|
|
},
|
|
});
|
|
|
|
await cmd.parseAsync(['server', 'test'], { from: 'user' });
|
|
|
|
expect(client.put).not.toHaveBeenCalled();
|
|
expect(output.join('\n')).toContain('cancelled');
|
|
});
|
|
|
|
it('strips read-only fields from editor content', async () => {
|
|
vi.mocked(client.get).mockImplementation(async (path: string) => {
|
|
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
|
|
return {
|
|
id: 'srv-1', name: 'test', description: '', transport: 'STDIO',
|
|
createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1,
|
|
};
|
|
});
|
|
|
|
let editorContent = '';
|
|
const cmd = createEditCommand({
|
|
client,
|
|
log,
|
|
getEditor: () => 'vi',
|
|
openEditor: (filePath) => {
|
|
editorContent = readFileSync(filePath, 'utf-8');
|
|
},
|
|
});
|
|
|
|
await cmd.parseAsync(['server', 'test'], { from: 'user' });
|
|
|
|
// The editor content should NOT contain read-only fields
|
|
expect(editorContent).not.toContain('id:');
|
|
expect(editorContent).not.toContain('createdAt');
|
|
expect(editorContent).not.toContain('updatedAt');
|
|
expect(editorContent).not.toContain('version');
|
|
// But should contain editable fields
|
|
expect(editorContent).toContain('name:');
|
|
});
|
|
|
|
it('rejects edit instance with error message', async () => {
|
|
const cmd = createEditCommand({ client, log });
|
|
|
|
await cmd.parseAsync(['instance', 'inst-1'], { from: 'user' });
|
|
|
|
expect(client.get).not.toHaveBeenCalled();
|
|
expect(client.put).not.toHaveBeenCalled();
|
|
expect(output.join('\n')).toContain('immutable');
|
|
});
|
|
|
|
});
|