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:
@@ -2,3 +2,4 @@ export * from './types/index.js';
|
||||
export * from './validation/index.js';
|
||||
export * from './constants/index.js';
|
||||
export * from './utils/index.js';
|
||||
export * from './secrets/index.js';
|
||||
|
||||
63
src/shared/src/secrets/file-store.ts
Normal file
63
src/shared/src/secrets/file-store.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { SecretStore, SecretStoreDeps } from './types.js';
|
||||
|
||||
function defaultConfigDir(): string {
|
||||
return join(homedir(), '.mcpctl');
|
||||
}
|
||||
|
||||
function secretsPath(configDir: string): string {
|
||||
return join(configDir, 'secrets');
|
||||
}
|
||||
|
||||
export class FileSecretStore implements SecretStore {
|
||||
private readonly configDir: string;
|
||||
|
||||
constructor(deps?: SecretStoreDeps) {
|
||||
this.configDir = deps?.configDir ?? defaultConfigDir();
|
||||
}
|
||||
|
||||
backend(): string {
|
||||
return 'file';
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
const data = this.readAll();
|
||||
return data[key] ?? null;
|
||||
}
|
||||
|
||||
async set(key: string, value: string): Promise<void> {
|
||||
const data = this.readAll();
|
||||
data[key] = value;
|
||||
this.writeAll(data);
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
const data = this.readAll();
|
||||
if (!(key in data)) return false;
|
||||
delete data[key];
|
||||
this.writeAll(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
private readAll(): Record<string, string> {
|
||||
const path = secretsPath(this.configDir);
|
||||
if (!existsSync(path)) return {};
|
||||
try {
|
||||
const raw = readFileSync(path, 'utf-8');
|
||||
return JSON.parse(raw) as Record<string, string>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private writeAll(data: Record<string, string>): void {
|
||||
if (!existsSync(this.configDir)) {
|
||||
mkdirSync(this.configDir, { recursive: true });
|
||||
}
|
||||
const path = secretsPath(this.configDir);
|
||||
writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
||||
chmodSync(path, 0o600);
|
||||
}
|
||||
}
|
||||
97
src/shared/src/secrets/gnome-keyring.ts
Normal file
97
src/shared/src/secrets/gnome-keyring.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type { SecretStore } from './types.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const SERVICE = 'mcpctl';
|
||||
|
||||
export type RunCommand = (cmd: string, args: string[], stdin?: string) => Promise<{ stdout: string; code: number }>;
|
||||
|
||||
function defaultRunCommand(cmd: string, args: string[], stdin?: string): Promise<{ stdout: string; code: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 5000,
|
||||
});
|
||||
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk));
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
const stdout = Buffer.concat(stdoutChunks).toString('utf-8');
|
||||
resolve({ stdout, code: code ?? 1 });
|
||||
});
|
||||
|
||||
if (stdin !== undefined) {
|
||||
child.stdin.write(stdin);
|
||||
child.stdin.end();
|
||||
} else {
|
||||
child.stdin.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export interface GnomeKeyringDeps {
|
||||
run?: RunCommand;
|
||||
}
|
||||
|
||||
export class GnomeKeyringStore implements SecretStore {
|
||||
private readonly run: RunCommand;
|
||||
|
||||
constructor(deps?: GnomeKeyringDeps) {
|
||||
this.run = deps?.run ?? defaultRunCommand;
|
||||
}
|
||||
|
||||
backend(): string {
|
||||
return 'gnome-keyring';
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout, code } = await this.run(
|
||||
'secret-tool', ['lookup', 'service', SERVICE, 'key', key],
|
||||
);
|
||||
if (code !== 0 || !stdout) return null;
|
||||
return stdout;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set(key: string, value: string): Promise<void> {
|
||||
const { code } = await this.run(
|
||||
'secret-tool',
|
||||
['store', '--label', `mcpctl: ${key}`, 'service', SERVICE, 'key', key],
|
||||
value,
|
||||
);
|
||||
if (code !== 0) {
|
||||
throw new Error(`secret-tool store exited with code ${code}`);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<boolean> {
|
||||
try {
|
||||
const { code } = await this.run(
|
||||
'secret-tool', ['clear', 'service', SERVICE, 'key', key],
|
||||
);
|
||||
return code === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async isAvailable(deps?: { run?: RunCommand }): Promise<boolean> {
|
||||
try {
|
||||
if (deps?.run) {
|
||||
const { code } = await deps.run('secret-tool', ['--version']);
|
||||
return code === 0;
|
||||
}
|
||||
await execFileAsync('secret-tool', ['--version'], { timeout: 3000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/shared/src/secrets/index.ts
Normal file
15
src/shared/src/secrets/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export type { SecretStore, SecretStoreDeps } from './types.js';
|
||||
export { FileSecretStore } from './file-store.js';
|
||||
export { GnomeKeyringStore } from './gnome-keyring.js';
|
||||
export type { GnomeKeyringDeps, RunCommand } from './gnome-keyring.js';
|
||||
|
||||
import { GnomeKeyringStore } from './gnome-keyring.js';
|
||||
import { FileSecretStore } from './file-store.js';
|
||||
import type { SecretStore, SecretStoreDeps } from './types.js';
|
||||
|
||||
export async function createSecretStore(deps?: SecretStoreDeps): Promise<SecretStore> {
|
||||
if (await GnomeKeyringStore.isAvailable()) {
|
||||
return new GnomeKeyringStore();
|
||||
}
|
||||
return new FileSecretStore(deps);
|
||||
}
|
||||
10
src/shared/src/secrets/types.ts
Normal file
10
src/shared/src/secrets/types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface SecretStore {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string): Promise<void>;
|
||||
delete(key: string): Promise<boolean>;
|
||||
backend(): string;
|
||||
}
|
||||
|
||||
export interface SecretStoreDeps {
|
||||
configDir?: string;
|
||||
}
|
||||
24
src/shared/tests/secrets/factory.test.ts
Normal file
24
src/shared/tests/secrets/factory.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { createSecretStore } from '../../src/secrets/index.js';
|
||||
import { GnomeKeyringStore } from '../../src/secrets/gnome-keyring.js';
|
||||
import { FileSecretStore } from '../../src/secrets/file-store.js';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('createSecretStore', () => {
|
||||
it('returns GnomeKeyringStore when secret-tool is available', async () => {
|
||||
vi.spyOn(GnomeKeyringStore, 'isAvailable').mockResolvedValue(true);
|
||||
const store = await createSecretStore();
|
||||
expect(store.backend()).toBe('gnome-keyring');
|
||||
expect(store).toBeInstanceOf(GnomeKeyringStore);
|
||||
});
|
||||
|
||||
it('returns FileSecretStore when secret-tool is not available', async () => {
|
||||
vi.spyOn(GnomeKeyringStore, 'isAvailable').mockResolvedValue(false);
|
||||
const store = await createSecretStore();
|
||||
expect(store.backend()).toBe('file');
|
||||
expect(store).toBeInstanceOf(FileSecretStore);
|
||||
});
|
||||
});
|
||||
93
src/shared/tests/secrets/file-store.test.ts
Normal file
93
src/shared/tests/secrets/file-store.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync, statSync, existsSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { FileSecretStore } from '../../src/secrets/file-store.js';
|
||||
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-secrets-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('FileSecretStore', () => {
|
||||
it('returns null for missing key', async () => {
|
||||
const store = new FileSecretStore({ configDir: tempDir });
|
||||
expect(await store.get('nonexistent')).toBeNull();
|
||||
});
|
||||
|
||||
it('stores and retrieves a secret', async () => {
|
||||
const store = new FileSecretStore({ configDir: tempDir });
|
||||
await store.set('api-key', 'sk-12345');
|
||||
expect(await store.get('api-key')).toBe('sk-12345');
|
||||
});
|
||||
|
||||
it('overwrites existing values', async () => {
|
||||
const store = new FileSecretStore({ configDir: tempDir });
|
||||
await store.set('api-key', 'old-value');
|
||||
await store.set('api-key', 'new-value');
|
||||
expect(await store.get('api-key')).toBe('new-value');
|
||||
});
|
||||
|
||||
it('stores multiple keys', async () => {
|
||||
const store = new FileSecretStore({ configDir: tempDir });
|
||||
await store.set('key-a', 'value-a');
|
||||
await store.set('key-b', 'value-b');
|
||||
expect(await store.get('key-a')).toBe('value-a');
|
||||
expect(await store.get('key-b')).toBe('value-b');
|
||||
});
|
||||
|
||||
it('deletes a key', async () => {
|
||||
const store = new FileSecretStore({ configDir: tempDir });
|
||||
await store.set('api-key', 'sk-12345');
|
||||
expect(await store.delete('api-key')).toBe(true);
|
||||
expect(await store.get('api-key')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns false when deleting nonexistent key', async () => {
|
||||
const store = new FileSecretStore({ configDir: tempDir });
|
||||
expect(await store.delete('nonexistent')).toBe(false);
|
||||
});
|
||||
|
||||
it('sets 0600 permissions on secrets file', async () => {
|
||||
const store = new FileSecretStore({ configDir: tempDir });
|
||||
await store.set('api-key', 'sk-12345');
|
||||
const stat = statSync(join(tempDir, 'secrets'));
|
||||
expect(stat.mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
it('creates config dir if missing', async () => {
|
||||
const nested = join(tempDir, 'sub', 'dir');
|
||||
const store = new FileSecretStore({ configDir: nested });
|
||||
await store.set('api-key', 'sk-12345');
|
||||
expect(existsSync(join(nested, 'secrets'))).toBe(true);
|
||||
});
|
||||
|
||||
it('recovers from corrupted JSON', async () => {
|
||||
writeFileSync(join(tempDir, 'secrets'), 'NOT JSON!!!', 'utf-8');
|
||||
const store = new FileSecretStore({ configDir: tempDir });
|
||||
// Should not throw, returns null for any key
|
||||
expect(await store.get('api-key')).toBeNull();
|
||||
// Should be able to write over corrupted file
|
||||
await store.set('api-key', 'fresh-value');
|
||||
expect(await store.get('api-key')).toBe('fresh-value');
|
||||
});
|
||||
|
||||
it('reports file backend', () => {
|
||||
const store = new FileSecretStore({ configDir: tempDir });
|
||||
expect(store.backend()).toBe('file');
|
||||
});
|
||||
|
||||
it('preserves other keys on delete', async () => {
|
||||
const store = new FileSecretStore({ configDir: tempDir });
|
||||
await store.set('key-a', 'value-a');
|
||||
await store.set('key-b', 'value-b');
|
||||
await store.delete('key-a');
|
||||
expect(await store.get('key-a')).toBeNull();
|
||||
expect(await store.get('key-b')).toBe('value-b');
|
||||
});
|
||||
});
|
||||
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