From 4adf172c254b777fd89f1e7cd38f6ba05121bb53 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 2 Feb 2026 20:54:19 -0800 Subject: [PATCH] feat: add config schema and loader with env var expansion --- config/default.yaml | 26 ++++++++++++++ src/config/index.ts | 2 ++ src/config/loader.test.ts | 71 +++++++++++++++++++++++++++++++++++++++ src/config/loader.ts | 37 ++++++++++++++++++++ src/config/schema.ts | 70 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 206 insertions(+) create mode 100644 config/default.yaml create mode 100644 src/config/index.ts create mode 100644 src/config/loader.test.ts create mode 100644 src/config/loader.ts create mode 100644 src/config/schema.ts diff --git a/config/default.yaml b/config/default.yaml new file mode 100644 index 0000000..42657df --- /dev/null +++ b/config/default.yaml @@ -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 diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..d6407d4 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,2 @@ +export { loadConfig } from './loader.js'; +export { configSchema, type Config, type TelegramConfig, type ModelConfig } from './schema.js'; diff --git a/src/config/loader.test.ts b/src/config/loader.test.ts new file mode 100644 index 0000000..8f7b039 --- /dev/null +++ b/src/config/loader.test.ts @@ -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 }); + }); +}); diff --git a/src/config/loader.ts b/src/config/loader.ts new file mode 100644 index 0000000..30ed0f2 --- /dev/null +++ b/src/config/loader.ts @@ -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 = {}; + 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); +} diff --git a/src/config/schema.ts b/src/config/schema.ts new file mode 100644 index 0000000..a9bfba6 --- /dev/null +++ b/src/config/schema.ts @@ -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; +export type TelegramConfig = z.infer; +export type ModelConfig = z.infer;