From 6f7b5b8f0fa03a9454142dc7bc124acd92fd258c Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 5 Feb 2026 22:13:05 -0800 Subject: [PATCH] feat(cli): add shared utilities for config loading and output --- src/cli/shared.test.ts | 101 +++++++++++++++++++++++++++++++++++++++++ src/cli/shared.ts | 66 +++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 src/cli/shared.test.ts create mode 100644 src/cli/shared.ts diff --git a/src/cli/shared.test.ts b/src/cli/shared.test.ts new file mode 100644 index 0000000..4d8f123 --- /dev/null +++ b/src/cli/shared.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { loadConfigSafe, redactSecrets, getConfigPath, getDataDir } from './shared.js'; +import { writeFileSync, mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; + +describe('CLI shared utilities', () => { + const testDir = join(tmpdir(), 'flynn-test-cli-shared'); + + afterEach(() => { + try { rmSync(testDir, { recursive: true }); } catch {} + }); + + describe('getConfigPath', () => { + it('returns FLYNN_CONFIG env var if set', () => { + const original = process.env.FLYNN_CONFIG; + process.env.FLYNN_CONFIG = '/custom/path.yaml'; + expect(getConfigPath()).toBe('/custom/path.yaml'); + if (original !== undefined) { + process.env.FLYNN_CONFIG = original; + } else { + delete process.env.FLYNN_CONFIG; + } + }); + + it('returns default path if env var not set', () => { + const original = process.env.FLYNN_CONFIG; + delete process.env.FLYNN_CONFIG; + const path = getConfigPath(); + expect(path).toContain('.config/flynn/config.yaml'); + if (original !== undefined) { + process.env.FLYNN_CONFIG = original; + } + }); + }); + + describe('loadConfigSafe', () => { + it('returns config on success', () => { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'config.yaml'); + writeFileSync(configPath, ` +telegram: + bot_token: "test-token" + allowed_chat_ids: [123] +models: + default: + provider: anthropic + model: claude-sonnet +`); + const result = loadConfigSafe(configPath); + expect(result.config).toBeDefined(); + expect(result.error).toBeUndefined(); + expect(result.config!.telegram.bot_token).toBe('test-token'); + }); + + it('returns error when file not found', () => { + const result = loadConfigSafe('/nonexistent/config.yaml'); + expect(result.config).toBeUndefined(); + expect(result.error).toBeDefined(); + }); + + it('returns error on invalid YAML', () => { + mkdirSync(testDir, { recursive: true }); + const configPath = join(testDir, 'bad.yaml'); + writeFileSync(configPath, '{{{{invalid yaml'); + const result = loadConfigSafe(configPath); + expect(result.config).toBeUndefined(); + expect(result.error).toBeDefined(); + }); + }); + + describe('redactSecrets', () => { + it('redacts bot_token', () => { + const config = { + telegram: { bot_token: 'secret-token-123', allowed_chat_ids: [123] }, + models: { default: { provider: 'anthropic', model: 'claude' } }, + }; + const redacted = redactSecrets(config); + expect(redacted.telegram.bot_token).toBe('***'); + expect(redacted.telegram.allowed_chat_ids).toEqual([123]); + }); + + it('redacts api_key in models', () => { + const config = { + telegram: { bot_token: 'token', allowed_chat_ids: [123] }, + models: { + default: { provider: 'anthropic', model: 'claude', api_key: 'sk-secret' }, + }, + }; + const redacted = redactSecrets(config); + expect(redacted.models.default.api_key).toBe('***'); + }); + }); + + describe('getDataDir', () => { + it('returns path under home directory', () => { + const dir = getDataDir(); + expect(dir).toContain('.local/share/flynn'); + }); + }); +}); diff --git a/src/cli/shared.ts b/src/cli/shared.ts new file mode 100644 index 0000000..a1b51c4 --- /dev/null +++ b/src/cli/shared.ts @@ -0,0 +1,66 @@ +import { loadConfig } from '../config/index.js'; +import type { Config } from '../config/index.js'; +import { resolve } from 'path'; +import { homedir } from 'os'; + +/** Get the config file path from env or default location. */ +export function getConfigPath(): string { + return process.env.FLYNN_CONFIG ?? resolve(homedir(), '.config/flynn/config.yaml'); +} + +/** Get the data directory path. */ +export function getDataDir(): string { + return resolve(homedir(), '.local/share/flynn'); +} + +/** Load config without throwing. Returns { config } or { error }. */ +export function loadConfigSafe(configPath?: string): { config?: Config; error?: string } { + const path = configPath ?? getConfigPath(); + try { + const config = loadConfig(path); + return { config }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { error: `Failed to load config from ${path}: ${message}` }; + } +} + +/** Deep-clone config and replace sensitive fields with '***'. */ +export function redactSecrets(config: Record): Record { + const sensitiveKeys = ['bot_token', 'api_key', 'auth_token']; + + function redact(obj: unknown): unknown { + if (obj === null || obj === undefined) return obj; + if (Array.isArray(obj)) return obj.map(redact); + if (typeof obj === 'object') { + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + if (sensitiveKeys.includes(key) && typeof value === 'string') { + result[key] = '***'; + } else { + result[key] = redact(value); + } + } + return result; + } + return obj; + } + + return redact(config) as Record; +} + +/** Format output for terminal display. */ +export function formatStatus( + status: 'pass' | 'fail' | 'warn' | 'skip', + label: string, + detail?: string, +): string { + const icons: Record = { + pass: '\x1b[32m[PASS]\x1b[0m', + fail: '\x1b[31m[FAIL]\x1b[0m', + warn: '\x1b[33m[WARN]\x1b[0m', + skip: '\x1b[2m[SKIP]\x1b[0m', + }; + const suffix = detail ? ` ${detail}` : ''; + return `${icons[status]} ${label}${suffix}`; +}