Files
sim/scripts/setup/env-files.ts
T
Theodore Li 35fd4ef42f improvement(self-host): simplify capability setup configuration (#6230)
* feat(self-host): add capability-aware setup

* fix(self-host): preserve capability compatibility

* fix(copilot): honor preview availability server-side

* improvement(self-host): centralize capability resolution

* fix(self-host): preserve integration availability paths

* fix(testing): align capability-aware config mocks

* improvement(self-host): simplify capability setup configuration

* fix(setup): preserve unowned storage overrides

* fix(self-host): reconcile storage and allowlists

* fix(integrations): preserve connect deep links
2026-08-04 15:47:36 -04:00

210 lines
7.2 KiB
TypeScript

import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { generateRandomHex } from '@sim/utils/random'
export const ROOT = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '../..')
export type EnvTarget = 'sim' | 'realtime' | 'db' | 'root'
export const ENV_PATHS: Record<EnvTarget, string> = {
sim: path.join(ROOT, 'apps/sim/.env'),
realtime: path.join(ROOT, 'apps/realtime/.env'),
db: path.join(ROOT, 'packages/db/.env'),
root: path.join(ROOT, '.env'),
}
const EXAMPLE_PATHS: Partial<Record<EnvTarget, string>> = {
sim: path.join(ROOT, 'apps/sim/.env.example'),
realtime: path.join(ROOT, 'apps/realtime/.env.example'),
db: path.join(ROOT, 'packages/db/.env.example'),
}
/** Keys that must be byte-identical between apps/sim/.env and apps/realtime/.env. */
export const SHARED_KEYS = [
'DATABASE_URL',
'BETTER_AUTH_SECRET',
'INTERNAL_API_SECRET',
'BETTER_AUTH_URL',
'NEXT_PUBLIC_APP_URL',
] as const
export const SECRET_KEYS = [
'BETTER_AUTH_SECRET',
'ENCRYPTION_KEY',
'INTERNAL_API_SECRET',
'API_ENCRYPTION_KEY',
// Authenticates the scheduler against the background job endpoints. Without
// it the cron service exits and no scheduled work runs, so setup generates it.
'CRON_SECRET',
] as const
const PLACEHOLDER_VALUES = new Set([
'your_password',
'your_secret_key',
'your_encryption_key',
'your_internal_api_secret',
'your_api_encryption_key',
'your_better_auth_secret_min_32_chars',
'dev-secret-at-least-32-characters-long',
'dev-encryption-key-at-least-32-chars',
'dev-internal-api-secret-min-32-chars',
])
export interface EnvFile {
target: EnvTarget
path: string
exists: boolean
content: string
vars: Map<string, string>
}
const LINE_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/
function parseValue(raw: string): string {
const trimmed = raw.trim()
if (trimmed.startsWith('"') || trimmed.startsWith("'")) {
const quote = trimmed[0]
const end = trimmed.indexOf(quote, 1)
return end === -1 ? trimmed.slice(1) : trimmed.slice(1, end)
}
return trimmed.replace(/\s+#.*$/, '').trim()
}
export function parseEnv(content: string): Map<string, string> {
const vars = new Map<string, string>()
for (const line of content.split('\n')) {
const match = LINE_RE.exec(line)
if (match) vars.set(match[1], parseValue(match[2]))
}
return vars
}
export function readEnvFile(target: EnvTarget): EnvFile {
const filePath = ENV_PATHS[target]
const exists = existsSync(filePath)
const content = exists ? readFileSync(filePath, 'utf8') : ''
return { target, path: filePath, exists, content, vars: parseEnv(content) }
}
/**
* Sets a key in env-file content: replaces the active line, uncomments a
* commented-out line, or appends. Returns the new content.
*/
export function upsertEnv(content: string, key: string, value: string): string {
const lines = content.split('\n')
const activeRe = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`)
const commentedRe = new RegExp(`^#\\s*${key}\\s*=`)
const activeIndexes = lines.flatMap((line, index) => (activeRe.test(line) ? [index] : []))
if (activeIndexes.length > 1) {
throw new Error(`Duplicate active ${key} entries found in environment file`)
}
const activeIdx = activeIndexes[0] ?? -1
const idx = activeIdx !== -1 ? activeIdx : lines.findIndex((l) => commentedRe.test(l))
const newLine = `${key}=${value}`
if (idx === -1) {
const trailing = lines.length > 0 && lines[lines.length - 1] === ''
if (trailing) lines.splice(lines.length - 1, 0, newLine)
else lines.push(newLine)
} else {
lines[idx] = newLine
}
return lines.join('\n')
}
/** Applies removals and replacements to one in-memory snapshot before it is written. */
export function reconcileEnvContent(
content: string,
remove: readonly string[],
values: Record<string, string>
): string {
const replacementKeys = new Set(Object.keys(values))
const removalKeys = new Set(remove.filter((key) => !replacementKeys.has(key)))
let reconciled = content
.split('\n')
.filter((line) => {
const match = LINE_RE.exec(line)
return !match || !removalKeys.has(match[1])
})
.join('\n')
for (const [key, value] of Object.entries(values)) {
reconciled = upsertEnv(reconciled, key, value)
}
return reconciled
}
/** Computes removals and replacements before writing the env file once. */
export function reconcileEnvValues(
target: EnvTarget,
remove: readonly string[],
values: Record<string, string>
): void {
const filePath = ENV_PATHS[target]
let content: string
if (existsSync(filePath)) {
content = readFileSync(filePath, 'utf8')
} else {
const example = EXAMPLE_PATHS[target]
content = example && existsSync(example) ? readFileSync(example, 'utf8') : ''
}
writeFileSync(filePath, reconcileEnvContent(content, remove, values))
}
/** Writes values into an env file, seeding a missing file from its .env.example. */
export function writeEnvValues(target: EnvTarget, values: Record<string, string>): void {
reconcileEnvValues(target, [], values)
}
export function archiveEnvFile(target: EnvTarget): string | null {
const filePath = ENV_PATHS[target]
if (!existsSync(filePath)) return null
const backup = `${filePath}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`
renameSync(filePath, backup)
return backup
}
export function generateSecret(): string {
return generateRandomHex(64)
}
/**
* `ENCRYPTION_KEY` and `API_ENCRYPTION_KEY` are read as raw AES-256 material,
* so the app requires exactly 64 hex characters and throws on anything else
* (`lib/core/security/encryption.ts`, `lib/api-key/crypto.ts`). A merely-long
* passphrase passes a length check and then fails every encryption path at
* runtime, so those two are validated on format rather than length.
*
* Lives here so setup (which replaces an unusable secret) and doctor (which
* reports one) apply the same rule — they disagreed while it was duplicated.
*/
const HEX_KEY_PATTERN = /^[0-9a-f]{64}$/i
const HEX_SECRET_KEYS = new Set<string>(['ENCRYPTION_KEY', 'API_ENCRYPTION_KEY'])
export function isUsableSecret(key: string, value: string): boolean {
if (isPlaceholder(value)) return false
return HEX_SECRET_KEYS.has(key) ? HEX_KEY_PATTERN.test(value) : value.length >= 32
}
/** Human-readable reason a secret is unusable, for doctor's finding message. */
export function secretRequirement(key: string): string {
return HEX_SECRET_KEYS.has(key)
? 'must be exactly 64 hex characters (32-byte AES key)'
: 'must be at least 32 characters'
}
export function isPlaceholder(value: string): boolean {
return PLACEHOLDER_VALUES.has(value) || value.startsWith('your_') || value.startsWith('your-')
}
/**
* Mirrors the app's `isTruthy` (apps/sim/lib/core/config/env.ts:633) exactly —
* `true` or `1` only. The app's separate `envBoolean` additionally accepts
* `yes`/`on`, but feature flags read through `isTruthy`, so accepting the wider
* set here made the wizard and doctor report a flag as on that the app treats
* as off.
*/
export function isTruthy(value: string | undefined): boolean {
if (value === undefined) return false
return value.toLowerCase() === 'true' || value === '1'
}