6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
210 lines
7.2 KiB
TypeScript
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'
|
|
}
|