Files
sim/scripts/setup/modes/dev.ts
T
Theodore Li 3f70096841 improvement(self-host): gate email verification on a mail provider, add self-host settings, land setup on signup (#6216)
* fix(auth): skip email verification when no mail provider is configured

Signup pushed /verify unconditionally, stranding self-hosted deployments
with no mail provider on a screen no email could ever satisfy. Derive one
server-side effective value (verification enabled AND deliverable) and read
it from Better Auth enforcement, signup routing, and the verify page.

* feat(settings): add a self-host section with the managed Chat keys link

Self-hosters had no in-app pointer to the managed service that issues their
Chat keys. New Settings > System > Self-host section, gated on `requiresSelfHosted`
so it is absent on hosted Sim, containing only that link.

* improvement(setup): land the wizard handoff on signup

A freshly provisioned deployment has no accounts and / renders the marketing
landing page, so the bare origin left operators hunting for the CTA. Single-source
the URLs and point every open-Sim handoff at /signup across all three modes.

* improvement(settings): mark the self-host section with a sprout

Server was already doing double duty for MCP servers and Mothership, and the
icon set ships no botanical glyph, so the mark is a text emoji.

* improvement(settings): draw the sprout as an emcn line icon, move to Platform

The emoji rendered in the platform's own colors, so it was the one glyph in the
nav that ignored --text-icon. Replaced with a hand-drawn emcn Sprout (24 grid,
1.55 stroke, currentColor) matching the house style, renamed the tab to
Self hosting, and regrouped it under Platform — self-hosting is deployment-wide,
not per-workspace. Still self-hosted-only.

* improvement(settings): drop the section header from self hosting

One row does not need a section label, and removing it takes the divider with
it. The body is now the Chat keys row and its managed-keys link, nothing else.
2026-08-03 15:58:15 -04:00

186 lines
6.6 KiB
TypeScript

import { spawnSync } from 'node:child_process'
import path from 'node:path'
import { truncate } from '@sim/utils/string'
import { resolveDatabase } from '../db.ts'
import type { Detection } from '../detect.ts'
import { ROOT, readEnvFile, writeEnvValues } from '../env-files.ts'
import { SetupError } from '../errors.ts'
import { pgProbe } from '../probes.ts'
import * as p from '../prompter.ts'
import { ensureRedis, resolveRedis } from '../redis.ts'
import {
chatFlagValues,
collectSecrets,
mothershipOverride,
promptCopilotKey,
promptEmail,
promptLlmKeys,
promptSecurity,
promptSignInProviders,
promptStorage,
promptUnlocks,
} from '../steps.ts'
import { glyph, theme } from '../theme.ts'
import { APP_URL } from '../urls.ts'
/**
* A migrate failure on a never-migrated database means setup failed — abort.
* On a database that already has applied migrations (a live but drifted dev
* DB), the failure is surfaced and the user decides whether to continue.
*/
async function runMigrations(dsn: string): Promise<void> {
const spin = p.spinner()
spin.start('Running database migrations…')
const result = spawnSync('bun', ['run', 'db:migrate'], {
cwd: path.join(ROOT, 'packages/db'),
encoding: 'utf8',
})
if (result.status === 0) {
spin.stop('Migrations applied')
return
}
spin.stop(`${glyph.fail} migrations failed`)
const error = truncate(`${result.stdout}\n${result.stderr}`.trim(), 2000)
const probe = await pgProbe(dsn)
const applied = probe.ok ? (probe.migrations?.applied ?? 0) : 0
if (applied === 0) {
throw new SetupError(`db:migrate failed on a fresh database:\n${error}`, [
`run it by hand to see the full output: ${theme.command('cd packages/db && bun run db:migrate')}`,
'check DATABASE_URL points at the database you expect',
])
}
p.log.warn(
`db:migrate failed, but this database already has ${applied} applied migrations — it may have schema drift (e.g. built with db:push).`
)
p.log.info(theme.muted(truncate(error, 600)))
const proceed = await p.confirm({
message: 'Continue setup without migrating? (doctor will keep flagging the drift)',
initialValue: true,
})
if (!proceed) throw new Error(`aborted: db:migrate failed:\n${error}`)
}
async function promptRedis(detection: Detection, existing?: string): Promise<string | null> {
const wants = await p.confirm({
message:
'Configure Redis? (powers live Chat status and table events; storage falls back to Postgres)',
initialValue: true,
})
if (!wants) return null
return resolveRedis(detection, existing)
}
async function promptTrigger(): Promise<Record<string, string> | null> {
const wants = await p.confirm({
message: 'Enable Trigger.dev for background jobs? (off = jobs run via the DB queue)',
initialValue: false,
})
if (!wants) return null
const secretKey = await p.password({
message: 'TRIGGER_SECRET_KEY',
validate: (v) => (v ? undefined : 'required'),
})
const projectId = await p.text({
message: 'TRIGGER_PROJECT_ID',
validate: (v) => (v ? undefined : 'required'),
})
return {
TRIGGER_DEV_ENABLED: 'true',
TRIGGER_SECRET_KEY: secretKey,
TRIGGER_PROJECT_ID: projectId,
}
}
export async function runDevMode(
detection: Detection,
quick: boolean
): Promise<{ startNow: boolean; script: string }> {
const sim = readEnvFile('sim')
const dsn = await resolveDatabase(detection, sim.vars.get('DATABASE_URL'))
const secrets = collectSecrets(sim)
const shared = {
DATABASE_URL: dsn,
BETTER_AUTH_SECRET: secrets.BETTER_AUTH_SECRET,
INTERNAL_API_SECRET: secrets.INTERNAL_API_SECRET,
BETTER_AUTH_URL: APP_URL,
NEXT_PUBLIC_APP_URL: APP_URL,
}
writeEnvValues('sim', {
...shared,
ENCRYPTION_KEY: secrets.ENCRYPTION_KEY,
API_ENCRYPTION_KEY: secrets.API_ENCRYPTION_KEY,
})
writeEnvValues('realtime', shared)
writeEnvValues('db', { DATABASE_URL: dsn })
p.log.step('Wrote apps/sim/.env, apps/realtime/.env, packages/db/.env (shared subset mirrored)')
await runMigrations(dsn)
const simAfter = readEnvFile('sim')
const values: Record<string, string> = {}
// Before the key is minted: a half-set override mints against one environment
// and validates against the other, and warning afterwards is too late — the
// bad key is already stored, and the next run offers to keep it.
Object.assign(values, mothershipOverride())
const copilotKey = await promptCopilotKey(simAfter.vars.get('COPILOT_API_KEY'))
if (copilotKey) values.COPILOT_API_KEY = copilotKey
Object.assign(values, chatFlagValues(copilotKey))
Object.assign(values, await promptLlmKeys(detection, !quick))
// Redis is set up in every mode, quick included. Storage falls back to
// PostgreSQL without it, but the pub/sub channels (live Chat task-status,
// table events) have no fallback — skipping it silently produces an install
// where live updates never arrive. Quick configures it with no questions at
// all; custom keeps the opt-out and the where-should-it-live ladder.
const redisUrl = quick
? await ensureRedis(detection, simAfter.vars.get('REDIS_URL'))
: await promptRedis(detection, simAfter.vars.get('REDIS_URL'))
if (redisUrl) {
values.REDIS_URL = redisUrl
writeEnvValues('realtime', { REDIS_URL: redisUrl })
}
if (!quick) {
const trigger = await promptTrigger()
if (trigger) Object.assign(values, trigger)
const storage = await promptStorage(simAfter.vars, false)
if (storage) Object.assign(values, storage)
Object.assign(values, await promptSignInProviders(simAfter.vars, APP_URL))
Object.assign(values, await promptEmail(simAfter.vars))
const security = await promptSecurity(simAfter.vars)
Object.assign(values, security.sim)
if (Object.keys(security.mirrorToRealtime).length > 0) {
writeEnvValues('realtime', security.mirrorToRealtime)
}
Object.assign(values, await promptUnlocks(simAfter.vars))
}
if (Object.keys(values).length > 0) writeEnvValues('sim', values)
let script = 'dev:full'
if (detection.specs.hostMemGb < 16) {
script = await p.select({
message: `Low RAM detected (${detection.specs.hostMemGb}GB) — which dev server?`,
options: [
{
value: 'dev:full:capped',
label: 'Capped heap (recommended)',
hint: 'caps Node at 4GB — every integration still available',
},
{
value: 'dev:full',
label: 'Uncapped',
hint: 'lets the dev server take what it needs (~4GB typical)',
},
],
initialValue: 'dev:full:capped',
})
}
return {
startNow: await p.confirm({
message: `Start Sim now? (bun run ${script})`,
initialValue: true,
}),
script,
}
}