profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM
runtimeConfig.ts 3,391 bytes
1 import { chmodSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
2 import type { ConfigStatus, ConfigUpdateRequest } from '../../shared/types'
3
4 export const ENV_FILE = '.env'
5
6 const PLACEHOLDER_PREFIX = '@insert'
7
8 export function readEnvKey(name: string, env: NodeJS.ProcessEnv = process.env): string | null {
9 const value = env[name]?.trim()
10 if (!value || value.startsWith(PLACEHOLDER_PREFIX)) return null
11 return value
12 }
13
14 export function keyHint(value: string | null): string | null {
15 if (!value) return null
16 return value.length <= 4 ? '****' : `...${value.slice(-4)}`
17 }
18
19 export function configStatus(demoLocked: boolean, env: NodeJS.ProcessEnv = process.env): ConfigStatus {
20 const anthropic = readEnvKey('ANTHROPIC_API_KEY', env)
21 const openai = readEnvKey('OPENAI_API_KEY', env)
22 const demo = env.MOCK_PROVIDERS === '1'
23
24 return {
25 mode: demo ? 'demo' : anthropic && openai ? 'real' : 'unconfigured',
26 demoLocked,
27 anthropicKeySet: anthropic !== null,
28 openaiKeySet: openai !== null,
29 anthropicKeyHint: keyHint(anthropic),
30 openaiKeyHint: keyHint(openai),
31 }
32 }
33
34 export function mergeEnvFile(existing: string, updates: Record<string, string>): string {
35 const written = new Set<string>()
36 const lines: string[] = []
37
38 for (const line of existing.length > 0 ? existing.split(/\r?\n/) : []) {
39 const name = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/)?.[1]
40 if (name === undefined || !(name in updates)) {
41 lines.push(line)
42 continue
43 }
44 // A later duplicate would win when the file is read back, so keep only one.
45 if (written.has(name)) continue
46 written.add(name)
47 lines.push(`${name}=${updates[name]}`)
48 }
49
50 while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
51
52 for (const [name, value] of Object.entries(updates)) {
53 if (!written.has(name)) lines.push(`${name}=${value}`)
54 }
55
56 return `${lines.join('\n')}\n`
57 }
58
59 export interface ApplyConfigOptions {
60 demoLocked: boolean
61 envPath?: string
62 env?: NodeJS.ProcessEnv
63 }
64
65 export interface ApplyConfigResult {
66 status: ConfigStatus
67 restartRequired: boolean
68 }
69
70 /**
71 * Persists the chosen provider mode to the local .env and, unless this process
72 * was started in demo mode, applies it to the running server right away.
73 */
74 export function applyConfig(update: ConfigUpdateRequest, options: ApplyConfigOptions): ApplyConfigResult {
75 const env = options.env ?? process.env
76 const envPath = options.envPath ?? ENV_FILE
77 const updates: Record<string, string> = {}
78
79 if (update.mode === 'demo') {
80 updates.MOCK_PROVIDERS = '1'
81 } else {
82 updates.MOCK_PROVIDERS = ''
83 if (update.anthropicKey) updates.ANTHROPIC_API_KEY = update.anthropicKey
84 if (update.openaiKey) updates.OPENAI_API_KEY = update.openaiKey
85 }
86
87 const existing = existsSync(envPath) ? readFileSync(envPath, 'utf8') : ''
88 writeFileSync(envPath, mergeEnvFile(existing, updates), 'utf8')
89 try {
90 chmodSync(envPath, 0o600)
91 } catch {
92 // Permission bits are advisory here; Windows filesystems ignore them.
93 }
94
95 const applyToProcess = !(options.demoLocked && update.mode === 'real')
96 if (applyToProcess) {
97 for (const [name, value] of Object.entries(updates)) {
98 if (value === '') delete env[name]
99 else env[name] = value
100 }
101 }
102
103 return { status: configStatus(options.demoLocked, env), restartRequired: !applyToProcess }
104 }
105