diff --git a/src/cli.ts b/src/cli.ts index 8a648b6..9c0e2f8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,8 +9,8 @@ */ // Config loaded from lettabot.yaml -import { loadConfig, applyConfigToEnv, serverModeLabel } from './config/index.js'; -const config = loadConfig(); +import { loadAppConfigOrExit, applyConfigToEnv, serverModeLabel } from './config/index.js'; +const config = loadAppConfigOrExit(); applyConfigToEnv(config); import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; diff --git a/src/cli/channel-management.ts b/src/cli/channel-management.ts index 109751f..14ad73c 100644 --- a/src/cli/channel-management.ts +++ b/src/cli/channel-management.ts @@ -6,7 +6,7 @@ */ import * as p from '@clack/prompts'; -import { loadConfig, saveConfig, resolveConfigPath } from '../config/index.js'; +import { loadAppConfigOrExit, saveConfig, resolveConfigPath } from '../config/index.js'; import { CHANNELS, getChannelHint, @@ -46,7 +46,7 @@ function getChannelDetails(id: ChannelId, channelConfig: any): string | undefine } function getChannelStatus(): ChannelStatus[] { - const config = loadConfig(); + const config = loadAppConfigOrExit(); return CHANNELS.map(ch => { const channelConfig = config.channels[ch.id as keyof typeof config.channels]; @@ -202,7 +202,7 @@ export async function addChannel(channelId?: string): Promise { process.exit(1); } - const config = loadConfig(); + const config = loadAppConfigOrExit(); const existingConfig = config.channels[channelId as keyof typeof config.channels]; // Get and run the setup function @@ -230,7 +230,7 @@ export async function removeChannel(channelId?: string): Promise { process.exit(1); } - const config = loadConfig(); + const config = loadAppConfigOrExit(); const channelConfig = config.channels[channelId as keyof typeof config.channels]; if (!channelConfig?.enabled) { diff --git a/src/cli/channels.ts b/src/cli/channels.ts index 0529e67..c5831bd 100644 --- a/src/cli/channels.ts +++ b/src/cli/channels.ts @@ -10,8 +10,8 @@ */ // Config loaded from lettabot.yaml -import { loadConfig, applyConfigToEnv } from '../config/index.js'; -const config = loadConfig(); +import { loadAppConfigOrExit, applyConfigToEnv } from '../config/index.js'; +const config = loadAppConfigOrExit(); applyConfigToEnv(config); // Types diff --git a/src/cli/history.ts b/src/cli/history.ts index 98a1f57..b5258e5 100644 --- a/src/cli/history.ts +++ b/src/cli/history.ts @@ -7,8 +7,8 @@ */ // Config loaded from lettabot.yaml -import { loadConfig, applyConfigToEnv } from '../config/index.js'; -const config = loadConfig(); +import { loadAppConfigOrExit, applyConfigToEnv } from '../config/index.js'; +const config = loadAppConfigOrExit(); applyConfigToEnv(config); import { fetchHistory, isValidLimit, parseFetchArgs } from './history-core.js'; import { loadLastTarget } from './shared.js'; diff --git a/src/cli/message.ts b/src/cli/message.ts index d4c7c62..df1284a 100644 --- a/src/cli/message.ts +++ b/src/cli/message.ts @@ -11,8 +11,8 @@ */ // Config loaded from lettabot.yaml -import { loadConfig, applyConfigToEnv } from '../config/index.js'; -const config = loadConfig(); +import { loadAppConfigOrExit, applyConfigToEnv } from '../config/index.js'; +const config = loadAppConfigOrExit(); applyConfigToEnv(config); import { existsSync, readFileSync } from 'node:fs'; import { loadLastTarget } from './shared.js'; diff --git a/src/cli/react.ts b/src/cli/react.ts index b42882d..d25b4f3 100644 --- a/src/cli/react.ts +++ b/src/cli/react.ts @@ -10,8 +10,8 @@ */ // Config loaded from lettabot.yaml -import { loadConfig, applyConfigToEnv } from '../config/index.js'; -const config = loadConfig(); +import { loadAppConfigOrExit, applyConfigToEnv } from '../config/index.js'; +const config = loadAppConfigOrExit(); applyConfigToEnv(config); import { loadLastTarget } from './shared.js'; diff --git a/src/config/index.ts b/src/config/index.ts index 47bd215..877b41f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,2 +1,3 @@ export * from './types.js'; export * from './io.js'; +export * from './runtime.js'; diff --git a/src/config/io.test.ts b/src/config/io.test.ts index cfb5716..89513dd 100644 --- a/src/config/io.test.ts +++ b/src/config/io.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, existsSync, readFileSync, writeFileSync, rmSync } from 'no import { join } from 'node:path'; import { tmpdir } from 'node:os'; import YAML from 'yaml'; -import { saveConfig, loadConfig, configToEnv, didLoadFail } from './io.js'; +import { saveConfig, loadConfig, loadConfigStrict, configToEnv, didLoadFail } from './io.js'; import { normalizeAgents, DEFAULT_CONFIG } from './types.js'; import type { LettaBotConfig } from './types.js'; @@ -375,3 +375,45 @@ describe('loadConfig deprecation warning for top-level api', () => { } }); }); + +describe('loadConfigStrict', () => { + it('should throw on parse error and set didLoadFail', () => { + const originalEnv = process.env.LETTABOT_CONFIG; + const tmpDir = mkdtempSync(join(tmpdir(), 'lettabot-strict-test-')); + const configPath = join(tmpDir, 'lettabot.yaml'); + + try { + writeFileSync(configPath, 'server:\n api: port: 6702\n', 'utf-8'); + process.env.LETTABOT_CONFIG = configPath; + + expect(() => loadConfigStrict()).toThrow(); + expect(didLoadFail()).toBe(true); + } finally { + process.env.LETTABOT_CONFIG = originalEnv; + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should throw when both top-level api and server.api are present', () => { + const originalEnv = process.env.LETTABOT_CONFIG; + const tmpDir = mkdtempSync(join(tmpdir(), 'lettabot-strict-test-')); + const configPath = join(tmpDir, 'lettabot.yaml'); + + try { + writeFileSync( + configPath, + 'server:\n mode: api\n api:\n host: 0.0.0.0\napi:\n port: 9090\n', + 'utf-8' + ); + process.env.LETTABOT_CONFIG = configPath; + + expect(() => loadConfigStrict()).toThrow( + /both top-level `api` and `server\.api` are set/ + ); + expect(didLoadFail()).toBe(true); + } finally { + process.env.LETTABOT_CONFIG = originalEnv; + rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/config/io.ts b/src/config/io.ts index 4521578..8f2285b 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -53,6 +53,51 @@ export function resolveConfigPath(): string { let _lastLoadFailed = false; export function didLoadFail(): boolean { return _lastLoadFailed; } +function hasObject(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function parseAndNormalizeConfig(content: string): LettaBotConfig { + const parsed = YAML.parse(content) as Partial; + + // Fix instantGroups: YAML parses large numeric IDs (e.g. Discord snowflakes) + // as JavaScript numbers, losing precision for values > Number.MAX_SAFE_INTEGER. + // Re-extract from document AST to preserve the original string representation. + fixLargeGroupIds(content, parsed); + + // Reject ambiguous API server configuration. During migration from top-level + // `api` to `server.api`, having both can silently drop fields. + if (hasObject(parsed.api) && hasObject(parsed.server) && hasObject(parsed.server.api)) { + throw new Error( + 'Conflicting API config: both top-level `api` and `server.api` are set. Remove top-level `api` and keep only `server.api`.' + ); + } + + // Merge with defaults and canonicalize server mode. + const merged = { + ...DEFAULT_CONFIG, + ...parsed, + server: { ...DEFAULT_CONFIG.server, ...parsed.server }, + agent: { ...DEFAULT_CONFIG.agent, ...parsed.agent }, + channels: { ...DEFAULT_CONFIG.channels, ...parsed.channels }, + }; + + const config = { + ...merged, + server: { + ...merged.server, + mode: canonicalizeServerMode(merged.server.mode), + }, + }; + + // Deprecation warning: top-level api should be moved under server + if (config.api && !config.server.api) { + console.warn('[Config] WARNING: Top-level `api:` is deprecated. Move it under `server:`.'); + } + + return config; +} + /** * Load config from YAML file */ @@ -66,44 +111,36 @@ export function loadConfig(): LettaBotConfig { try { const content = readFileSync(configPath, 'utf-8'); - const parsed = YAML.parse(content) as Partial; - - // Fix instantGroups: YAML parses large numeric IDs (e.g. Discord snowflakes) - // as JavaScript numbers, losing precision for values > Number.MAX_SAFE_INTEGER. - // Re-extract from document AST to preserve the original string representation. - fixLargeGroupIds(content, parsed); - - // Merge with defaults and canonicalize server mode. - const merged = { - ...DEFAULT_CONFIG, - ...parsed, - server: { ...DEFAULT_CONFIG.server, ...parsed.server }, - agent: { ...DEFAULT_CONFIG.agent, ...parsed.agent }, - channels: { ...DEFAULT_CONFIG.channels, ...parsed.channels }, - }; - - const config = { - ...merged, - server: { - ...merged.server, - mode: canonicalizeServerMode(merged.server.mode), - }, - }; - - // Deprecation warning: top-level api should be moved under server - if (config.api && !config.server.api) { - console.warn('[Config] WARNING: Top-level `api:` is deprecated. Move it under `server:`.'); - } - - return config; + return parseAndNormalizeConfig(content); } catch (err) { _lastLoadFailed = true; - console.error(`[Config] Failed to parse ${configPath}:`, err); - console.warn('[Config] Using default configuration. Check your YAML syntax.'); + console.error(`[Config] Failed to load ${configPath}:`, err); + console.warn('[Config] Using default configuration. Check your YAML syntax and field locations.'); return { ...DEFAULT_CONFIG }; } } +/** + * Strict config loader. Throws on invalid YAML/schema instead of silently + * falling back to defaults. + */ +export function loadConfigStrict(): LettaBotConfig { + _lastLoadFailed = false; + const configPath = resolveConfigPath(); + + if (!existsSync(configPath)) { + return { ...DEFAULT_CONFIG }; + } + + try { + const content = readFileSync(configPath, 'utf-8'); + return parseAndNormalizeConfig(content); + } catch (err) { + _lastLoadFailed = true; + throw err; + } +} + /** * Save config to YAML file */ diff --git a/src/config/runtime.test.ts b/src/config/runtime.test.ts new file mode 100644 index 0000000..ce02bd7 --- /dev/null +++ b/src/config/runtime.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, vi } from 'vitest'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { loadAppConfigOrExit } from './runtime.js'; +import { didLoadFail } from './io.js'; + +describe('loadAppConfigOrExit', () => { + it('should load valid config without exiting', () => { + const originalEnv = process.env.LETTABOT_CONFIG; + const tmpDir = mkdtempSync(join(tmpdir(), 'lettabot-runtime-test-')); + const configPath = join(tmpDir, 'lettabot.yaml'); + + try { + writeFileSync(configPath, 'server:\n mode: api\n', 'utf-8'); + process.env.LETTABOT_CONFIG = configPath; + + const config = loadAppConfigOrExit(((code: number): never => { + throw new Error(`unexpected-exit:${code}`); + })); + + expect(config.server.mode).toBe('api'); + expect(didLoadFail()).toBe(false); + } finally { + process.env.LETTABOT_CONFIG = originalEnv; + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should log and exit on invalid config', () => { + const originalEnv = process.env.LETTABOT_CONFIG; + const tmpDir = mkdtempSync(join(tmpdir(), 'lettabot-runtime-test-')); + const configPath = join(tmpDir, 'lettabot.yaml'); + + try { + writeFileSync(configPath, 'server:\n api: port: 6702\n', 'utf-8'); + process.env.LETTABOT_CONFIG = configPath; + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const exit = (code: number): never => { + throw new Error(`exit:${code}`); + }; + + expect(() => loadAppConfigOrExit(exit)).toThrow('exit:1'); + expect(didLoadFail()).toBe(true); + expect(errorSpy).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('Failed to load'), + expect.anything() + ); + expect(errorSpy).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('Fix the errors above') + ); + + errorSpy.mockRestore(); + } finally { + process.env.LETTABOT_CONFIG = originalEnv; + rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/config/runtime.ts b/src/config/runtime.ts new file mode 100644 index 0000000..821aafc --- /dev/null +++ b/src/config/runtime.ts @@ -0,0 +1,19 @@ +import type { LettaBotConfig } from './types.js'; +import { loadConfigStrict, resolveConfigPath } from './io.js'; + +export type ExitFn = (code: number) => never; + +/** + * Load config for app/CLI entrypoints. On invalid config, print one + * consistent error and terminate. + */ +export function loadAppConfigOrExit(exitFn: ExitFn = process.exit): LettaBotConfig { + try { + return loadConfigStrict(); + } catch (err) { + const configPath = resolveConfigPath(); + console.error(`[Config] Failed to load ${configPath}:`, err); + console.error(`[Config] Fix the errors above in ${configPath} and restart.`); + return exitFn(1); + } +} diff --git a/src/main.ts b/src/main.ts index 5e33564..35bc584 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,23 +15,18 @@ import { loadOrGenerateApiKey } from './api/auth.js'; // Load YAML config and apply to process.env (overrides .env values) import { - loadConfig, + loadAppConfigOrExit, applyConfigToEnv, syncProviders, resolveConfigPath, - didLoadFail, isDockerServerMode, serverModeLabel, } from './config/index.js'; import { isLettaApiUrl } from './utils/server.js'; import { getDataDir, getWorkingDir, hasRailwayVolume } from './utils/paths.js'; -const yamlConfig = loadConfig(); -if (didLoadFail()) { - console.warn(`[Config] Fix the errors above in ${resolveConfigPath()} and restart.`); -} else { - const configSource = existsSync(resolveConfigPath()) ? resolveConfigPath() : 'defaults + environment variables'; - console.log(`[Config] Loaded from ${configSource}`); -} +const yamlConfig = loadAppConfigOrExit(); +const configSource = existsSync(resolveConfigPath()) ? resolveConfigPath() : 'defaults + environment variables'; +console.log(`[Config] Loaded from ${configSource}`); if (yamlConfig.agents?.length) { console.log(`[Config] Mode: ${serverModeLabel(yamlConfig.server.mode)}, Agents: ${yamlConfig.agents.map(a => a.name).join(', ')}`); } else { diff --git a/src/onboard.ts b/src/onboard.ts index 41e04c1..207fdc7 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -1258,8 +1258,8 @@ export async function onboard(options?: { nonInteractive?: boolean }): Promise = {}; // Load existing config if available - const { loadConfig, resolveConfigPath } = await import('./config/index.js'); - const existingConfig = loadConfig(); + const { loadAppConfigOrExit, resolveConfigPath } = await import('./config/index.js'); + const existingConfig = loadAppConfigOrExit(); const configPath = resolveConfigPath(); const hasExistingConfig = existsSync(configPath);