feat: add config schema and loader with env var expansion
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
# Flynn Configuration
|
||||||
|
# Copy to ~/.config/flynn/config.yaml and customize
|
||||||
|
|
||||||
|
telegram:
|
||||||
|
bot_token: ${FLYNN_TELEGRAM_TOKEN}
|
||||||
|
allowed_chat_ids: [] # Add your Telegram chat ID
|
||||||
|
|
||||||
|
server:
|
||||||
|
tailscale_only: true
|
||||||
|
localhost: true
|
||||||
|
port: 18800
|
||||||
|
|
||||||
|
models:
|
||||||
|
default:
|
||||||
|
provider: anthropic
|
||||||
|
model: claude-sonnet-4-20250514
|
||||||
|
|
||||||
|
hooks:
|
||||||
|
confirm:
|
||||||
|
- shell.*
|
||||||
|
- file.write
|
||||||
|
log:
|
||||||
|
- web.*
|
||||||
|
- file.read
|
||||||
|
silent:
|
||||||
|
- notify
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { loadConfig } from './loader.js';
|
||||||
|
export { configSchema, type Config, type TelegramConfig, type ModelConfig } from './schema.js';
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { loadConfig } from './loader.js';
|
||||||
|
import { writeFileSync, mkdirSync, rmSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
|
||||||
|
describe('loadConfig', () => {
|
||||||
|
const testDir = join(tmpdir(), 'flynn-test-config');
|
||||||
|
|
||||||
|
it('loads valid config from file', () => {
|
||||||
|
mkdirSync(testDir, { recursive: true });
|
||||||
|
const configPath = join(testDir, 'config.yaml');
|
||||||
|
writeFileSync(configPath, `
|
||||||
|
telegram:
|
||||||
|
bot_token: "test-token"
|
||||||
|
allowed_chat_ids: [123456789]
|
||||||
|
server:
|
||||||
|
port: 18800
|
||||||
|
models:
|
||||||
|
default:
|
||||||
|
provider: anthropic
|
||||||
|
model: claude-sonnet
|
||||||
|
`);
|
||||||
|
|
||||||
|
const config = loadConfig(configPath);
|
||||||
|
|
||||||
|
expect(config.telegram.bot_token).toBe('test-token');
|
||||||
|
expect(config.telegram.allowed_chat_ids).toContain(123456789);
|
||||||
|
expect(config.server.port).toBe(18800);
|
||||||
|
expect(config.models.default.provider).toBe('anthropic');
|
||||||
|
|
||||||
|
rmSync(testDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expands environment variables', () => {
|
||||||
|
mkdirSync(testDir, { recursive: true });
|
||||||
|
const configPath = join(testDir, 'config.yaml');
|
||||||
|
process.env.TEST_BOT_TOKEN = 'env-token-value';
|
||||||
|
writeFileSync(configPath, `
|
||||||
|
telegram:
|
||||||
|
bot_token: \${TEST_BOT_TOKEN}
|
||||||
|
allowed_chat_ids: [123]
|
||||||
|
server:
|
||||||
|
port: 18800
|
||||||
|
models:
|
||||||
|
default:
|
||||||
|
provider: anthropic
|
||||||
|
model: claude-sonnet
|
||||||
|
`);
|
||||||
|
|
||||||
|
const config = loadConfig(configPath);
|
||||||
|
|
||||||
|
expect(config.telegram.bot_token).toBe('env-token-value');
|
||||||
|
|
||||||
|
delete process.env.TEST_BOT_TOKEN;
|
||||||
|
rmSync(testDir, { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on invalid config', () => {
|
||||||
|
mkdirSync(testDir, { recursive: true });
|
||||||
|
const configPath = join(testDir, 'config.yaml');
|
||||||
|
writeFileSync(configPath, `
|
||||||
|
telegram:
|
||||||
|
bot_token: ""
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(() => loadConfig(configPath)).toThrow();
|
||||||
|
|
||||||
|
rmSync(testDir, { recursive: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { parse } from 'yaml';
|
||||||
|
import { configSchema, type Config } from './schema.js';
|
||||||
|
|
||||||
|
function expandEnvVars(value: string): string {
|
||||||
|
return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
|
||||||
|
const envValue = process.env[envVar];
|
||||||
|
if (envValue === undefined) {
|
||||||
|
throw new Error(`Environment variable ${envVar} is not set`);
|
||||||
|
}
|
||||||
|
return envValue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandEnvVarsInObject(obj: unknown): unknown {
|
||||||
|
if (typeof obj === 'string') {
|
||||||
|
return expandEnvVars(obj);
|
||||||
|
}
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map(expandEnvVarsInObject);
|
||||||
|
}
|
||||||
|
if (obj !== null && typeof obj === 'object') {
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
result[key] = expandEnvVarsInObject(value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(configPath: string): Config {
|
||||||
|
const rawContent = readFileSync(configPath, 'utf-8');
|
||||||
|
const rawConfig = parse(rawContent);
|
||||||
|
const expandedConfig = expandEnvVarsInObject(rawConfig);
|
||||||
|
return configSchema.parse(expandedConfig);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const telegramSchema = z.object({
|
||||||
|
bot_token: z.string().min(1, 'Bot token is required'),
|
||||||
|
allowed_chat_ids: z.array(z.number()).min(1, 'At least one chat ID required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const serverSchema = z.object({
|
||||||
|
tailscale_only: z.boolean().default(true),
|
||||||
|
localhost: z.boolean().default(true),
|
||||||
|
port: z.number().default(18800),
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelConfigSchema = z.object({
|
||||||
|
provider: z.enum(['anthropic', 'openai', 'gemini', 'ollama', 'llamacpp']),
|
||||||
|
model: z.string(),
|
||||||
|
endpoint: z.string().optional(),
|
||||||
|
for: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelsSchema = z.object({
|
||||||
|
local: modelConfigSchema.optional(),
|
||||||
|
fast: modelConfigSchema.optional(),
|
||||||
|
default: modelConfigSchema,
|
||||||
|
complex: modelConfigSchema.optional(),
|
||||||
|
fallback_chain: z.array(z.string()).default(['anthropic']),
|
||||||
|
});
|
||||||
|
|
||||||
|
const backendsSchema = z.object({
|
||||||
|
claude_code: z.object({
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
path: z.string().optional(),
|
||||||
|
}).default({ enabled: false }),
|
||||||
|
opencode: z.object({
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
path: z.string().optional(),
|
||||||
|
}).default({ enabled: false }),
|
||||||
|
native: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
}).default({ enabled: true }),
|
||||||
|
}).default({});
|
||||||
|
|
||||||
|
const hooksSchema = z.object({
|
||||||
|
confirm: z.array(z.string()).default([]),
|
||||||
|
log: z.array(z.string()).default([]),
|
||||||
|
silent: z.array(z.string()).default([]),
|
||||||
|
}).default({});
|
||||||
|
|
||||||
|
const mcpServerSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
command: z.string(),
|
||||||
|
args: z.array(z.string()).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mcpSchema = z.object({
|
||||||
|
servers: z.array(mcpServerSchema).default([]),
|
||||||
|
}).default({ servers: [] });
|
||||||
|
|
||||||
|
export const configSchema = z.object({
|
||||||
|
telegram: telegramSchema,
|
||||||
|
server: serverSchema.default({}),
|
||||||
|
models: modelsSchema,
|
||||||
|
backends: backendsSchema.default({}),
|
||||||
|
hooks: hooksSchema.default({}),
|
||||||
|
mcp: mcpSchema.default({ servers: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Config = z.infer<typeof configSchema>;
|
||||||
|
export type TelegramConfig = z.infer<typeof telegramSchema>;
|
||||||
|
export type ModelConfig = z.infer<typeof modelConfigSchema>;
|
||||||
Reference in New Issue
Block a user