takt/src/infra/config/env/config-env-overrides.ts

152 lines
5.7 KiB
TypeScript

type EnvValueType = 'string' | 'boolean' | 'number' | 'json';
interface EnvSpec {
path: string;
type: EnvValueType;
}
function normalizeEnvSegment(segment: string): string {
return segment
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/[^a-zA-Z0-9]+/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '')
.toUpperCase();
}
export function envVarNameFromPath(path: string): string {
const key = path
.split('.')
.map(normalizeEnvSegment)
.filter((segment) => segment.length > 0)
.join('_');
return `TAKT_${key}`;
}
function parseEnvValue(envKey: string, raw: string, type: EnvValueType): unknown {
if (type === 'string') {
return raw;
}
if (type === 'boolean') {
const normalized = raw.trim().toLowerCase();
if (normalized === 'true') return true;
if (normalized === 'false') return false;
throw new Error(`${envKey} must be one of: true, false`);
}
if (type === 'number') {
const trimmed = raw.trim();
const value = Number(trimmed);
if (!Number.isFinite(value)) {
throw new Error(`${envKey} must be a number`);
}
return value;
}
try {
return JSON.parse(raw);
} catch {
throw new Error(`${envKey} must be valid JSON`);
}
}
function setNested(target: Record<string, unknown>, path: string, value: unknown): void {
const parts = path.split('.');
let current: Record<string, unknown> = target;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!part) continue;
const next = current[part];
if (typeof next !== 'object' || next === null || Array.isArray(next)) {
current[part] = {};
}
current = current[part] as Record<string, unknown>;
}
const leaf = parts[parts.length - 1];
if (!leaf) return;
current[leaf] = value;
}
function applyEnvOverrides(target: Record<string, unknown>, specs: readonly EnvSpec[]): void {
for (const spec of specs) {
const envKey = envVarNameFromPath(spec.path);
const raw = process.env[envKey];
if (raw === undefined) continue;
const parsedValue = parseEnvValue(envKey, raw, spec.type);
setNested(target, spec.path, parsedValue);
}
}
const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [
{ path: 'language', type: 'string' },
{ path: 'log_level', type: 'string' },
{ path: 'provider', type: 'string' },
{ path: 'model', type: 'string' },
{ path: 'observability', type: 'json' },
{ path: 'observability.provider_events', type: 'boolean' },
{ path: 'analytics', type: 'json' },
{ path: 'analytics.enabled', type: 'boolean' },
{ path: 'analytics.events_path', type: 'string' },
{ path: 'analytics.retention_days', type: 'number' },
{ path: 'worktree_dir', type: 'string' },
{ path: 'auto_pr', type: 'boolean' },
{ path: 'draft_pr', type: 'boolean' },
{ path: 'disabled_builtins', type: 'json' },
{ path: 'enable_builtin_pieces', type: 'boolean' },
{ path: 'anthropic_api_key', type: 'string' },
{ path: 'openai_api_key', type: 'string' },
{ path: 'codex_cli_path', type: 'string' },
{ path: 'opencode_api_key', type: 'string' },
{ path: 'pipeline', type: 'json' },
{ path: 'pipeline.default_branch_prefix', type: 'string' },
{ path: 'pipeline.commit_message_template', type: 'string' },
{ path: 'pipeline.pr_body_template', type: 'string' },
{ path: 'minimal_output', type: 'boolean' },
{ path: 'bookmarks_file', type: 'string' },
{ path: 'piece_categories_file', type: 'string' },
{ path: 'persona_providers', type: 'json' },
{ path: 'provider_options', type: 'json' },
{ path: 'provider_options.codex.network_access', type: 'boolean' },
{ path: 'provider_options.opencode.network_access', type: 'boolean' },
{ path: 'provider_options.claude.sandbox.allow_unsandboxed_commands', type: 'boolean' },
{ path: 'provider_options.claude.sandbox.excluded_commands', type: 'json' },
{ path: 'provider_profiles', type: 'json' },
{ path: 'runtime', type: 'json' },
{ path: 'runtime.prepare', type: 'json' },
{ path: 'branch_name_strategy', type: 'string' },
{ path: 'prevent_sleep', type: 'boolean' },
{ path: 'notification_sound', type: 'boolean' },
{ path: 'notification_sound_events', type: 'json' },
{ path: 'notification_sound_events.iteration_limit', type: 'boolean' },
{ path: 'notification_sound_events.piece_complete', type: 'boolean' },
{ path: 'notification_sound_events.piece_abort', type: 'boolean' },
{ path: 'notification_sound_events.run_complete', type: 'boolean' },
{ path: 'notification_sound_events.run_abort', type: 'boolean' },
{ path: 'interactive_preview_movements', type: 'number' },
{ path: 'verbose', type: 'boolean' },
{ path: 'concurrency', type: 'number' },
{ path: 'task_poll_interval_ms', type: 'number' },
];
const PROJECT_ENV_SPECS: readonly EnvSpec[] = [
{ path: 'piece', type: 'string' },
{ path: 'provider', type: 'string' },
{ path: 'verbose', type: 'boolean' },
{ path: 'analytics', type: 'json' },
{ path: 'analytics.enabled', type: 'boolean' },
{ path: 'analytics.events_path', type: 'string' },
{ path: 'analytics.retention_days', type: 'number' },
{ path: 'provider_options', type: 'json' },
{ path: 'provider_options.codex.network_access', type: 'boolean' },
{ path: 'provider_options.opencode.network_access', type: 'boolean' },
{ path: 'provider_options.claude.sandbox.allow_unsandboxed_commands', type: 'boolean' },
{ path: 'provider_options.claude.sandbox.excluded_commands', type: 'json' },
{ path: 'provider_profiles', type: 'json' },
];
export function applyGlobalConfigEnvOverrides(target: Record<string, unknown>): void {
applyEnvOverrides(target, GLOBAL_ENV_SPECS);
}
export function applyProjectConfigEnvOverrides(target: Record<string, unknown>): void {
applyEnvOverrides(target, PROJECT_ENV_SPECS);
}