feat(cli): add shared utilities for config loading and output
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
||||
if (sensitiveKeys.includes(key) && typeof value === 'string') {
|
||||
result[key] = '***';
|
||||
} else {
|
||||
result[key] = redact(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
return redact(config) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Format output for terminal display. */
|
||||
export function formatStatus(
|
||||
status: 'pass' | 'fail' | 'warn' | 'skip',
|
||||
label: string,
|
||||
detail?: string,
|
||||
): string {
|
||||
const icons: Record<string, string> = {
|
||||
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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user