2026-02-22 14:33:25 +00:00
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
|
import { createCreateCommand } from '../../src/commands/create.js';
|
2026-02-22 23:06:33 +00:00
|
|
|
import { type ApiClient, ApiError } from '../../src/api-client.js';
|
2026-02-22 14:33:25 +00:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('create command', () => {
|
|
|
|
|
let client: ReturnType<typeof mockClient>;
|
|
|
|
|
let output: string[];
|
|
|
|
|
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
client = mockClient();
|
|
|
|
|
output = [];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('create server', () => {
|
|
|
|
|
it('creates a server with minimal flags', async () => {
|
|
|
|
|
const cmd = createCreateCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync(['server', 'my-server'], { from: 'user' });
|
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({
|
|
|
|
|
name: 'my-server',
|
|
|
|
|
transport: 'STDIO',
|
|
|
|
|
replicas: 1,
|
|
|
|
|
}));
|
|
|
|
|
expect(output.join('\n')).toContain("server 'test' created");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('creates a server with all flags', async () => {
|
|
|
|
|
const cmd = createCreateCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([
|
|
|
|
|
'server', 'ha-mcp',
|
|
|
|
|
'-d', 'Home Assistant MCP',
|
|
|
|
|
'--docker-image', 'ghcr.io/ha-mcp:latest',
|
|
|
|
|
'--transport', 'STREAMABLE_HTTP',
|
|
|
|
|
'--external-url', 'http://localhost:8086/mcp',
|
|
|
|
|
'--container-port', '3000',
|
|
|
|
|
'--replicas', '2',
|
|
|
|
|
'--command', 'python',
|
|
|
|
|
'--command', '-c',
|
|
|
|
|
'--command', 'print("hello")',
|
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>
2026-02-22 18:40:58 +00:00
|
|
|
'--env', 'API_KEY=secretRef:creds:API_KEY',
|
|
|
|
|
'--env', 'BASE_URL=http://localhost',
|
2026-02-22 14:33:25 +00:00
|
|
|
], { from: 'user' });
|
|
|
|
|
|
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', {
|
|
|
|
|
name: 'ha-mcp',
|
|
|
|
|
description: 'Home Assistant MCP',
|
|
|
|
|
dockerImage: 'ghcr.io/ha-mcp:latest',
|
|
|
|
|
transport: 'STREAMABLE_HTTP',
|
|
|
|
|
externalUrl: 'http://localhost:8086/mcp',
|
|
|
|
|
containerPort: 3000,
|
|
|
|
|
replicas: 2,
|
|
|
|
|
command: ['python', '-c', 'print("hello")'],
|
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>
2026-02-22 18:40:58 +00:00
|
|
|
env: [
|
|
|
|
|
{ name: 'API_KEY', valueFrom: { secretRef: { name: 'creds', key: 'API_KEY' } } },
|
|
|
|
|
{ name: 'BASE_URL', value: 'http://localhost' },
|
2026-02-22 14:33:25 +00:00
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('defaults transport to STDIO', async () => {
|
|
|
|
|
const cmd = createCreateCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync(['server', 'test'], { from: 'user' });
|
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({
|
|
|
|
|
transport: 'STDIO',
|
|
|
|
|
}));
|
|
|
|
|
});
|
2026-02-22 23:06:33 +00:00
|
|
|
|
|
|
|
|
it('strips null values from template when using --from-template', async () => {
|
|
|
|
|
vi.mocked(client.get).mockResolvedValueOnce([{
|
|
|
|
|
id: 'tpl-1',
|
|
|
|
|
name: 'grafana',
|
|
|
|
|
version: '1.0.0',
|
|
|
|
|
description: 'Grafana MCP',
|
|
|
|
|
packageName: '@leval/mcp-grafana',
|
|
|
|
|
dockerImage: null,
|
|
|
|
|
transport: 'STDIO',
|
|
|
|
|
repositoryUrl: 'https://github.com/test',
|
|
|
|
|
externalUrl: null,
|
|
|
|
|
command: null,
|
|
|
|
|
containerPort: null,
|
|
|
|
|
replicas: 1,
|
|
|
|
|
env: [{ name: 'TOKEN', required: true, description: 'A token' }],
|
|
|
|
|
healthCheck: { tool: 'test', arguments: {} },
|
|
|
|
|
createdAt: '2025-01-01',
|
|
|
|
|
updatedAt: '2025-01-01',
|
|
|
|
|
}] as never);
|
|
|
|
|
const cmd = createCreateCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([
|
|
|
|
|
'server', 'my-grafana', '--from-template=grafana',
|
|
|
|
|
'--env', 'TOKEN=secretRef:creds:TOKEN',
|
|
|
|
|
], { from: 'user' });
|
|
|
|
|
const call = vi.mocked(client.post).mock.calls[0]![1] as Record<string, unknown>;
|
|
|
|
|
// null fields from template should NOT be in the body
|
|
|
|
|
expect(call).not.toHaveProperty('dockerImage');
|
|
|
|
|
expect(call).not.toHaveProperty('externalUrl');
|
|
|
|
|
expect(call).not.toHaveProperty('command');
|
|
|
|
|
expect(call).not.toHaveProperty('containerPort');
|
|
|
|
|
// non-null fields should be present
|
|
|
|
|
expect(call.packageName).toBe('@leval/mcp-grafana');
|
|
|
|
|
expect(call.healthCheck).toEqual({ tool: 'test', arguments: {} });
|
|
|
|
|
expect(call.templateName).toBe('grafana');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('throws on 409 without --force', async () => {
|
|
|
|
|
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Server already exists: my-server"}'));
|
|
|
|
|
const cmd = createCreateCommand({ client, log });
|
|
|
|
|
await expect(cmd.parseAsync(['server', 'my-server'], { from: 'user' })).rejects.toThrow('API error 409');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('updates existing server on 409 with --force', async () => {
|
|
|
|
|
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Server already exists"}'));
|
|
|
|
|
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'srv-1', name: 'my-server' }] as never);
|
|
|
|
|
const cmd = createCreateCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync(['server', 'my-server', '--force'], { from: 'user' });
|
|
|
|
|
expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({
|
|
|
|
|
transport: 'STDIO',
|
|
|
|
|
}));
|
|
|
|
|
expect(output.join('\n')).toContain("server 'my-server' updated");
|
|
|
|
|
});
|
2026-02-22 14:33:25 +00:00
|
|
|
});
|
|
|
|
|
|
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>
2026-02-22 18:40:58 +00:00
|
|
|
describe('create secret', () => {
|
|
|
|
|
it('creates a secret with --data flags', async () => {
|
2026-02-22 14:33:25 +00:00
|
|
|
const cmd = createCreateCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync([
|
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>
2026-02-22 18:40:58 +00:00
|
|
|
'secret', 'ha-creds',
|
|
|
|
|
'--data', 'TOKEN=abc123',
|
|
|
|
|
'--data', 'URL=https://ha.local',
|
2026-02-22 14:33:25 +00:00
|
|
|
], { from: 'user' });
|
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>
2026-02-22 18:40:58 +00:00
|
|
|
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");
|
2026-02-22 14:33:25 +00:00
|
|
|
});
|
|
|
|
|
|
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>
2026-02-22 18:40:58 +00:00
|
|
|
it('creates a secret with empty data', async () => {
|
2026-02-22 14:33:25 +00:00
|
|
|
const cmd = createCreateCommand({ client, log });
|
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>
2026-02-22 18:40:58 +00:00
|
|
|
await cmd.parseAsync(['secret', 'empty-secret'], { from: 'user' });
|
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', {
|
|
|
|
|
name: 'empty-secret',
|
|
|
|
|
data: {},
|
|
|
|
|
});
|
2026-02-22 14:33:25 +00:00
|
|
|
});
|
2026-02-22 23:06:33 +00:00
|
|
|
|
|
|
|
|
it('throws on 409 without --force', async () => {
|
|
|
|
|
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Secret already exists: my-creds"}'));
|
|
|
|
|
const cmd = createCreateCommand({ client, log });
|
|
|
|
|
await expect(cmd.parseAsync(['secret', 'my-creds', '--data', 'KEY=val'], { from: 'user' })).rejects.toThrow('API error 409');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('updates existing secret on 409 with --force', async () => {
|
|
|
|
|
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Secret already exists"}'));
|
|
|
|
|
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'sec-1', name: 'my-creds' }] as never);
|
|
|
|
|
const cmd = createCreateCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync(['secret', 'my-creds', '--data', 'KEY=val', '--force'], { from: 'user' });
|
|
|
|
|
expect(client.put).toHaveBeenCalledWith('/api/v1/secrets/sec-1', { data: { KEY: 'val' } });
|
|
|
|
|
expect(output.join('\n')).toContain("secret 'my-creds' updated");
|
|
|
|
|
});
|
2026-02-22 14:33:25 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('create project', () => {
|
|
|
|
|
it('creates a project', async () => {
|
|
|
|
|
const cmd = createCreateCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync(['project', 'my-project', '-d', 'A test project'], { from: 'user' });
|
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
|
|
|
|
|
name: 'my-project',
|
|
|
|
|
description: 'A test project',
|
|
|
|
|
});
|
|
|
|
|
expect(output.join('\n')).toContain("project 'test' created");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it('creates a project with no description', async () => {
|
|
|
|
|
const cmd = createCreateCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync(['project', 'minimal'], { from: 'user' });
|
|
|
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
|
|
|
|
|
name: 'minimal',
|
|
|
|
|
description: '',
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-02-22 23:06:33 +00:00
|
|
|
|
|
|
|
|
it('updates existing project on 409 with --force', async () => {
|
|
|
|
|
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Project already exists"}'));
|
|
|
|
|
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'proj-1', name: 'my-proj' }] as never);
|
|
|
|
|
const cmd = createCreateCommand({ client, log });
|
|
|
|
|
await cmd.parseAsync(['project', 'my-proj', '-d', 'updated', '--force'], { from: 'user' });
|
|
|
|
|
expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1', { description: 'updated' });
|
|
|
|
|
expect(output.join('\n')).toContain("project 'my-proj' updated");
|
|
|
|
|
});
|
2026-02-22 14:33:25 +00:00
|
|
|
});
|
|
|
|
|
});
|