feat: LLM provider configuration, secret store, and setup wizard
Add secure credential storage (GNOME Keyring + file fallback), LLM provider config in ~/.mcpctl/config.json, interactive setup wizard (mcpctl config setup), and wire configured provider into mcplocal for smart pagination summaries. - Secret store: SecretStore interface, GnomeKeyringStore, FileSecretStore - Config schema: LlmConfigSchema with provider/model/url/binaryPath - Setup wizard: arrow-key provider/model selection, dynamic model fetch - Provider factory: creates ProviderRegistry from config + secrets - Status: shows LLM line with hint when not configured - 572 tests passing across all packages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
125
src/shared/tests/secrets/gnome-keyring.test.ts
Normal file
125
src/shared/tests/secrets/gnome-keyring.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { GnomeKeyringStore } from '../../src/secrets/gnome-keyring.js';
|
||||
import type { RunCommand } from '../../src/secrets/gnome-keyring.js';
|
||||
|
||||
function mockRun(
|
||||
responses: Record<string, { stdout: string; code: number }>,
|
||||
): RunCommand {
|
||||
return vi.fn(async (cmd: string, args: string[], _stdin?: string) => {
|
||||
const key = `${cmd} ${args.join(' ')}`;
|
||||
for (const [pattern, response] of Object.entries(responses)) {
|
||||
if (key.includes(pattern)) return response;
|
||||
}
|
||||
return { stdout: '', code: 1 };
|
||||
});
|
||||
}
|
||||
|
||||
describe('GnomeKeyringStore', () => {
|
||||
describe('get', () => {
|
||||
it('returns value on success', async () => {
|
||||
const run = mockRun({ 'lookup': { stdout: 'my-secret', code: 0 } });
|
||||
const store = new GnomeKeyringStore({ run });
|
||||
expect(await store.get('api-key')).toBe('my-secret');
|
||||
});
|
||||
|
||||
it('returns null on exit code 1', async () => {
|
||||
const run = mockRun({ 'lookup': { stdout: '', code: 1 } });
|
||||
const store = new GnomeKeyringStore({ run });
|
||||
expect(await store.get('api-key')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on empty stdout', async () => {
|
||||
const run = mockRun({ 'lookup': { stdout: '', code: 0 } });
|
||||
const store = new GnomeKeyringStore({ run });
|
||||
expect(await store.get('api-key')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on error', async () => {
|
||||
const run = vi.fn().mockRejectedValue(new Error('timeout'));
|
||||
const store = new GnomeKeyringStore({ run });
|
||||
expect(await store.get('api-key')).toBeNull();
|
||||
});
|
||||
|
||||
it('calls secret-tool with correct args', async () => {
|
||||
const run = vi.fn().mockResolvedValue({ stdout: 'val', code: 0 });
|
||||
const store = new GnomeKeyringStore({ run });
|
||||
await store.get('my-key');
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'secret-tool',
|
||||
['lookup', 'service', 'mcpctl', 'key', 'my-key'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('set', () => {
|
||||
it('calls secret-tool store with value as stdin', async () => {
|
||||
const run = vi.fn().mockResolvedValue({ stdout: '', code: 0 });
|
||||
const store = new GnomeKeyringStore({ run });
|
||||
await store.set('api-key', 'secret-value');
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'secret-tool',
|
||||
['store', '--label', 'mcpctl: api-key', 'service', 'mcpctl', 'key', 'api-key'],
|
||||
'secret-value',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on non-zero exit code', async () => {
|
||||
const run = vi.fn().mockResolvedValue({ stdout: '', code: 1 });
|
||||
const store = new GnomeKeyringStore({ run });
|
||||
await expect(store.set('api-key', 'val')).rejects.toThrow('exited with code 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('returns true on success', async () => {
|
||||
const run = mockRun({ 'clear': { stdout: '', code: 0 } });
|
||||
const store = new GnomeKeyringStore({ run });
|
||||
expect(await store.delete('api-key')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false on failure', async () => {
|
||||
const run = mockRun({ 'clear': { stdout: '', code: 1 } });
|
||||
const store = new GnomeKeyringStore({ run });
|
||||
expect(await store.delete('api-key')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false on error', async () => {
|
||||
const run = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
const store = new GnomeKeyringStore({ run });
|
||||
expect(await store.delete('api-key')).toBe(false);
|
||||
});
|
||||
|
||||
it('calls secret-tool clear with correct args', async () => {
|
||||
const run = vi.fn().mockResolvedValue({ stdout: '', code: 0 });
|
||||
const store = new GnomeKeyringStore({ run });
|
||||
await store.delete('my-key');
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'secret-tool',
|
||||
['clear', 'service', 'mcpctl', 'key', 'my-key'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAvailable', () => {
|
||||
it('returns true when secret-tool exists', async () => {
|
||||
const run = vi.fn().mockResolvedValue({ stdout: '0.20', code: 0 });
|
||||
expect(await GnomeKeyringStore.isAvailable({ run })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when secret-tool not found', async () => {
|
||||
const run = vi.fn().mockRejectedValue(new Error('ENOENT'));
|
||||
expect(await GnomeKeyringStore.isAvailable({ run })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false on non-zero exit', async () => {
|
||||
const run = vi.fn().mockResolvedValue({ stdout: '', code: 127 });
|
||||
expect(await GnomeKeyringStore.isAvailable({ run })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('reports gnome-keyring backend', () => {
|
||||
const run = vi.fn().mockResolvedValue({ stdout: '', code: 0 });
|
||||
const store = new GnomeKeyringStore({ run });
|
||||
expect(store.backend()).toBe('gnome-keyring');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user