feat(setup): setup wizard with browser-based Chat key handoff (#5911)

* feat(setup): setup wizard with browser-based Chat key handoff

Adds `bun run setup` and `bun run doctor` for local installs, and replaces
the wizard's paste-your-Chat-key step with a browser handoff that never puts
the key in a URL.

* improvement(setup): drop the paste-a-key fallback, simplify consent copy

The browser handoff is now the only path — the wizard waits on a spinner
instead of racing a paste prompt. Consent card leads with "Connect your
terminal" and moves the match-the-code disclaimer into the description.

* fix(setup): pin kube context, keep secrets out of argv, validate reused keys

Review findings from #5911:
- helm/kubectl now run against the validated context instead of the ambient one
- helm values are piped on stdin rather than passed as --set arguments
- ENCRYPTION_KEY/API_ENCRYPTION_KEY are checked for the 64-hex format the app
  requires, not just length, so an unusable key is replaced rather than kept
- the managed Redis container's published port is read back instead of assumed

* refactor(copilot): one module for Chat API key operations

list/generate/delete each repeated the same /api/validate-key envelope in
their route. They now share callValidateKey in lib/copilot/server/api-keys.ts,
which also keeps the display masking server-side so the full key can only ever
leave at creation.

* improvement(setup): reuse shared helpers, parallelize probes, drop dead code

- PKCE verifier/state/pairing code now use generateSecureToken, generateRandomHex
  and generateShortId instead of hand-rolled randomBytes; the pairing loop's
  modulo was unbiased only because 256 % 32 == 0
- new sha256Base64Url in @sim/security/hash so both sides of the PKCE exchange
  derive the challenge from one implementation
- isUsableSecret moved beside SECRET_KEYS so setup and doctor apply the same
  rule; doctor previously passed a key setup would replace
- isTruthy narrowed to true/1, matching the app it claims to mirror — it accepted
  yes/on, so a flag could read on in doctor and off in the app
- checkLive runs its five probes concurrently (~17s serial worst case)
- detection overlaps the banner animation instead of queueing behind it
- glyph.fail/glyph.warn at 13 sites that bypassed the constant; removed unused
  prompter exports, a dead ENV_PATHS re-export, and an unused export keyword

* fix(setup): make doctor understand the compose env layout

Compose writes a single root .env (what docker-compose reads via env_file) but
the checks required the three per-app files, so a successful compose install was
followed by doctor printing three failures and exiting 1 — and the whole
coherence catalog was skipped because it keyed off apps/sim/.env existing.

Layout is now derived from what's on disk and every check consults it: file and
schema checks iterate the layout's targets, consistency reports skip when
there's only one file to mirror, and coherence/live read the layout's primary
file. The wizard's existing-config detection counts root for the same reason —
a compose install used to read as unconfigured and re-run from scratch.

* feat(cli-auth): device-authorization poll flow, drop the loopback listener

The CLI no longer binds a local port. It generates a request id + poll secret,
opens /cli/auth, and polls /api/cli/auth/poll over TLS while the user approves
in the browser — so the flow works over SSH and inside containers, where the
browser and terminal don't share a machine.

- approve stores the approval keyed by request id (session-authed, userId from
  the session only); poll verifies the secret before an atomic claim, so an
  observer of the semi-public request id can neither mint nor cancel it
- pairing code stays as the anti-phishing compare; no key ever crosses the
  browser; done page just confirms
- removes the loopback listener, /token exchange, buildCliHandoffUrl, and
  validateCliCallbackUrl (+ its tests) — nothing hands a key to a URL anymore

* fix(setup): reuse an existing managed Postgres container instead of colliding

A running sim-postgres fell through to `docker run --name sim-postgres` and died
on the name conflict; a stopped one failed with "no DATABASE_URL to reach it"
because the generated password only lived in the env files a fresh clone lacks.

Both facts are recoverable from Docker: the ladder now reads the published port
and password back via `docker inspect` and reuses the container (starting it if
stopped). A container that won't answer prompts before recreating, and never
drops the data volume silently.

* improvement(setup): audience-first run-mode hints

Each run mode now names who it's for — compose for self-hosting/evaluating, dev
for contributing to Sim, k8s for rehearsing a production deploy — with the live
detection state (Docker/kube/VM) appended.

* fix(cli-auth): retry a failed mint, port container/port fixes to Redis + k8s

Review findings from #5911:
- poll now reserves the mint with an atomic NX lock instead of deleting the
  approval up front, so a failed mint (e.g. mothership blip) is retried by the
  next poll instead of forcing a fresh browser approval; the lock still prevents
  a double-mint and its TTL frees the slot if the caller dies
- setup reuses/recreates an unhealthy managed sim-redis instead of colliding on
  the name (Redis has no data volume, so it removes and recreates without a prompt)
- k8s failure-path hints carry --context, matching the success-path hints, so a
  changed ambient context can't send diagnostics to the wrong cluster
- compose port-free waits for a killed port to actually release before
  re-checking; SIGKILL is async, so the immediate re-check re-saw the port

* fix(setup): harden mint cleanup, Windows browser, container detection, helm cwd

Review findings from #5911:
- a post-mint completeApproval failure no longer routes into releaseMint — the
  mint lock now outlives the approval (shared TTL), so a cleanup blip can't leave
  a re-mintable window and orphan a key; cleanup is best-effort after the key ships
- compose doctor --fix writes the feature-flag twin to the layout's primary env
  (root .env on a compose install), not always apps/sim/.env
- Windows opens the browser via `cmd /c start "" <url>` — `start` is a shell
  builtin, so spawning it directly ENOENT'd and the handoff never opened
- managed-container detection filters loosely and pins the exact name in code;
  Docker's `name=^x$` anchor matches the internal `/x` form and often missed,
  skipping the reuse branch
- the shared helm/kind run helper pins cwd to the repo root, matching helm test,
  so `helm upgrade --install ./helm/sim` works from any working directory

* feat(chat-keys): standalone manage page, drop from settings nav, refresh README

- Add /account/settings/chat-keys — a linkable page to view, create, and revoke Chat API keys
- Remove Chat keys from the settings sidebar (account + unified nav) and its render branches
- README: replace Docker Compose + Manual Setup with the bun run setup wizard; drop the manual COPILOT_API_KEY step, point to the manage page

* fix(setup): per-key reason in the secret-replacement warning

Cursor: the warn hardcoded '64-character hex key', but only ENCRYPTION_KEY/API_ENCRYPTION_KEY require that — BETTER_AUTH_SECRET/INTERNAL_API_SECRET only need length >= 32. Use the existing secretRequirement(key) helper so each replaced key reports its actual requirement.

* fix(setup): compose doctor schema, cross-platform binary detection, quoted context hints

- Doctor: for the compose (root) env layout, require only the secrets compose has no interpolation default for (BETTER_AUTH_SECRET/ENCRYPTION_KEY/INTERNAL_API_SECRET). DATABASE_URL/BETTER_AUTH_URL/NEXT_PUBLIC_APP_URL come from docker-compose ${VAR:-default}, so a healthy compose install no longer fails doctor.
- Binary detection: use Bun.which instead of which (which is absent on Windows), so kubectl/helm/kind/docker resolve cross-platform.
- k8s diagnostic hints: POSIX-quote the kube-context so a context with whitespace/metacharacters can't break or inject into a copied command.

* fix(setup): quote kube-context in the helm uninstall tear-down hint too

The tear-down hint used --kube-context ${context} raw while the sibling kubectl hints already used shq(); a context with whitespace/metacharacters could break or inject into the copied command. All copyable k8s hints now go through shq(context).

* feat(setup): sim lifecycle CLI — start/stop/status/logs/down/reset

Turn the setup entry into a 'sim' command umbrella so there's one place to run everything, not scattered docker/bun commands. Adds a global bin (bun link) + a bun run sim fallback.

- Detects how you're running (compose file / managed dev containers / helm release) from disk + docker/helm state — no persisted mode. Ambiguous installs prompt.
- start/stop/restart/logs work per mode; down removes containers (volumes kept); reset archives .env + wipes managed data; both destructive verbs confirm first.
- status shows detected mode, container states, and app/realtime health.
- Wizard outro + README now point at the sim commands and the one-time bun link.

* feat(setup): 'bun run sim' is the primary entry; bare invocation prints help

- Lead usage/wizard-outro/README with 'bun run sim <cmd>' (works with zero PATH setup); global bare 'sim' via bun link is an optional upgrade, with the ~/.bun/bin PATH caveat spelled out (Homebrew's bun omits it).
- Bare 'sim' now prints help instead of launching the wizard; the wizard is 'sim setup'. The 'setup' npm script passes the keyword so 'bun run setup' is unchanged.

* fix(setup): quote the auth URL for cmd /c start on Windows

Cursor (High): cmd re-parses the command line and treats & in the query string as a command separator, so cmd /c start opened a URL truncated at the first &, breaking the key flow on win32 (the handoff URL always has request/challenge/pairing). Quote the URL and pass args verbatim so & stays literal.

* fix(setup): verify kube-context is really local; lengthen CLI handoff wait

- k8s: a context named like a local cluster (kind-*, docker-desktop) can actually point at a remote API server. Verify the server host is loopback/docker-internal before defaulting the 'use this context?' confirm to yes; otherwise warn and default to no, so generated secrets can't ship to a remote cluster on a blind Enter.
- cli-auth: bump the device-flow wait from 3 to 15 minutes so first-time users have time to sign up, wait for the email OTP, and approve before the terminal stops polling. The server-side approval record keeps its own short TTL, so a longer client wait only costs cheap rate-limited polls.

* fix(setup): only manage k8s lifecycle on a verified-local context

Greptile: sim down/reset used the ambient kube-context, so switching context after setup could uninstall a same-named sim-dev release from the wrong cluster. Gate k8sInstall on the same locality check the wizard uses (API server is loopback/docker-internal) via a shared isLocalKubeContext helper — the wizard only ever deploys locally, so a remote current-context is never treated as a Sim install.

* fix(setup): doctor skips placeholder secrets when seeding; reset names its target

- checks: the missing-file autofix copied shared keys from apps/sim/.env whenever truthy, including .env.example placeholders — doctor --fix could seed unusable secrets into realtime/db env files. Skip placeholders, matching autofixForMissing.
- lifecycle: reset now names the exact install (k8s context / compose file / dev containers) in its confirm, so a destructive reset can't silently hit the wrong same-named install after a context switch (down already names the context).

* fix(cli-auth): size the poll rate limit to the poll cadence; honor Retry-After

The poll route used the default public-IP bucket (10 burst, 5/min) but the CLI polls every 2s (30/min), so it 429'd within ~20s — worse behind a slow dev cold-compile. Give the endpoint a bucket matched to its cadence (60 burst, 60/min); it's not a brute-force surface (unknown request id returns pending, minting needs the 256-bit verifier). Also make the CLI honor Retry-After and back off on 429 so a shared-NAT per-IP limit degrades gracefully instead of hammering.

* fix(setup): check ports before starting the dev server, not just compose

Local dev auto-start spawned bun run dev:full with no port check, so it silently started a server that couldn't bind when 3000/3002 were already taken (e.g. another worktree's dev server). Extract compose's port-conflict resolver into a shared ensurePortsFree(ports) and run it before the dev start too — kill/recheck/leave, same as compose. Leaving the ports skips the auto-start with guidance instead of failing; compose still treats it as fatal.

* fix(setup): verify the kube cluster is reachable, not just local

A kubeconfig context can outlive its cluster — a kind cluster gets deleted or its Docker container stops (Docker/machine restart), but the context entry remains, pointing at a dead API-server port. The wizard checked the context looked local and handed it to helm, which failed with 'cluster unreachable'.

Add a clusterReachable() liveness probe: only offer the current context when it actually answers; if a local context is dead, fall through to the kind path. There, if kind still knows 'sim' but it's stopped, start its node containers and wait for the API; if it's gone, create fresh. Either way the user gets a working cluster instead of a cryptic helm failure.

* fix(helm): point appVersion at published image tags (v-prefixed, current)

The chart's appVersion was "0.6.73", but CI publishes GHCR tags with a v prefix (its release-commit regex captures v0.7.45). Since sim.image defaults every image tag to Chart.AppVersion, a default helm install requested ghcr.io/simstudioai/{simstudio,realtime,migrations}:0.6.73 — a tag that has never existed — so app and realtime sat in ImagePullBackOff and helm --wait failed with 'progress deadline exceeded'. Any self-hoster installing with default values hit this, not just the setup wizard.

Set appVersion to v0.7.45 (latest release on main; all three images verified present on ghcr) and bump the chart version to 1.1.1. Verified with helm lint, helm template (all images render as v0.7.45), and a live helm upgrade on a kind cluster where the new pods pull successfully while the old 0.6.73 pods remain in ImagePullBackOff.

* Revert "fix(helm): point appVersion at published image tags (v-prefixed, current)"

This reverts commit 28b6047d1d.

* chore(api-validation): rebaseline route count to 977 after staging merge

Staging moved the baseline to 975; this branch's two CLI-auth routes (approve, poll) make 977. The clean merge absorbed the earlier +2 adjustment.

* fix(settings): don't highlight a sibling nav item on nested settings pages

/account/settings/chat-keys is a real page but deliberately not a nav item, so the sidebar's parseSettingsPathSection fell through to defaultSection ('general') and highlighted General — the page read as though it lived inside General.

Resolve the sidebar's active item with a null default so an unmatched nested route highlights nothing, and widen SettingsSidebar's activeSection to string | null. The section feeding the title/description provider keeps its default (pages override title/description anyway), and /account/settings/billing/credit-usage still correctly highlights Billing.

* fix(setup,auth): manage explicitly-confirmed k8s contexts, fail loudly on reset, clear stale post-auth redirect

- lifecycle: detection is now factual — a sim-dev release either exists on the current context or it doesn't. Gating on locality stranded a release the user explicitly confirmed during setup (status/start/stop/down/reset all claimed no k8s install). Locality is recorded instead and surfaced through describeInstall, which every destructive confirm renders, so acting on a non-local cluster is named and defaulted to no rather than silently blocked or silently allowed.
- lifecycle: reset no longer discards helm uninstall's exit status. Env files are archived by that point, so claiming 'Reset complete' while the release still runs is the worst outcome — it now throws with retry/inspect commands.
- auth: signup clears POST_AUTH_REDIRECT_STORAGE_KEY when it has no callbackUrl, and the verification-disabled path consumes it, so a stale CLI/invite destination can't leak into a later flow in the same tab.
This commit is contained in:
Theodore Li
2026-07-25 04:24:36 -04:00
committed by GitHub
parent ca77908e20
commit 19c3b6f47d
65 changed files with 5009 additions and 311 deletions
+71
View File
@@ -0,0 +1,71 @@
import { sleep } from '@sim/utils/helpers'
import chalk from 'chalk'
import { restoreTerminal } from './terminal.ts'
import { isRich, theme } from './theme.ts'
const WORDMARK = [' ▀ ', '▄▀▀▀ █ █▀█▀█', '▀▀▀▄ █ █ █ █', '▄▄▄▀ █ █ █ █'] as const
const TAGLINE = 'the AI workspace'
const WIDTH = Math.max(...WORDMARK.map((row) => row.length))
const EDGE = chalk.hex('#ffffff')
const SHELL = chalk.hex('#3d3d3d')
const FRAME_DELAY_MS = 35
function renderRow(row: string, edge: number): string {
let out = ''
for (let col = 0; col < row.length; col++) {
const ch = row[col]
if (ch === ' ') {
out += ch
} else if (col < edge - 1) {
out += theme.accent(ch)
} else if (col <= edge) {
out += EDGE(ch)
} else {
out += SHELL(ch)
}
}
return out
}
function paintFrame(edge: number, redraw: boolean): void {
if (redraw) process.stdout.write(`\x1b[${WORDMARK.length + 1}F`)
for (const row of WORDMARK) {
process.stdout.write(`\x1b[K${renderRow(row, edge)}\n`)
}
process.stdout.write(`\x1b[K${theme.muted(TAGLINE)}\n`)
}
/** Animated half-block sim wordmark; degrades to a plain line off-TTY/CI. */
export async function showBanner(): Promise<void> {
const animated =
process.stdout.isTTY &&
isRich() &&
!process.env.CI &&
!process.env.VITEST &&
(process.stdout.columns ?? 80) >= WIDTH + 2
console.log()
if (!animated) {
console.log(`◆ Sim — ${TAGLINE}`)
console.log()
return
}
const onSigint = () => {
restoreTerminal()
process.exit(130)
}
process.once('SIGINT', onSigint)
process.stdout.write('\x1b[?25l')
try {
paintFrame(-2, false)
for (let edge = 0; edge <= WIDTH + 2; edge++) {
await sleep(FRAME_DELAY_MS)
paintFrame(edge, true)
}
} finally {
process.stdout.write('\x1b[?25h')
process.removeListener('SIGINT', onSigint)
}
console.log()
}
+626
View File
@@ -0,0 +1,626 @@
import { portOpen } from './detect.ts'
import {
type EnvFile,
type EnvTarget,
generateSecret,
isPlaceholder,
isTruthy,
isUsableSecret,
readEnvFile,
SECRET_KEYS,
SHARED_KEYS,
secretRequirement,
writeEnvValues,
} from './env-files.ts'
import { httpHealth, pgProbe, redisPing } from './probes.ts'
import { FLAG_TWINS, hasMailProvider, LOGIN_PROVIDERS } from './twins.ts'
export type CheckGroup = 'files' | 'schema' | 'consistency' | 'coherence' | 'live'
export type CheckStatus = 'pass' | 'warn' | 'fail' | 'skip'
export interface Finding {
group: CheckGroup
status: CheckStatus
message: string
fix?: string
autofix?: () => void
}
/**
* Which env-file topology this install uses. Compose mode writes a single root
* `.env` (that's what `docker-compose.*.yml` reads via `env_file`), dev mode
* writes the three per-app files. Checking for the wrong one reports a healthy
* install as broken, so the layout is derived and every check consults it.
*/
export type EnvLayout = 'split' | 'root' | 'none'
export interface CheckContext {
env: Record<EnvTarget, EnvFile>
layout: EnvLayout
/** The file holding app configuration for this layout — what coherence reads. */
primary: EnvFile
live: boolean
}
/** Split wins when both exist: the per-app files are what a dev run actually loads. */
function detectLayout(env: Record<EnvTarget, EnvFile>): EnvLayout {
if (env.sim.exists || env.realtime.exists || env.db.exists) return 'split'
return env.root.exists ? 'root' : 'none'
}
/** Targets whose files this layout expects to exist. */
function layoutTargets(layout: EnvLayout): EnvTarget[] {
if (layout === 'split') return ['sim', 'realtime', 'db']
return layout === 'root' ? ['root'] : []
}
export function loadCheckContext(live: boolean): CheckContext {
const env = {
sim: readEnvFile('sim'),
realtime: readEnvFile('realtime'),
db: readEnvFile('db'),
root: readEnvFile('root'),
}
const layout = detectLayout(env)
return { env, layout, primary: layout === 'root' ? env.root : env.sim, live }
}
const REQUIRED_KEYS: Partial<Record<EnvTarget, string[]>> = {
sim: [
'DATABASE_URL',
'BETTER_AUTH_SECRET',
'BETTER_AUTH_URL',
'NEXT_PUBLIC_APP_URL',
'ENCRYPTION_KEY',
'INTERNAL_API_SECRET',
],
realtime: [
'DATABASE_URL',
'BETTER_AUTH_URL',
'BETTER_AUTH_SECRET',
'INTERNAL_API_SECRET',
'NEXT_PUBLIC_APP_URL',
],
db: ['DATABASE_URL'],
// Compose's single root .env only carries the secrets that have no safe
// interpolation default in docker-compose.*.yml. DATABASE_URL, BETTER_AUTH_URL,
// and NEXT_PUBLIC_APP_URL are supplied by `${VAR:-default}` there, so requiring
// them here would fail a healthy compose install that never wrote them.
root: ['BETTER_AUTH_SECRET', 'ENCRYPTION_KEY', 'INTERNAL_API_SECRET'],
}
const MIN_32_KEYS = new Set<string>(SECRET_KEYS)
const URL_KEYS = ['DATABASE_URL', 'BETTER_AUTH_URL', 'NEXT_PUBLIC_APP_URL']
function rel(file: EnvFile): string {
return `${file.target === 'root' ? '' : file.target === 'db' ? 'packages/db/' : `apps/${file.target}/`}.env`
}
function checkFiles(ctx: CheckContext): Finding[] {
if (ctx.layout === 'none') {
return [
{
group: 'files',
status: 'fail',
message: 'no env files found',
fix: 'run: bun run setup',
},
]
}
const findings: Finding[] = []
for (const target of layoutTargets(ctx.layout)) {
const file = ctx.env[target]
if (file.exists) {
findings.push({ group: 'files', status: 'pass', message: `${rel(file)} exists` })
continue
}
const canSeed = target !== 'sim' && ctx.env.sim.exists
findings.push({
group: 'files',
status: 'fail',
message: `${rel(file)} is missing`,
fix: canSeed
? `run doctor --fix to seed it from apps/${target === 'db' ? '../packages/db' : target}/.env.example + apps/sim/.env`
: 'run: bun run setup',
autofix: canSeed
? () => {
const keys = target === 'db' ? ['DATABASE_URL'] : [...SHARED_KEYS]
const values: Record<string, string> = {}
for (const key of keys) {
const value = ctx.env.sim.vars.get(key)
// Skip placeholders so seeding never copies an .env.example stub
// into the new file (matches autofixForMissing).
if (value && !isPlaceholder(value)) values[key] = value
}
writeEnvValues(target, values)
}
: undefined,
})
}
return findings
}
function autofixForMissing(
ctx: CheckContext,
target: EnvTarget,
key: string
): (() => void) | undefined {
const simValue = ctx.env.sim.vars.get(key)
if (
target !== 'sim' &&
(SHARED_KEYS as readonly string[]).includes(key) &&
simValue &&
!isPlaceholder(simValue)
) {
return () => writeEnvValues(target, { [key]: simValue })
}
if (MIN_32_KEYS.has(key)) {
return () => writeEnvValues(target, { [key]: generateSecret() })
}
return undefined
}
function checkSchema(ctx: CheckContext): Finding[] {
const findings: Finding[] = []
const production = process.env.NODE_ENV === 'production'
for (const target of layoutTargets(ctx.layout)) {
const file = ctx.env[target]
if (!file.exists) continue
const missing: string[] = []
for (const key of REQUIRED_KEYS[target] ?? []) {
const value = file.vars.get(key)
if (!value) {
missing.push(key)
findings.push({
group: 'schema',
status: 'fail',
message: `${rel(file)}: ${key} is missing or empty`,
fix: MIN_32_KEYS.has(key) ? 'doctor --fix generates it' : `set ${key} in ${rel(file)}`,
autofix: autofixForMissing(ctx, target, key),
})
continue
}
if (isPlaceholder(value)) {
findings.push({
group: 'schema',
status: production ? 'fail' : 'warn',
message: `${rel(file)}: ${key} still has the .env.example placeholder`,
fix: MIN_32_KEYS.has(key)
? 'doctor --fix generates a real value'
: `replace the placeholder in ${rel(file)}`,
autofix: MIN_32_KEYS.has(key)
? () => writeEnvValues(target, { [key]: generateSecret() })
: undefined,
})
continue
}
if (MIN_32_KEYS.has(key) && !isUsableSecret(key, value)) {
findings.push({
group: 'schema',
status: 'fail',
message: `${rel(file)}: ${key} ${secretRequirement(key)}`,
fix: 'generate a new one with `openssl rand -hex 32` (rotating it invalidates existing sessions/encrypted data)',
})
continue
}
if (URL_KEYS.includes(key)) {
try {
new URL(value)
} catch {
findings.push({
group: 'schema',
status: 'fail',
message: `${rel(file)}: ${key} is not a valid URL (${value})`,
fix: `correct ${key} in ${rel(file)}`,
})
}
}
}
if (missing.length === 0 && findings.every((f) => !f.message.startsWith(rel(file)))) {
findings.push({
group: 'schema',
status: 'pass',
message: `${rel(file)}: required keys valid`,
})
}
}
return findings
}
function checkConsistency(ctx: CheckContext): Finding[] {
// Consistency is about the same key agreeing across files; a single root
// file has nothing to disagree with.
if (ctx.layout !== 'split') {
return ctx.layout === 'root'
? [{ group: 'consistency', status: 'skip', message: 'single .env — nothing to mirror' }]
: []
}
const findings: Finding[] = []
const { sim, realtime, db } = ctx.env
if (sim.exists && realtime.exists) {
for (const key of SHARED_KEYS) {
const simValue = sim.vars.get(key)
const realtimeValue = realtime.vars.get(key)
if (!simValue || !realtimeValue) continue
if (simValue !== realtimeValue) {
findings.push({
group: 'consistency',
status: 'fail',
message: `${key} differs between apps/sim/.env and apps/realtime/.env`,
fix: 'doctor --fix mirrors the apps/sim/.env value',
autofix: () => writeEnvValues('realtime', { [key]: simValue }),
})
}
}
}
if (sim.exists && db.exists) {
const simDsn = sim.vars.get('DATABASE_URL')
const dbDsn = db.vars.get('DATABASE_URL')
if (simDsn && dbDsn && simDsn !== dbDsn) {
findings.push({
group: 'consistency',
status: 'fail',
message:
'DATABASE_URL differs between apps/sim/.env and packages/db/.env — migrations would hit a different database',
fix: 'doctor --fix mirrors the apps/sim/.env value',
autofix: () => writeEnvValues('db', { DATABASE_URL: simDsn }),
})
}
}
if (findings.length === 0) {
findings.push({
group: 'consistency',
status: 'pass',
message: 'shared env subset is in sync across files',
})
}
return findings
}
function checkCoherence(ctx: CheckContext): Finding[] {
const findings: Finding[] = []
const sim = ctx.primary
if (!sim.exists) return findings
if (isTruthy(sim.vars.get('TRIGGER_DEV_ENABLED'))) {
const missing = ['TRIGGER_SECRET_KEY', 'TRIGGER_PROJECT_ID'].filter((k) => !sim.vars.get(k))
if (missing.length > 0) {
findings.push({
group: 'coherence',
status: 'fail',
message: `TRIGGER_DEV_ENABLED is on but ${missing.join(' and ')} ${missing.length > 1 ? 'are' : 'is'} not set`,
fix: 'set the missing Trigger.dev vars or remove TRIGGER_DEV_ENABLED (jobs fall back to the DB queue)',
})
}
}
const redisUrl = sim.vars.get('REDIS_URL')
if (redisUrl?.startsWith('rediss://')) {
const host = new URL(redisUrl).hostname
if (/^\d+\.\d+\.\d+\.\d+$/.test(host) && !sim.vars.get('REDIS_TLS_SERVERNAME')) {
findings.push({
group: 'coherence',
status: 'fail',
message:
'rediss:// with a bare IP host requires REDIS_TLS_SERVERNAME — the redis client throws without it',
fix: 'set REDIS_TLS_SERVERNAME to the certificate hostname',
})
}
}
const appUrl = sim.vars.get('NEXT_PUBLIC_APP_URL')
if (appUrl) {
try {
const host = new URL(appUrl).hostname
if (host === 'sim.ai' || host.endsWith('.sim.ai')) {
findings.push({
group: 'coherence',
status: 'warn',
message: `NEXT_PUBLIC_APP_URL points at ${host} — this flips isHosted=true and disables self-host overrides`,
fix: 'use your own domain or http://localhost:3000',
})
}
} catch {
// schema group already reports the invalid URL
}
}
const hasS3 = Boolean(sim.vars.get('AWS_REGION') && sim.vars.get('S3_BUCKET_NAME'))
const s3Partial = Boolean(sim.vars.get('AWS_REGION')) !== Boolean(sim.vars.get('S3_BUCKET_NAME'))
const hasAzure = Boolean(
sim.vars.get('AZURE_CONNECTION_STRING') || sim.vars.get('AZURE_ACCOUNT_NAME')
)
const azurePartial =
Boolean(sim.vars.get('AZURE_ACCOUNT_NAME')) &&
!sim.vars.get('AZURE_ACCOUNT_KEY') &&
!sim.vars.get('AZURE_CONNECTION_STRING')
const hasGcs = Boolean(sim.vars.get('GCS_BUCKET_NAME'))
if (s3Partial) {
findings.push({
group: 'coherence',
status: 'fail',
message:
'S3 is half-configured (need BOTH AWS_REGION and S3_BUCKET_NAME) — storage silently falls back to local disk',
fix: 'set the missing var, or remove both to use local disk intentionally',
})
}
if (azurePartial) {
findings.push({
group: 'coherence',
status: 'fail',
message:
'Azure storage is half-configured — AZURE_ACCOUNT_NAME needs AZURE_ACCOUNT_KEY (or use AZURE_CONNECTION_STRING)',
fix: 'set the missing credential, or remove the Azure vars',
})
}
if (hasAzure && hasS3) {
findings.push({
group: 'coherence',
status: 'warn',
message:
'both Azure Blob and S3 are configured — Azure takes precedence, the S3 vars are ignored',
fix: 'remove the backend you are not using',
})
}
if (hasGcs && (hasAzure || hasS3)) {
findings.push({
group: 'coherence',
status: 'warn',
message:
'GCS is configured alongside Azure/S3 — GCS is only used when neither of those is set',
fix: 'remove the backend you are not using',
})
}
for (const { server, client } of FLAG_TWINS) {
const serverValue = sim.vars.get(server)
const clientValue = sim.vars.get(client)
const bothUnset = serverValue === undefined && clientValue === undefined
if (bothUnset || isTruthy(serverValue) === isTruthy(clientValue)) continue
const setSide = serverValue !== undefined ? server : client
const missingSide = serverValue !== undefined ? client : server
const value = serverValue ?? clientValue ?? ''
findings.push({
group: 'coherence',
status: 'fail',
message: `${setSide} is set but its twin ${missingSide} disagrees — server and browser will render different features`,
fix: `doctor --fix sets ${missingSide}=${value}`,
// Write to the layout's primary env (root on a compose install), not always sim.
autofix: () => writeEnvValues(sim.target, { [missingSide]: value }),
})
}
const disableAuth = sim.vars.get('DISABLE_AUTH')
if (
isTruthy(disableAuth) &&
ctx.env.realtime.exists &&
!isTruthy(ctx.env.realtime.vars.get('DISABLE_AUTH'))
) {
findings.push({
group: 'coherence',
status: 'fail',
message:
'DISABLE_AUTH is on in apps/sim/.env but not apps/realtime/.env — the socket server still enforces auth, so the canvas breaks silently',
fix: 'doctor --fix mirrors it into apps/realtime/.env',
autofix: () => writeEnvValues('realtime', { DISABLE_AUTH: disableAuth as string }),
})
}
if (isTruthy(sim.vars.get('EMAIL_VERIFICATION_ENABLED')) && !hasMailProvider(sim.vars)) {
findings.push({
group: 'coherence',
status: 'fail',
message:
'EMAIL_VERIFICATION_ENABLED is on but no mail provider is configured — verification emails only go to the console, locking out new users',
fix: 'configure RESEND_API_KEY / SMTP_* / AWS_SES_REGION, or turn verification off',
})
}
const featureRules: Array<{ flag: string; needs: string[]; label: string }> = [
{ flag: 'BILLING_ENABLED', needs: ['STRIPE_SECRET_KEY'], label: 'billing' },
{ flag: 'E2B_ENABLED', needs: ['E2B_API_KEY'], label: 'E2B code execution' },
{ flag: 'SSO_ENABLED', needs: ['SSO_ISSUER'], label: 'SSO' },
]
for (const rule of featureRules) {
if (!isTruthy(sim.vars.get(rule.flag))) continue
const missing = rule.needs.filter((key) => !sim.vars.get(key))
if (missing.length > 0) {
findings.push({
group: 'coherence',
status: 'fail',
message: `${rule.flag} is on but ${missing.join(', ')} is not set — ${rule.label} will fail at runtime`,
fix: `set ${missing.join(', ')} or remove ${rule.flag}`,
})
}
}
if (
isTruthy(sim.vars.get('PII_GRANULAR_REDACTION')) &&
!isTruthy(sim.vars.get('PII_REDACTION'))
) {
findings.push({
group: 'coherence',
status: 'warn',
message:
'PII_GRANULAR_REDACTION is on but PII_REDACTION is off — the granular flag is inert without it',
fix: 'set PII_REDACTION=true or remove PII_GRANULAR_REDACTION',
})
}
if (
Boolean(sim.vars.get('TURNSTILE_SECRET_KEY')) !==
Boolean(sim.vars.get('NEXT_PUBLIC_TURNSTILE_SITE_KEY'))
) {
findings.push({
group: 'coherence',
status: 'fail',
message:
'Turnstile is half-configured — TURNSTILE_SECRET_KEY and NEXT_PUBLIC_TURNSTILE_SITE_KEY must both be set',
fix: 'set the missing Turnstile var or remove both',
})
}
for (const provider of LOGIN_PROVIDERS) {
if (Boolean(sim.vars.get(provider.idKey)) !== Boolean(sim.vars.get(provider.secretKey))) {
findings.push({
group: 'coherence',
status: 'fail',
message: `${provider.label} login is half-configured — ${provider.idKey} and ${provider.secretKey} must both be set`,
fix: 'set the missing credential or remove both',
})
}
}
const appUrlValue = sim.vars.get('NEXT_PUBLIC_APP_URL')
if (
appUrlValue &&
!appUrlValue.includes('localhost') &&
!appUrlValue.includes('127.0.0.1') &&
!sim.vars.get('NEXT_PUBLIC_SOCKET_URL')
) {
findings.push({
group: 'coherence',
status: 'warn',
message:
'NEXT_PUBLIC_APP_URL is not localhost but NEXT_PUBLIC_SOCKET_URL is unset — the browser cannot find the realtime server',
fix: 'set NEXT_PUBLIC_SOCKET_URL to the public URL of the realtime service (:3002)',
})
}
if (findings.length === 0) {
findings.push({ group: 'coherence', status: 'pass', message: 'no conflicting settings' })
}
return findings
}
async function checkDatabase(sim: EnvFile): Promise<Finding[]> {
const findings: Finding[] = []
const dsn = sim.vars.get('DATABASE_URL')
const dsnPassword = (() => {
try {
return dsn ? new URL(dsn).password : null
} catch {
return null
}
})()
if (dsn && dsnPassword !== null && !isPlaceholder(dsnPassword)) {
const probe = await pgProbe(dsn)
if (!probe.ok) {
findings.push({
group: 'live',
status: 'fail',
message: `database unreachable: ${probe.error}`,
fix: 'start Postgres (bun run setup can manage a pgvector container) or fix DATABASE_URL',
})
} else {
findings.push({ group: 'live', status: 'pass', message: 'database reachable' })
if (!probe.pgvectorAvailable) {
findings.push({
group: 'live',
status: 'fail',
message: 'pgvector extension is not available on this Postgres',
fix: 'use the pgvector/pgvector:pg17 image or install the extension',
})
}
const { applied, journal } = probe.migrations ?? { applied: null, journal: 0 }
if (applied === null) {
findings.push({
group: 'live',
status: 'fail',
message: 'migrations have never run on this database',
fix: 'cd packages/db && bun run db:migrate',
})
} else if (applied < journal) {
findings.push({
group: 'live',
status: 'warn',
message: `database has ${applied}/${journal} migrations applied`,
fix: 'cd packages/db && bun run db:migrate',
})
} else {
findings.push({
group: 'live',
status: 'pass',
message: `migrations up to date (${applied})`,
})
}
}
} else {
findings.push({
group: 'live',
status: 'skip',
message: 'database: DATABASE_URL not usable yet',
})
}
return findings
}
async function checkRedis(sim: EnvFile): Promise<Finding[]> {
const redisUrl = sim.vars.get('REDIS_URL')
if (!redisUrl) return []
const ping = await redisPing(redisUrl)
return [
ping.ok
? { group: 'live', status: 'pass', message: 'redis reachable' }
: {
group: 'live',
status: 'fail',
message: `redis unreachable: ${ping.error}`,
fix: 'fix REDIS_URL or remove it (optional for single-replica)',
},
]
}
async function checkService(label: string, port: number, url: string): Promise<Finding[]> {
if (!(await portOpen(port))) {
return [{ group: 'live', status: 'skip', message: `${label}: not running on :${port}` }]
}
if (await httpHealth(url)) {
return [{ group: 'live', status: 'pass', message: `${label} healthy on :${port}` }]
}
return [
{
group: 'live',
status: 'fail',
message: `${label}: something is on :${port} but ${url} is not answering`,
fix: 'check the dev server logs',
},
]
}
async function checkOllama(sim: EnvFile): Promise<Finding[]> {
const ollamaUrl = sim.vars.get('OLLAMA_URL')
if (!ollamaUrl) return []
return [
(await httpHealth(`${ollamaUrl.replace(/\/$/, '')}/api/tags`))
? { group: 'live', status: 'pass', message: 'ollama reachable' }
: {
group: 'live',
status: 'warn',
message: 'OLLAMA_URL is set but Ollama is not answering',
fix: 'start Ollama or remove OLLAMA_URL',
},
]
}
/**
* The five probes are independent, so they run concurrently — serially this is
* the sum of every timeout (~17s worst case) on a command whose whole job is to
* tell you what's broken. Results are concatenated in a fixed order so the
* report stays deterministic regardless of which probe settles first.
*/
async function checkLive(ctx: CheckContext): Promise<Finding[]> {
const sim = ctx.primary
const [database, redis, app, realtime, ollama] = await Promise.all([
checkDatabase(sim),
checkRedis(sim),
checkService('app', 3000, 'http://localhost:3000/api/health'),
checkService('realtime', 3002, 'http://localhost:3002/health'),
checkOllama(sim),
])
return [...database, ...redis, ...app, ...realtime, ...ollama]
}
export async function runChecks(ctx: CheckContext, groups?: CheckGroup[]): Promise<Finding[]> {
const findings: Finding[] = [
...checkFiles(ctx),
...checkSchema(ctx),
...checkConsistency(ctx),
...checkCoherence(ctx),
]
if (ctx.live) findings.push(...(await checkLive(ctx)))
return groups ? findings.filter((f) => groups.includes(f.group)) : findings
}
+126
View File
@@ -0,0 +1,126 @@
import { spawnSync } from 'node:child_process'
import { sha256Base64Url } from '@sim/security/hash'
import { generateSecureToken } from '@sim/security/tokens'
import { sleep } from '@sim/utils/helpers'
import { generateShortId } from '@sim/utils/id'
import { parseRetryAfter } from '@sim/utils/retry'
import * as p from './prompter.ts'
import { link, theme } from './theme.ts'
// Generous enough for a first-time user to create an account, wait for the email
// OTP, land back on /cli/auth, and approve — a few minutes is routine. The
// server-side approval record has its own short TTL, so a long client wait only
// costs cheap, rate-limited polls.
const WAIT_MS = 900_000
const POLL_INTERVAL_MS = 2000
function openBrowser(url: string): void {
if (process.env.SIM_SETUP_NO_BROWSER) return
if (process.platform === 'win32') {
// `start` is a cmd builtin, not an executable — spawning it directly ENOENTs.
// cmd re-parses the command line and would treat `&` in the query string as a
// command separator, truncating the URL; quote it (the query is URL-encoded, so
// it never contains a `"`) and pass args verbatim so Node doesn't re-quote them.
// `""` is start's window-title placeholder, required before the URL.
spawnSync('cmd', ['/c', 'start', '""', `"${url}"`], {
stdio: 'ignore',
windowsVerbatimArguments: true,
})
return
}
const command = process.platform === 'darwin' ? 'open' : 'xdg-open'
spawnSync(command, [url], { stdio: 'ignore' })
}
/** No O/0 or I/1 — this exists to be compared by eye against a browser tab. */
const PAIRING_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
/**
* Short human-comparable code, shown in this terminal and on the approval page.
*
* The poll secret binds the *key* to this process, but nothing cryptographic
* tells the user whether the page they're approving belongs to their terminal
* or to a link someone sent them — an attacker supplies the request id and
* challenge. Comparing this code is the only thing that distinguishes the two.
*/
function createPairingCode(): string {
const chars = generateShortId(8, PAIRING_ALPHABET)
return `${chars.slice(0, 4)}-${chars.slice(4)}`
}
interface PollResponse {
status: 'pending' | 'complete'
key?: { apiKey?: string }
}
/**
* Device-flow handoff: open the approval page and poll for the key over TLS.
*
* No loopback listener — the terminal and browser need not share a machine, so
* this works over SSH and inside containers. The poll secret never leaves this
* process; only its digest reaches the server, so an observer of the request id
* cannot mint. Returns the key, or null on timeout / failed poll (re-run to
* retry). Ctrl-C exits setup via the SIGINT handler.
*/
export async function browserKeyFlow(origin: string): Promise<string | null> {
const request = generateSecureToken(32)
const pollSecret = generateSecureToken(32)
const challenge = sha256Base64Url(pollSecret)
const pairingCode = createPairingCode()
const query = new URLSearchParams({ request, challenge, pairing: pairingCode })
const authUrl = `${origin}/cli/auth?${query}`
p.note(
`${theme.heading(pairingCode)}\n\n${theme.muted('The page should show this code. If it shows a different one,\nthe request is not from this terminal — close the tab.')}`,
'Confirm this code in your browser'
)
p.log.info(
`Opening your browser — sign in and approve; the key comes back automatically.\n If it doesn't open: ${link(authUrl, authUrl)}`
)
openBrowser(authUrl)
const spin = p.spinner()
spin.start('Waiting for approval in your browser')
const deadline = Date.now() + WAIT_MS
while (Date.now() < deadline) {
await sleep(POLL_INTERVAL_MS)
// null means still pending or a transient error — either way, keep waiting.
const key = await pollOnce(origin, request, pollSecret)
if (key) {
spin.stop('Approved')
return key
}
}
spin.stop('Browser handoff timed out')
return null
}
/**
* One poll. Returns the key when the approval completes, `null` while pending or
* on a transient error (the caller keeps waiting until the deadline).
*/
async function pollOnce(origin: string, request: string, verifier: string): Promise<string | null> {
try {
const response = await fetch(`${origin}/api/cli/auth/poll`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ request, verifier }),
})
// Behind a shared NAT the per-IP bucket can be hit — honor Retry-After and
// back off instead of hammering the endpoint every interval.
if (response.status === 429) {
const retryMs = parseRetryAfter(response.headers.get('retry-after'))
if (retryMs) await sleep(retryMs)
return null
}
if (!response.ok) return null
const data = (await response.json()) as PollResponse
return data.status === 'complete' ? (data.key?.apiKey ?? null) : null
} catch {
return null
}
}
+238
View File
@@ -0,0 +1,238 @@
import { spawnSync } from 'node:child_process'
import { DB_CONTAINER, type Detection } from './detect.ts'
import { ensureDocker } from './docker.ts'
import { generateSecret } from './env-files.ts'
import { SetupError } from './errors.ts'
import { pgProbe, waitFor } from './probes.ts'
import * as p from './prompter.ts'
import { glyph, theme } from './theme.ts'
const DEFAULT_DSN = 'postgresql://postgres:postgres@localhost:5432/simstudio'
export function docker(args: string[]): void {
const result = spawnSync('docker', args, { encoding: 'utf8' })
if (result.status !== 0) {
throw new Error(`docker ${args[0]} failed: ${result.stderr.trim() || result.stdout.trim()}`)
}
}
function dockerOutput(args: string[]): string | null {
const result = spawnSync('docker', args, { encoding: 'utf8' })
return result.status === 0 ? result.stdout.trim() : null
}
interface ManagedContainer {
running: boolean
dsn: string
}
/**
* Recovers everything needed to reach an existing managed container from Docker
* itself, so re-running the wizard is idempotent.
*
* Both facts used to be unrecoverable: the password is generated at creation and
* only lived in the env files the run wrote, and the host port varies (5433 when
* 5432 is taken). Reading them back turns "a container already exists" from a
* fatal name collision into a reuse.
*/
function inspectManagedContainer(): ManagedContainer | null {
const running = dockerOutput(['inspect', DB_CONTAINER, '--format', '{{.State.Running}}'])
if (running === null) return null
const env = dockerOutput([
'inspect',
DB_CONTAINER,
'--format',
'{{range .Config.Env}}{{println .}}{{end}}',
])
const password = env
?.split('\n')
.find((line) => line.startsWith('POSTGRES_PASSWORD='))
?.slice('POSTGRES_PASSWORD='.length)
if (!password) return null
// `docker port` only reports a published port while the container runs; the
// static config carries it either way.
const hostPort = dockerOutput([
'inspect',
DB_CONTAINER,
'--format',
'{{(index .HostConfig.PortBindings "5432/tcp" 0).HostPort}}',
])
if (!hostPort) return null
return {
running: running === 'true',
dsn: `postgresql://postgres:${password}@localhost:${hostPort}/simstudio`,
}
}
/** Starts the container if needed and returns its DSN, or null if it won't answer. */
async function reuseManagedContainer(container: ManagedContainer): Promise<string | null> {
if (!container.running) docker(['start', DB_CONTAINER])
const spin = p.spinner()
spin.start(`Reusing existing ${DB_CONTAINER} container…`)
const healthy = await waitFor(async () => (await pgProbe(container.dsn)).ok, 30_000, 1500)
spin.stop(
healthy
? `Postgres running in ${DB_CONTAINER} on :${new URL(container.dsn).port}`
: `${glyph.warn} ${DB_CONTAINER} exists but is not answering`
)
return healthy ? container.dsn : null
}
async function probeWithSpinner(dsn: string, label: string): Promise<boolean> {
const spin = p.spinner()
spin.start(label)
const probe = await pgProbe(dsn)
if (probe.ok && probe.pgvectorAvailable === false) {
spin.stop(`${glyph.warn} connected, but pgvector is missing on that Postgres`)
return false
}
spin.stop(probe.ok ? 'database reachable (pgvector available)' : `${glyph.warn} ${probe.error}`)
return probe.ok
}
async function promptExternalDsn(): Promise<string> {
for (;;) {
const dsn = await p.text({
message: 'Postgres connection string (needs the pgvector extension)',
placeholder: DEFAULT_DSN,
validate: (value) => {
if (!value) return 'required'
try {
new URL(value)
return undefined
} catch {
return 'not a valid connection URL'
}
},
})
if (await probeWithSpinner(dsn, 'Testing connection…')) return dsn
const retry = await p.confirm({
message: 'Connection failed — try a different URL?',
initialValue: true,
})
if (!retry) {
throw new SetupError('no usable Postgres.', [
'install Docker — the wizard manages a pgvector container for you',
'or bring any Postgres with the pgvector extension and re-run with its connection string',
])
}
}
}
/**
* Provisions the managed container, reconciling with one that already exists
* rather than colliding on the name. Recreating is always an explicit choice —
* the data volume outlives the container, so a silent recreate would quietly
* re-point setup at data the user may not expect.
*/
async function startManagedContainer(detection: Detection): Promise<string> {
const existing = inspectManagedContainer()
if (existing) {
const reused = await reuseManagedContainer(existing)
if (reused) return reused
const recreate = await p.confirm({
message: `${DB_CONTAINER} exists but is not answering. Remove and recreate it? Its data volume is kept.`,
initialValue: true,
})
if (!recreate) {
throw new SetupError(`the existing ${DB_CONTAINER} container is not usable.`, [
`inspect: ${theme.command(`docker logs ${DB_CONTAINER}`)}`,
`remove it: ${theme.command(`docker rm -f ${DB_CONTAINER}`)}`,
`start clean: ${theme.command('docker volume rm sim-postgres-data')} drops its data too`,
])
}
docker(['rm', '-f', DB_CONTAINER])
}
const password = generateSecret().slice(0, 24)
const hostPort = detection.postgresPortOpen ? 5433 : 5432
const dsn = `postgresql://postgres:${password}@localhost:${hostPort}/simstudio`
docker([
'run',
'-d',
'--name',
DB_CONTAINER,
'--label',
'managed-by=sim-setup',
'-v',
'sim-postgres-data:/var/lib/postgresql/data',
'-e',
`POSTGRES_PASSWORD=${password}`,
'-e',
'POSTGRES_DB=simstudio',
'-p',
`${hostPort}:5432`,
'pgvector/pgvector:pg17',
])
const spin = p.spinner()
spin.start(`Starting ${DB_CONTAINER} container on :${hostPort}…`)
const healthy = await waitFor(async () => (await pgProbe(dsn)).ok, 45_000, 1500)
if (!healthy) {
spin.stop(`${glyph.fail} container did not become healthy`)
const logs = spawnSync('docker', ['logs', '--tail', '20', DB_CONTAINER], { encoding: 'utf8' })
throw new SetupError(
`the Postgres container failed to start. Last logs:\n${logs.stdout}${logs.stderr}`,
[
`inspect: ${theme.command(`docker logs ${DB_CONTAINER}`)}`,
`remove and retry: ${theme.command(`docker rm -f ${DB_CONTAINER}`)} then re-run the wizard`,
]
)
}
spin.stop(`Postgres running in ${DB_CONTAINER} on :${hostPort}`)
return dsn
}
/**
* The mode-B database ladder: reuse a working DSN, offer (never silently adopt)
* a Postgres already on 5432, start/reuse the wizard-managed pgvector
* container, or take an external DSN. Adopting an existing database is always
* an explicit choice — migrations run against whatever is chosen here.
*/
export async function resolveDatabase(detection: Detection, existingDsn?: string): Promise<string> {
if (existingDsn && (await probeWithSpinner(existingDsn, 'Testing existing DATABASE_URL…'))) {
return existingDsn
}
// Any managed container, running or stopped — a running one used to fall
// through to `docker run` and die on the name collision.
if (detection.dbContainer?.managed) {
const existing = inspectManagedContainer()
const reused = existing && (await reuseManagedContainer(existing))
if (reused) return reused
}
if (
detection.postgresPortOpen &&
(await probeWithSpinner(DEFAULT_DSN, 'Postgres found on :5432 — testing default credentials…'))
) {
const adopt = await p.confirm({
message: `Use the existing Postgres on :5432? Migrations will run against its "simstudio" database — if that's your dev data, say no and get an isolated container instead.`,
initialValue: false,
})
if (adopt) return DEFAULT_DSN
}
const dockerAvailable = await ensureDocker(false)
const options: p.SelectOption<'container' | 'external'>[] = []
if (dockerAvailable) {
options.push({
value: 'container',
label: 'Start a Postgres container for me',
hint: `pgvector/pgvector:pg17, persistent volume, named ${DB_CONTAINER} — recommended`,
})
}
options.push({
value: 'external',
label: 'Use an existing Postgres',
hint: 'paste a connection string (needs pgvector)',
})
if (!dockerAvailable) {
p.log.warn('Docker is not available, so the wizard cannot manage a Postgres container for you.')
}
const choice = await p.select({ message: 'Where should the database live?', options })
return choice === 'container' ? startManagedContainer(detection) : promptExternalDsn()
}
+156
View File
@@ -0,0 +1,156 @@
import { spawnSync } from 'node:child_process'
import net from 'node:net'
import os from 'node:os'
import { ROOT, readEnvFile } from './env-files.ts'
export const MANAGED_LABEL = 'managed-by=sim-setup'
export const DB_CONTAINER = 'sim-postgres'
export const REDIS_CONTAINER = 'sim-redis'
const SHELL_LLM_KEYS = [
'OPENAI_API_KEY',
'ANTHROPIC_API_KEY',
'GEMINI_API_KEY',
'XAI_API_KEY',
'MISTRAL_API_KEY',
] as const
export interface Detection {
dockerRunning: boolean
appPortOpen: boolean
realtimePortOpen: boolean
postgresPortOpen: boolean
redisPortOpen: boolean
envFiles: { sim: boolean; realtime: boolean; db: boolean; root: boolean }
dbContainer: { state: 'running' | 'stopped'; managed: boolean } | null
redisContainer: { state: 'running' | 'stopped'; managed: boolean } | null
shellLlmKeys: string[]
ollamaReachable: boolean
binaries: { kubectl: boolean; helm: boolean; kind: boolean }
kubeContext: string | null
specs: { hostMemGb: number; dockerMemGb: number | null; freeDiskGb: number | null }
}
export function portOpen(port: number, timeoutMs = 500): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.connect({ port, host: '127.0.0.1' })
const done = (result: boolean) => {
socket.destroy()
resolve(result)
}
socket.setTimeout(timeoutMs, () => done(false))
socket.once('connect', () => done(true))
socket.once('error', () => done(false))
})
}
export interface PortOwnerInfo {
command: string
pid: number
isDocker: boolean
}
export function portOwner(port: number): PortOwnerInfo | null {
const result = spawnSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN'], { encoding: 'utf8' })
if (result.status !== 0) return null
const line = result.stdout.split('\n')[1]
if (!line) return null
const [command, pid] = line.split(/\s+/)
if (!command || !pid) return null
return { command, pid: Number(pid), isDocker: /^(com\.docke|docker)/i.test(command) }
}
function commandSucceeds(command: string, args: string[]): boolean {
return spawnSync(command, args, { stdio: 'ignore' }).status === 0
}
function commandOutput(command: string, args: string[]): string | null {
const result = spawnSync(command, args, { encoding: 'utf8' })
return result.status === 0 ? result.stdout.trim() : null
}
function detectContainer(dockerRunning: boolean, name: string): Detection['dbContainer'] {
if (!dockerRunning) return null
// Docker's `name=^x$` anchor matches against the internal `/x` form and often
// misses, so filter loosely (substring) and pin the exact name in code.
const out = commandOutput('docker', [
'ps',
'-a',
'--filter',
`name=${name}`,
'--format',
'{{.Names}}\t{{.State}}\t{{.Labels}}',
])
if (!out) return null
const row = out
.split('\n')
.map((line) => line.split('\t'))
.find(([containerName]) => containerName === name)
if (!row) return null
const [, state, labels = ''] = row
return {
state: state === 'running' ? 'running' : 'stopped',
managed: labels.includes(MANAGED_LABEL),
}
}
async function ollamaReachable(): Promise<boolean> {
try {
const res = await fetch('http://localhost:11434/api/tags', {
signal: AbortSignal.timeout(800),
})
return res.ok
} catch {
return false
}
}
function detectSpecs(dockerRunning: boolean): Detection['specs'] {
const dockerMem = dockerRunning
? commandOutput('docker', ['info', '--format', '{{.MemTotal}}'])
: null
const df = spawnSync('df', ['-k', ROOT], { encoding: 'utf8' })
const dfAvail =
df.status === 0 ? Number(df.stdout.trim().split('\n')[1]?.split(/\s+/)[3]) : Number.NaN
return {
hostMemGb: Math.round(os.totalmem() / 1024 ** 3),
dockerMemGb: dockerMem ? Math.round((Number(dockerMem) / 1024 ** 3) * 10) / 10 : null,
freeDiskGb: Number.isNaN(dfAvail) ? null : Math.round(dfAvail / 1024 ** 2),
}
}
export async function runDetection(): Promise<Detection> {
const dockerRunning = commandSucceeds('docker', ['info'])
const [appPortOpen, realtimePortOpen, postgresPortOpen, redisPortOpen, ollamaPortOpen] =
await Promise.all([
portOpen(3000),
portOpen(3002),
portOpen(5432),
portOpen(6379),
portOpen(11434),
])
return {
dockerRunning,
appPortOpen,
realtimePortOpen,
postgresPortOpen,
redisPortOpen,
envFiles: {
sim: readEnvFile('sim').exists,
realtime: readEnvFile('realtime').exists,
db: readEnvFile('db').exists,
root: readEnvFile('root').exists,
},
dbContainer: detectContainer(dockerRunning, DB_CONTAINER),
redisContainer: detectContainer(dockerRunning, REDIS_CONTAINER),
shellLlmKeys: SHELL_LLM_KEYS.filter((key) => process.env[key]),
ollamaReachable: ollamaPortOpen ? await ollamaReachable() : false,
binaries: {
kubectl: Bun.which('kubectl') !== null,
helm: Bun.which('helm') !== null,
kind: Bun.which('kind') !== null,
},
kubeContext: commandOutput('kubectl', ['config', 'current-context']),
specs: detectSpecs(dockerRunning),
}
}
+68
View File
@@ -0,0 +1,68 @@
import { spawnSync } from 'node:child_process'
import { SetupError } from './errors.ts'
import { waitFor } from './probes.ts'
import * as p from './prompter.ts'
import { glyph, theme } from './theme.ts'
const INSTALL_HINTS = [
'install Docker Desktop: https://docker.com/products/docker-desktop',
`or OrbStack (lighter on macOS): ${theme.command('brew install orbstack')}`,
]
function daemonUp(): boolean {
return spawnSync('docker', ['info'], { stdio: 'ignore' }).status === 0
}
function installed(): boolean {
// Bun.which resolves PATH cross-platform (incl. PATHEXT on Windows); `which`
// is not a standard Windows command.
return Bun.which('docker') !== null
}
/**
* Returns whether the Docker daemon is available, offering to launch Docker
* Desktop (macOS) when it's installed but stopped. Never installs anything.
* With required=true, unavailability is a SetupError instead of false.
*/
export async function ensureDocker(required: boolean): Promise<boolean> {
if (daemonUp()) return true
if (!installed()) {
if (required) throw new SetupError('Docker is not installed.', INSTALL_HINTS)
return false
}
if (process.platform !== 'darwin') {
if (required) {
throw new SetupError('Docker is installed but the daemon is not running.', [
`start it: ${theme.command('sudo systemctl start docker')} (Linux)`,
])
}
return false
}
const launch = await p.confirm({
message: 'Docker is installed but not running — start Docker Desktop now?',
initialValue: true,
})
if (!launch) {
if (required) {
throw new SetupError('Docker is required for this mode.', [
'start Docker Desktop, then re-run the wizard',
])
}
return false
}
spawnSync('open', ['-a', 'Docker'], { stdio: 'ignore' })
const spin = p.spinner()
spin.start('Waiting for the Docker daemon…')
const up = await waitFor(async () => daemonUp(), 90_000, 2000)
spin.stop(up ? 'Docker is running' : `${glyph.fail} daemon did not come up`)
if (!up) {
throw new SetupError('Docker Desktop did not start within 90s.', [
'first-ever launch needs a GUI license acceptance — open Docker Desktop manually once, then re-run',
])
}
return true
}
+65
View File
@@ -0,0 +1,65 @@
import { type CheckGroup, type Finding, loadCheckContext, runChecks } from './checks.ts'
import { glyph, theme } from './theme.ts'
const GROUP_TITLES: Record<CheckGroup, string> = {
files: 'Env files',
schema: 'Schema',
consistency: 'Consistency',
coherence: 'Coherence',
live: 'Live',
}
const GROUP_ORDER: CheckGroup[] = ['files', 'schema', 'consistency', 'coherence', 'live']
function render(findings: Finding[], fixedCount: number): void {
console.log(`\n${theme.heading('◆ Sim doctor')}\n`)
for (const group of GROUP_ORDER) {
const groupFindings = findings.filter((f) => f.group === group)
if (groupFindings.length === 0) continue
console.log(theme.heading(GROUP_TITLES[group]))
for (const finding of groupFindings) {
console.log(` ${glyph[finding.status]} ${finding.message}`)
if (finding.fix && finding.status !== 'pass') {
console.log(` ${theme.muted(`fix: ${finding.fix}`)}`)
}
}
console.log()
}
const counts = {
pass: findings.filter((f) => f.status === 'pass').length,
warn: findings.filter((f) => f.status === 'warn').length,
fail: findings.filter((f) => f.status === 'fail').length,
}
const summary = [`${counts.pass} passed`]
if (counts.warn) summary.push(theme.warn(`${counts.warn} warning${counts.warn > 1 ? 's' : ''}`))
if (counts.fail) summary.push(theme.error(`${counts.fail} failed`))
if (fixedCount) summary.push(theme.success(`${fixedCount} fixed`))
console.log(summary.join(theme.muted(' · ')))
}
export async function runDoctor(options: { fix: boolean; json: boolean }): Promise<number> {
let findings = await runChecks(loadCheckContext(true))
let fixedCount = 0
if (options.fix) {
const fixable = findings.filter(
(f) => (f.status === 'fail' || f.status === 'warn') && f.autofix
)
for (const finding of fixable) {
finding.autofix?.()
fixedCount++
}
if (fixedCount > 0) findings = await runChecks(loadCheckContext(true))
}
if (options.json) {
console.log(
JSON.stringify(
findings.map(({ autofix: _autofix, ...rest }) => rest),
null,
2
)
)
} else {
render(findings, fixedCount)
}
return findings.some((f) => f.status === 'fail') ? 1 : 0
}
+175
View File
@@ -0,0 +1,175 @@
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',
] 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.has(match[1])) 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 activeIdx = lines.findIndex((l) => activeRe.test(l))
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')
}
/** Writes values into an env file, seeding a missing file from its .env.example. */
export function writeEnvValues(target: EnvTarget, 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') : ''
}
for (const [key, value] of Object.entries(values)) {
content = upsertEnv(content, key, value)
}
writeFileSync(filePath, content)
}
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_')
}
/**
* 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'
}
+10
View File
@@ -0,0 +1,10 @@
/** A setup failure that carries actionable next steps for the failure screen. */
export class SetupError extends Error {
readonly hints: string[]
constructor(message: string, hints: string[] = []) {
super(message)
this.name = 'SetupError'
this.hints = hints
}
}
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bun
import { getErrorMessage } from '@sim/utils/errors'
import { runDoctor } from './doctor.ts'
import { SetupError } from './errors.ts'
import { isLifecycleCommand, runLifecycle } from './lifecycle.ts'
import { exitWith, restoreTerminal } from './terminal.ts'
import { theme } from './theme.ts'
import { runWizard, type WizardMode } from './wizard.ts'
const USAGE = `Usage:
bun run setup run the setup wizard
bun run sim setup [--quick] [--mode compose|dev|k8s]
bun run sim doctor [--fix] [--json] check your setup
bun run sim start | stop | restart bring your install up / down / cycle
bun run sim status what's installed and healthy
bun run sim logs follow logs
bun run sim down remove containers (data kept)
bun run sim reset archive .env + wipe managed data
Prefer a bare "sim"? Run "bun link" once, then ensure ~/.bun/bin is on your
PATH (Homebrew's bun doesn't add it): export PATH="$HOME/.bun/bin:$PATH".`
function parseMode(value: string | undefined): WizardMode {
if (value === 'compose' || value === 'dev' || value === 'k8s') return value
throw new Error(`invalid --mode "${value}" — expected compose, dev, or k8s`)
}
async function main(): Promise<void> {
const args = process.argv.slice(2)
if (args.includes('--help') || args.includes('-h')) {
console.log(USAGE)
return
}
process.on('SIGINT', () => exitWith(130))
const command = args[0]
// Bare `sim` prints help; the wizard is `sim setup` (or `bun run setup`).
if (!command) {
console.log(USAGE)
return
}
if (command === 'doctor') {
process.exitCode = await runDoctor({
fix: args.includes('--fix'),
json: args.includes('--json'),
})
return
}
if (isLifecycleCommand(command)) {
await runLifecycle(command)
return
}
if (command === 'setup') {
const setupArgs = args.slice(1)
const modeIdx = setupArgs.indexOf('--mode')
await runWizard({
quick: setupArgs.includes('--quick'),
mode: modeIdx === -1 ? undefined : parseMode(setupArgs[modeIdx + 1]),
})
return
}
console.error(`Unknown command: ${command}\n`)
console.log(USAGE)
process.exitCode = 1
}
function renderFailure(error: unknown): void {
const hints = error instanceof SetupError ? error.hints : []
console.error()
console.error(`${theme.error('✗ Setup failed')}\n`)
console.error(` ${getErrorMessage(error).split('\n').join('\n ')}`)
if (hints.length > 0) {
console.error(`\n ${theme.heading('Try:')}`)
for (const hint of hints) {
console.error(` ${theme.muted('•')} ${hint}`)
}
}
console.error(
`\n ${theme.muted('Your progress is saved — re-run')} ${theme.command('bun run setup')} ${theme.muted('to pick up where you left off.')}`
)
}
main()
.catch((error) => {
renderFailure(error)
process.exitCode = 1
})
.finally(restoreTerminal)
+391
View File
@@ -0,0 +1,391 @@
import { spawnSync } from 'node:child_process'
import { DB_CONTAINER, type Detection, REDIS_CONTAINER, runDetection } from './detect.ts'
import { archiveEnvFile, ROOT } from './env-files.ts'
import { SetupError } from './errors.ts'
import { isLocalKubeContext } from './modes/k8s.ts'
import { httpHealth } from './probes.ts'
import * as p from './prompter.ts'
import { glyph, theme } from './theme.ts'
const APP_URL = 'http://localhost:3000'
const REALTIME_HEALTH = 'http://localhost:3002/health'
const POSTGRES_VOLUME = 'sim-postgres-data'
const COMPOSE_FILES = ['docker-compose.prod.yml', 'docker-compose.local.yml'] as const
const K8S_RELEASE = 'sim-dev'
const K8S_NAMESPACE = 'sim-dev'
export const LIFECYCLE_COMMANDS = [
'start',
'stop',
'restart',
'status',
'logs',
'down',
'reset',
] as const
export type LifecycleCommand = (typeof LIFECYCLE_COMMANDS)[number]
export function isLifecycleCommand(value: string): value is LifecycleCommand {
return (LIFECYCLE_COMMANDS as readonly string[]).includes(value)
}
/**
* POSIX-quote a value for a copyable shell hint — a kube-context can contain
* whitespace or metacharacters that would break a copied command.
*/
function shq(value: string): string {
if (/^[A-Za-z0-9._/-]+$/.test(value)) return value
return `'${value.replace(/'/g, `'\\''`)}'`
}
/** Non-throwing docker probe; returns trimmed stdout or null on any failure. */
function dockerText(args: string[]): string | null {
const result = spawnSync('docker', args, { cwd: ROOT, encoding: 'utf8' })
return result.status === 0 ? result.stdout.trim() : null
}
/** Docker command whose output the user should see (up, logs); returns exit code. */
function dockerInherit(args: string[]): number {
return spawnSync('docker', args, { cwd: ROOT, stdio: 'inherit' }).status ?? 1
}
/** Docker command that must succeed; throws a SetupError with stderr on failure. */
function dockerRun(args: string[], failMessage: string): void {
const result = spawnSync('docker', args, { cwd: ROOT, encoding: 'utf8' })
if (result.status !== 0) {
throw new SetupError(`${failMessage}: ${result.stderr.trim() || result.stdout.trim()}`)
}
}
interface ComposeInstall {
kind: 'compose'
file: string
}
interface DevInstall {
kind: 'dev'
postgres: boolean
redis: boolean
}
interface K8sInstall {
kind: 'k8s'
context: string
/** False when the context's API server is outside the local allowlist — flagged before destructive ops. */
local: boolean
}
type Install = ComposeInstall | DevInstall | K8sInstall
/**
* A compose project brought up from ROOT reuses the same project name at `ps`,
* so probing each candidate compose file recovers exactly which one owns
* containers — no need to guess the project name or persist the choice.
*/
function composeInstalls(): ComposeInstall[] {
return COMPOSE_FILES.filter((file) => dockerText(['compose', '-f', file, 'ps', '-aq'])).map(
(file) => ({ kind: 'compose', file })
)
}
/** Dev mode owns the split env files and, usually, the managed Postgres/Redis. */
function devInstall(detection: Detection): DevInstall | null {
const postgres = detection.dbContainer?.managed ?? false
const redis = detection.redisContainer?.managed ?? false
const splitEnv = detection.envFiles.sim || detection.envFiles.realtime || detection.envFiles.db
if (!postgres && !redis && !splitEnv) return null
return { kind: 'dev', postgres, redis }
}
/**
* Detection is factual: a release either exists on the current context or it
* doesn't. Setup lets the user explicitly confirm a context whose API server is
* outside the local allowlist, so gating detection on locality would strand that
* release — `status`/`start`/`stop`/`down`/`reset` would all claim there is no
* Kubernetes install. Instead the locality is recorded and surfaced: every
* destructive path names the target (and flags a non-local one) before acting.
*/
function k8sInstall(detection: Detection): K8sInstall | null {
const context = detection.kubeContext
if (!context) return null
const status = spawnSync(
'helm',
['status', K8S_RELEASE, '--kube-context', context, '-n', K8S_NAMESPACE],
{ stdio: 'ignore' }
)
if (status.status !== 0) return null
return { kind: 'k8s', context, local: isLocalKubeContext(context) }
}
function detectInstalls(detection: Detection): Install[] {
const installs: Install[] = [...composeInstalls()]
const dev = devInstall(detection)
if (dev) installs.push(dev)
const k8s = k8sInstall(detection)
if (k8s) installs.push(k8s)
return installs
}
function describeInstall(install: Install): string {
if (install.kind === 'compose') return `Docker Compose (${install.file})`
if (install.kind === 'dev') return 'Local dev (managed Postgres/Redis)'
// Naming a non-local cluster is the guard against acting on the wrong one after
// an ambient context switch — every destructive confirm renders this string.
const scope = install.local ? '' : ' — NOT a verified-local cluster'
return `Kubernetes (context ${install.context}${scope})`
}
/** One install → use it; several → let the user pick; none → null. */
async function resolveInstall(installs: Install[]): Promise<Install | null> {
if (installs.length <= 1) return installs[0] ?? null
const choice = await p.select({
message: 'Multiple installs detected — which one?',
options: installs.map((install, index) => ({
value: String(index),
label: describeInstall(install),
})),
initialValue: '0',
})
return installs[Number(choice)]
}
function managedNames(install: DevInstall): string[] {
const names: string[] = []
if (install.postgres) names.push(DB_CONTAINER)
if (install.redis) names.push(REDIS_CONTAINER)
return names
}
function k8sReachHints(context: string): string {
const c = shq(context)
return [
`kubectl --context ${c} -n ${K8S_NAMESPACE} port-forward svc/${K8S_RELEASE}-app 3000:3000`,
`kubectl --context ${c} -n ${K8S_NAMESPACE} get pods`,
].join('\n')
}
function start(install: Install): void {
if (install.kind === 'compose') {
const spin = p.spinner()
spin.start('Starting containers…')
dockerRun(['compose', '-f', install.file, 'up', '-d'], 'docker compose up failed')
spin.stop('Containers up')
p.note(
[`open ${APP_URL}`, 'follow logs: sim logs', 'stop: sim stop'].join('\n'),
'Running'
)
return
}
if (install.kind === 'dev') {
const names = managedNames(install)
for (const name of names) dockerRun(['start', name], `docker start ${name} failed`)
if (names.length) p.log.step(`Started ${names.join(', ')}`)
p.note(
['start the dev server: bun run dev:full', 'stop DB/Redis: sim stop'].join('\n'),
'Ready'
)
return
}
p.note(k8sReachHints(install.context), 'Kubernetes is managed with kubectl')
}
function stop(install: Install): void {
if (install.kind === 'compose') {
const spin = p.spinner()
spin.start('Stopping containers…')
dockerRun(['compose', '-f', install.file, 'stop'], 'docker compose stop failed')
spin.stop('Containers stopped (data kept)')
p.note(['start again: sim start', 'remove: sim down'].join('\n'), 'Stopped')
return
}
if (install.kind === 'dev') {
const names = managedNames(install)
for (const name of names) dockerRun(['stop', name], `docker stop ${name} failed`)
if (names.length) p.log.step(`Stopped ${names.join(', ')}`)
p.note(
'The dev server runs in the foreground — stop it with Ctrl-C in its terminal.',
'Dev server'
)
return
}
const c = shq(install.context)
p.note(
[
`scale down: kubectl --context ${c} -n ${K8S_NAMESPACE} scale deploy --all --replicas=0`,
`scale up: kubectl --context ${c} -n ${K8S_NAMESPACE} scale deploy --all --replicas=1`,
'tear down: sim down',
].join('\n'),
'Kubernetes'
)
}
function restart(install: Install): void {
if (install.kind === 'compose') {
const spin = p.spinner()
spin.start('Restarting containers…')
dockerRun(['compose', '-f', install.file, 'restart'], 'docker compose restart failed')
spin.stop('Containers restarted')
p.note(`open ${APP_URL}`, 'Running')
return
}
if (install.kind === 'dev') {
const names = managedNames(install)
for (const name of names) dockerRun(['restart', name], `docker restart ${name} failed`)
if (names.length) p.log.step(`Restarted ${names.join(', ')}`)
p.note('Restart the dev server manually (Ctrl-C, then bun run dev:full).', 'Dev server')
return
}
p.note(k8sReachHints(install.context), 'Kubernetes is managed with kubectl')
}
function showLogs(install: Install): void {
if (install.kind === 'compose') {
dockerInherit(['compose', '-f', install.file, 'logs', '-f', '--tail', '100'])
return
}
if (install.kind === 'dev') {
const names = managedNames(install)
p.note(
[
'the dev server logs stream in its own terminal (bun run dev:full)',
...names.map((name) => `container: docker logs -f ${name}`),
].join('\n'),
'Logs'
)
return
}
spawnSync(
'kubectl',
['--context', install.context, '-n', K8S_NAMESPACE, 'logs', '-f', `deploy/${K8S_RELEASE}-app`],
{ stdio: 'inherit' }
)
}
async function down(install: Install): Promise<void> {
const ok = await p.confirm({
message: `Remove ${describeInstall(install)} containers? Data volumes are kept.`,
initialValue: false,
})
if (!ok) {
p.log.info('Left it running.')
return
}
if (install.kind === 'compose') {
dockerRun(['compose', '-f', install.file, 'down'], 'docker compose down failed')
p.log.step('Containers removed (volumes kept)')
return
}
if (install.kind === 'dev') {
const names = managedNames(install)
if (names.length) {
dockerRun(['rm', '-f', ...names], 'docker rm failed')
p.log.step(`Removed ${names.join(', ')} (Postgres volume ${POSTGRES_VOLUME} kept)`)
} else {
p.log.info('No managed containers to remove.')
}
return
}
const result = spawnSync(
'helm',
['uninstall', K8S_RELEASE, '--kube-context', install.context, '-n', K8S_NAMESPACE],
{ stdio: 'inherit' }
)
if (result.status !== 0) throw new SetupError('helm uninstall failed')
p.log.step(`Uninstalled ${K8S_RELEASE}`)
}
async function reset(install: Install | null): Promise<void> {
// Name the exact target: k8s acts on the ambient context, so spelling out which
// cluster (or compose file / dev containers) is about to be wiped keeps a reset
// from silently hitting the wrong same-named install after a context switch.
const target = install ? ` ${describeInstall(install)} will be removed.` : ''
const ok = await p.confirm({
message: theme.error(
`Reset archives your .env files and wipes managed data (volumes).${target} Continue?`
),
initialValue: false,
})
if (!ok) {
p.log.info('Reset cancelled.')
return
}
for (const target of ['sim', 'realtime', 'db', 'root'] as const) {
const backup = archiveEnvFile(target)
if (backup) p.log.step(`Archived ${backup}`)
}
if (install?.kind === 'compose') {
dockerRun(['compose', '-f', install.file, 'down', '-v'], 'docker compose down -v failed')
p.log.step('Containers and volumes removed')
} else if (install?.kind === 'dev') {
const names = managedNames(install)
if (names.length) spawnSync('docker', ['rm', '-f', ...names], { cwd: ROOT, stdio: 'ignore' })
spawnSync('docker', ['volume', 'rm', POSTGRES_VOLUME], { cwd: ROOT, stdio: 'ignore' })
p.log.step('Managed containers and Postgres volume removed')
} else if (install?.kind === 'k8s') {
const uninstall = spawnSync(
'helm',
['uninstall', K8S_RELEASE, '--kube-context', install.context, '-n', K8S_NAMESPACE],
{ stdio: 'inherit' }
)
// Env files are already archived, so a failed uninstall leaves a live release
// with no local config — the worst thing to do is call that a success.
if (uninstall.status !== 0) {
throw new SetupError(
`env files were archived, but helm uninstall failed — the ${K8S_RELEASE} release is still running.`,
[
`retry: ${theme.command(`helm uninstall ${K8S_RELEASE} --kube-context ${shq(install.context)} -n ${K8S_NAMESPACE}`)}`,
`check the release: ${theme.command(`helm status ${K8S_RELEASE} --kube-context ${shq(install.context)} -n ${K8S_NAMESPACE}`)}`,
]
)
}
p.log.step(`Uninstalled ${K8S_RELEASE}`)
}
p.note(`start fresh with ${theme.command('sim setup')}`, 'Reset complete')
}
async function status(): Promise<void> {
const detection = await runDetection()
const installs = detectInstalls(detection)
console.log(`\n${theme.heading('◆ Sim status')}\n`)
if (installs.length === 0) {
console.log(` ${glyph.warn} No Sim install detected — run ${theme.command('sim setup')}.`)
return
}
for (const install of installs) console.log(` ${glyph.pass} ${describeInstall(install)}`)
const containerState = (state: { state: 'running' | 'stopped' } | null) =>
state ? state.state : 'absent'
console.log()
console.log(` postgres (${DB_CONTAINER}): ${containerState(detection.dbContainer)}`)
console.log(` redis (${REDIS_CONTAINER}): ${containerState(detection.redisContainer)}`)
const [app, realtime] = await Promise.all([
httpHealth(`${APP_URL}/api/health`),
httpHealth(REALTIME_HEALTH),
])
console.log()
console.log(` app (:3000) ${app ? glyph.pass : glyph.fail}`)
console.log(` realtime (:3002) ${realtime ? glyph.pass : glyph.fail}`)
}
export async function runLifecycle(command: LifecycleCommand): Promise<void> {
if (command === 'status') return status()
const installs = detectInstalls(await runDetection())
// Reset stays useful with nothing running — it still archives stray .env files.
if (command === 'reset') return reset(await resolveInstall(installs))
const install = await resolveInstall(installs)
if (!install) {
p.log.warn(`No Sim install detected. Run ${theme.command('sim setup')} first.`)
return
}
switch (command) {
case 'start':
return start(install)
case 'stop':
return stop(install)
case 'restart':
return restart(install)
case 'logs':
return showLogs(install)
case 'down':
return down(install)
}
}
+118
View File
@@ -0,0 +1,118 @@
import { spawnSync } from 'node:child_process'
import type { Detection } from '../detect.ts'
import { ensureDocker } from '../docker.ts'
import { ROOT, readEnvFile, writeEnvValues } from '../env-files.ts'
import { SetupError } from '../errors.ts'
import { ensurePortsFree } from '../ports.ts'
import { httpHealth, waitFor } from '../probes.ts'
import * as p from '../prompter.ts'
import {
collectSecrets,
promptCopilotKey,
promptEmail,
promptLlmKeys,
promptSecurity,
promptSignInProviders,
promptStorage,
promptUnlocks,
} from '../steps.ts'
import { glyph, theme } from '../theme.ts'
/**
* Compose publishes 3000 and 3002 — resolve conflicts before touching docker,
* instead of letting `docker compose up` die halfway through startup. Aborting
* is fatal here: compose can't come up while the ports are held.
*/
async function ensureComposePortsFree(composeFile: string): Promise<void> {
if (await ensurePortsFree([3000, 3002])) return
throw new SetupError('ports 3000/3002 are in use', [
`free the ports, then re-run: ${theme.command('bun run setup')}`,
`see what holds them: ${theme.command('lsof -nP -iTCP:3000 -sTCP:LISTEN')}`,
`stop a container publishing them: ${theme.command('docker ps')}`,
`compose file in play: ${composeFile}`,
])
}
export async function runComposeMode(detection: Detection, quick: boolean): Promise<void> {
await ensureDocker(true)
const variant = quick
? 'prod'
: await p.select({
message: 'Which images?',
options: [
{
value: 'prod',
label: 'Published images',
hint: 'pulls ghcr.io/simstudioai/* — fastest',
},
{
value: 'local',
label: 'Build from source',
hint: 'builds docker/*.Dockerfile — for testing local changes',
},
],
initialValue: 'prod',
})
const composeFile = variant === 'prod' ? 'docker-compose.prod.yml' : 'docker-compose.local.yml'
const root = readEnvFile('root')
const values = collectSecrets(root)
const copilotKey = await promptCopilotKey(root.vars.get('COPILOT_API_KEY'))
if (copilotKey) values.COPILOT_API_KEY = copilotKey
Object.assign(values, await promptLlmKeys(detection, !quick))
if (!quick) {
const storage = await promptStorage(root.vars, true)
if (storage) Object.assign(values, storage)
const appUrl = root.vars.get('NEXT_PUBLIC_APP_URL') ?? 'http://localhost:3000'
Object.assign(values, await promptSignInProviders(root.vars, appUrl))
Object.assign(values, await promptEmail(root.vars))
const security = await promptSecurity(root.vars)
Object.assign(values, security.sim, security.mirrorToRealtime)
Object.assign(values, await promptUnlocks(root.vars))
}
if (!root.vars.get('LOG_LEVEL')) {
values.LOG_LEVEL = 'INFO'
p.log.step(
'Set LOG_LEVEL=INFO (production containers default to ERROR, which hides startup problems)'
)
}
if (!root.vars.get('NEXT_TELEMETRY_DISABLED')) values.NEXT_TELEMETRY_DISABLED = '1'
writeEnvValues('root', values)
p.log.step('Wrote .env (compose reads it for variable substitution)')
await ensureComposePortsFree(composeFile)
p.log.step(`Running docker compose -f ${composeFile} up -d`)
const result = spawnSync('docker', ['compose', '-f', composeFile, 'up', '-d'], {
cwd: ROOT,
stdio: 'inherit',
})
if (result.status !== 0) {
throw new SetupError(`docker compose exited with ${result.status}.`, [
`inspect what failed: ${theme.command(`docker compose -f ${composeFile} logs --tail 50`)}`,
`container status: ${theme.command(`docker compose -f ${composeFile} ps`)}`,
`clean slate: ${theme.command(`docker compose -f ${composeFile} down`)} then re-run the wizard`,
])
}
const spin = p.spinner()
spin.start('Waiting for Sim to come up (first run pulls images and migrates)…')
const appHealthy = await waitFor(
() => httpHealth('http://localhost:3000/api/health'),
300_000,
3000
)
const realtimeHealthy =
appHealthy && (await waitFor(() => httpHealth('http://localhost:3002/health'), 60_000, 2000))
if (!appHealthy || !realtimeHealthy) {
spin.stop(`${glyph.fail} services did not become healthy`)
throw new SetupError(
`${!appHealthy ? 'the app (:3000)' : 'realtime (:3002)'} never answered its health check.`,
[
`follow the logs: ${theme.command(`docker compose -f ${composeFile} logs -f`)}`,
'first boots on slow disks can exceed the wait — if containers are still starting, just wait and open http://localhost:3000',
]
)
}
spin.stop('App and realtime are healthy')
}
+171
View File
@@ -0,0 +1,171 @@
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 { resolveRedis } from '../redis.ts'
import {
collectSecrets,
promptCopilotKey,
promptEmail,
promptLlmKeys,
promptSecurity,
promptSignInProviders,
promptStorage,
promptUnlocks,
} from '../steps.ts'
import { glyph, theme } from '../theme.ts'
const APP_URL = 'http://localhost:3000'
/**
* 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? (only needed for multi-replica — single instance runs fine without it)',
initialValue: Boolean(existing),
})
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> = {}
const copilotKey = await promptCopilotKey(simAfter.vars.get('COPILOT_API_KEY'))
if (copilotKey) values.COPILOT_API_KEY = copilotKey
Object.assign(values, await promptLlmKeys(detection, !quick))
if (!quick) {
const redisUrl = await promptRedis(detection, simAfter.vars.get('REDIS_URL'))
if (redisUrl) {
values.REDIS_URL = redisUrl
writeEnvValues('realtime', { REDIS_URL: redisUrl })
}
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:minimal-registry',
label: 'Minimal block registry (recommended)',
hint: 'much lower memory — loads fewer integration blocks in dev',
},
{
value: 'dev:full',
label: 'Full registry',
hint: 'every block available — can use 4-5GB+ on its own',
},
],
initialValue: 'dev:full:minimal-registry',
})
}
return {
startNow: await p.confirm({
message: `Start Sim now? (bun run ${script})`,
initialValue: true,
}),
script,
}
}
+298
View File
@@ -0,0 +1,298 @@
import { spawnSync } from 'node:child_process'
import { getErrorMessage } from '@sim/utils/errors'
import type { Detection } from '../detect.ts'
import { ensureDocker } from '../docker.ts'
import { generateSecret, ROOT } from '../env-files.ts'
import { SetupError } from '../errors.ts'
import { waitFor } from '../probes.ts'
import * as p from '../prompter.ts'
import { glyph, theme } from '../theme.ts'
const RELEASE = 'sim-dev'
const NAMESPACE = 'sim-dev'
const LOCAL_CONTEXT_PREFIXES = ['kind-', 'docker-desktop', 'minikube', 'orbstack']
/**
* `input` is piped on stdin rather than passed as arguments — argv is readable
* by any process on the machine, so secrets must never travel that way.
*/
function run(command: string, args: string[], failMessage: string, input?: string): string {
// `helm upgrade --install ./helm/sim` uses chart paths relative to the repo
// root, so pin cwd regardless of where the wizard was invoked from.
const result = spawnSync(command, args, { encoding: 'utf8', input, cwd: ROOT })
if (result.status !== 0) {
throw new Error(`${failMessage}: ${result.stderr.trim() || result.stdout.trim()}`)
}
return result.stdout
}
function isLocalContext(context: string): boolean {
return LOCAL_CONTEXT_PREFIXES.some((prefix) => context === prefix || context.startsWith(prefix))
}
const LOCAL_SERVER_HOSTS = new Set([
'127.0.0.1',
'localhost',
'0.0.0.0',
'::1',
'kubernetes.docker.internal',
'host.docker.internal',
])
/** True when a context's API server is a loopback/host address — i.e. a local cluster. */
export function isLocalKubeContext(context: string): boolean {
const server = contextServerHost(context)
return server !== null && LOCAL_SERVER_HOSTS.has(server)
}
/**
* Liveness probe — a kubeconfig entry can outlive a stopped or deleted cluster
* (kind clusters are Docker containers that don't restart on their own), so a
* context looking local is no guarantee its API server answers.
*/
function clusterReachable(context: string): boolean {
return (
spawnSync('kubectl', ['cluster-info', '--context', context, '--request-timeout=5s'], {
stdio: 'ignore',
}).status === 0
)
}
/** The API server host a context points at, or null if kubectl can't resolve it. */
function contextServerHost(context: string): string | null {
const result = spawnSync(
'kubectl',
[
'config',
'view',
'--minify',
'--context',
context,
'-o',
'jsonpath={.clusters[0].cluster.server}',
],
{ encoding: 'utf8' }
)
if (result.status !== 0) return null
try {
return new URL(result.stdout.trim()).hostname
} catch {
return null
}
}
/**
* POSIX-quote a value for a copyable shell hint. A kube-context passes only a
* prefix check, so it can still contain whitespace or shell metacharacters that
* would break the `--context` argument (or run embedded syntax) when copied.
* Ordinary context names stay bare; only unsafe ones get single-quoted.
*/
function shq(value: string): string {
if (/^[A-Za-z0-9._/-]+$/.test(value)) return value
return `'${value.replace(/'/g, `'\\''`)}'`
}
async function ensureLocalContext(detection: Detection): Promise<string> {
if (!detection.binaries.helm || !detection.binaries.kubectl) {
throw new SetupError('kubernetes mode needs kubectl and helm on PATH.', [
`install them: ${theme.command('brew install kubectl helm')}`,
])
}
const context = detection.kubeContext
if (context && isLocalContext(context)) {
// The name is only a hint — a remote cluster can be named like a local one
// (e.g. "kind-prod"). Verify the API server is a loopback/host address before
// defaulting to "yes", so generated secrets can't silently ship to a remote
// cluster on a blind Enter.
const server = contextServerHost(context)
if (server && LOCAL_SERVER_HOSTS.has(server)) {
if (!clusterReachable(context)) {
// The context is local but its cluster isn't answering — stopped or
// deleted. Don't offer it (helm would just fail); fall through to the
// kind path, which starts a stopped "sim" cluster or creates one.
p.log.warn(
`Context "${context}" points at a local cluster that isn't responding — it looks stopped or deleted. The wizard will start or recreate a kind cluster instead.`
)
} else {
const useIt = await p.confirm({
message: `Use current kube context "${context}"?`,
initialValue: true,
})
if (useIt) return context
}
} else {
p.log.warn(
`Context "${context}" is named like a local cluster, but its API server${server ? ` (${server})` : ''} does not look local. Continuing would deploy the generated secrets there.`
)
const useIt = await p.confirm({
message: `Deploy to "${context}" anyway?`,
initialValue: false,
})
if (useIt) return context
}
} else if (context) {
p.log.warn(
`Current context "${context}" does not look like a local cluster. Deploying to remote clusters is not supported by the wizard yet — switch to a kind/docker-desktop context, or drive helm directly (see helm/sim/examples/values-production.yaml).`
)
}
if (!detection.binaries.kind) {
throw new SetupError('no local cluster available.', [
`install kind: ${theme.command('brew install kind')} — the wizard creates the cluster for you`,
'or enable Kubernetes in Docker Desktop settings, then re-run',
])
}
await ensureDocker(true)
const clusters = run('kind', ['get', 'clusters'], 'kind get clusters failed')
.trim()
.split('\n')
.filter(Boolean)
if (clusters.includes('sim')) {
run('kind', ['export', 'kubeconfig', '--name', 'sim'], 'kind export kubeconfig failed')
if (clusterReachable('kind-sim')) {
p.log.step('Reusing existing kind cluster "sim"')
} else {
// The cluster exists in kind but isn't answering — its node containers are
// stopped (a Docker/machine restart). Start them and wait for the API.
const spin = p.spinner()
spin.start('kind cluster "sim" is stopped — starting it…')
const nodes = run('kind', ['get', 'nodes', '--name', 'sim'], 'kind get nodes failed')
.trim()
.split('\n')
.filter(Boolean)
for (const node of nodes) spawnSync('docker', ['start', node], { stdio: 'ignore' })
const up = await waitFor(() => Promise.resolve(clusterReachable('kind-sim')), 60_000, 2000)
if (!up) {
spin.stop(`${glyph.fail} kind cluster "sim" would not start`)
throw new SetupError('the kind cluster "sim" exists but will not come up.', [
`inspect it: ${theme.command('docker ps -a --filter name=sim-control-plane')}`,
`recreate it: ${theme.command('kind delete cluster --name sim')}, then re-run ${theme.command('bun run setup')}`,
])
}
spin.stop('kind cluster "sim" started')
}
} else {
const spin = p.spinner()
spin.start('Creating kind cluster "sim"…')
run('kind', ['create', 'cluster', '--name', 'sim'], 'kind create cluster failed')
spin.stop('kind cluster "sim" ready')
}
return 'kind-sim'
}
function existingReleaseSecrets(context: string): Record<string, string> | null {
const scope = ['--kube-context', context, '-n', NAMESPACE]
const status = spawnSync('helm', ['status', RELEASE, ...scope], { stdio: 'ignore' })
if (status.status !== 0) return null
const values = JSON.parse(
run('helm', ['get', 'values', RELEASE, ...scope, '-o', 'json'], 'helm get values failed')
) as { app?: { env?: Record<string, string> }; postgresql?: { auth?: { password?: string } } }
const env = values.app?.env ?? {}
const password = values.postgresql?.auth?.password
if (
!env.BETTER_AUTH_SECRET ||
!env.ENCRYPTION_KEY ||
!env.INTERNAL_API_SECRET ||
!env.CRON_SECRET ||
!password
) {
return null
}
return {
BETTER_AUTH_SECRET: env.BETTER_AUTH_SECRET,
ENCRYPTION_KEY: env.ENCRYPTION_KEY,
INTERNAL_API_SECRET: env.INTERNAL_API_SECRET,
CRON_SECRET: env.CRON_SECRET,
POSTGRES_PASSWORD: password,
}
}
/**
* Values document piped to helm on stdin instead of `--set`. `JSON.stringify`
* quotes and escapes each value — JSON is a subset of YAML, so a secret
* containing `#`, `:`, or a leading `*` can neither break the document nor be
* reinterpreted as YAML syntax.
*/
function secretValues(secrets: Record<string, string>): string {
const { POSTGRES_PASSWORD, ...appEnv } = secrets
const env = Object.entries(appEnv)
.map(([key, value]) => ` ${key}: ${JSON.stringify(value)}`)
.join('\n')
return `app:\n env:\n${env}\npostgresql:\n auth:\n password: ${JSON.stringify(POSTGRES_PASSWORD)}\n`
}
export async function runK8sMode(detection: Detection): Promise<void> {
// Pin every subsequent call to the context we validated: the ambient context
// can change between detection and deploy, which would send generated
// credentials to an unintended cluster.
const context = await ensureLocalContext(detection)
const reused = existingReleaseSecrets(context)
const secrets = reused ?? {
BETTER_AUTH_SECRET: generateSecret(),
ENCRYPTION_KEY: generateSecret(),
INTERNAL_API_SECRET: generateSecret(),
CRON_SECRET: generateSecret(),
POSTGRES_PASSWORD: generateSecret().slice(0, 24),
}
if (reused) p.log.step('Reusing secrets from the existing release')
const spin = p.spinner()
spin.start('helm upgrade --install (first run pulls images — this can take several minutes)…')
try {
run(
'helm',
[
'upgrade',
'--install',
RELEASE,
'./helm/sim',
'--kube-context',
context,
'--namespace',
NAMESPACE,
'--create-namespace',
'--values',
'./helm/sim/examples/values-development.yaml',
'--values',
'-',
'--wait',
'--timeout',
'15m',
],
'helm upgrade --install failed',
secretValues(secrets)
)
} catch (error) {
spin.stop(`${glyph.fail} helm install failed`)
throw new SetupError(getErrorMessage(error), [
`pod status: ${theme.command(`kubectl --context ${shq(context)} -n ${NAMESPACE} get pods`)}`,
`stuck pods: ${theme.command(`kubectl --context ${shq(context)} -n ${NAMESPACE} describe pod <name> | tail -20`)}`,
'ImagePullBackOff on ghcr.io/simstudioai/* usually means the chart appVersion tag was never published — check Chart.yaml against ghcr',
])
}
spin.stop('Release deployed, all pods ready')
const testSpin = p.spinner()
testSpin.start('Running helm test…')
const test = spawnSync('helm', ['test', RELEASE, '--kube-context', context, '-n', NAMESPACE], {
encoding: 'utf8',
cwd: ROOT,
})
if (test.status !== 0) {
testSpin.stop(`${glyph.fail} helm test failed`)
throw new SetupError(`helm test failed:\n${test.stdout}${test.stderr}`, [
`pod status: ${theme.command(`kubectl --context ${shq(context)} -n ${NAMESPACE} get pods`)}`,
`app logs: ${theme.command(`kubectl --context ${shq(context)} -n ${NAMESPACE} logs deploy/${RELEASE}-app --tail 50`)}`,
])
}
testSpin.stop('helm test passed')
p.note(
[
`kubectl --context ${shq(context)} -n ${NAMESPACE} port-forward svc/${RELEASE}-app 3000:3000`,
`kubectl --context ${shq(context)} -n ${NAMESPACE} get pods`,
`helm uninstall ${RELEASE} --kube-context ${shq(context)} -n ${NAMESPACE} # tear down`,
].join('\n'),
'Reach your cluster'
)
}
+78
View File
@@ -0,0 +1,78 @@
import { type PortOwnerInfo, portOpen, portOwner } from './detect.ts'
import { waitFor } from './probes.ts'
import * as p from './prompter.ts'
import { theme } from './theme.ts'
interface BusyPort {
port: number
owner: PortOwnerInfo | null
}
function describe(busy: BusyPort): string {
return busy.owner
? `:${busy.port} is held by ${busy.owner.command} (pid ${busy.owner.pid})`
: `:${busy.port} is in use`
}
/**
* Resolve any listeners on `ports` before Sim tries to bind them. Loops offering
* to kill non-docker owners (or pointing at docker ones) until every port is
* free. Returns true when all are free, false if the user left them alone — the
* caller decides whether that's fatal (compose can't start) or just skips an
* optional auto-start (dev).
*/
export async function ensurePortsFree(ports: number[]): Promise<boolean> {
for (;;) {
const busy: BusyPort[] = []
for (const port of ports) {
if (await portOpen(port)) busy.push({ port, owner: portOwner(port) })
}
if (busy.length === 0) return true
p.log.warn(`Sim needs ports ${ports.join(' and ')}, but ${busy.map(describe).join(' and ')}.`)
const dockerOwned = busy.filter((b) => b.owner?.isDocker)
if (dockerOwned.length > 0) {
p.log.info(
theme.muted(
'A docker-published port means a container holds it — find it with `docker ps` and stop it.'
)
)
}
const killable = busy.filter((b) => b.owner && !b.owner.isDocker)
const options: p.SelectOption<'recheck' | 'kill' | 'abort'>[] = [
{ value: 'recheck', label: "I've stopped it — check again" },
]
if (killable.length > 0) {
options.push({
value: 'kill',
label: 'Kill it for me',
hint: killable.map((b) => `${b.owner?.command} on :${b.port}`).join(', '),
})
}
options.push({ value: 'abort', label: 'Leave the ports alone' })
const choice = await p.select({ message: 'How do you want to handle it?', options })
if (choice === 'abort') return false
if (choice === 'kill') {
for (const b of killable) {
if (b.owner) process.kill(b.owner.pid, 'SIGKILL')
}
p.log.step(
`Killed ${killable.map((b) => `${b.owner?.command} (pid ${b.owner?.pid})`).join(', ')}`
)
// SIGKILL is async — the kernel releases the listening socket a beat after
// the process dies, so re-checking immediately would still see the port
// held. Wait for the killed ports to actually free before looping.
await waitFor(
async () => {
for (const b of killable) {
if (await portOpen(b.port)) return false
}
return true
},
5000,
250
)
}
}
}
+112
View File
@@ -0,0 +1,112 @@
import { readFileSync } from 'node:fs'
import net from 'node:net'
import path from 'node:path'
import tls from 'node:tls'
import { getErrorMessage } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import postgres from 'postgres'
import { ROOT } from './env-files.ts'
export interface PgProbeResult {
ok: boolean
error?: string
pgvectorAvailable?: boolean
migrations?: { applied: number | null; journal: number }
}
function journalMigrationCount(): number {
const journalPath = path.join(ROOT, 'packages/db/migrations/meta/_journal.json')
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as { entries: unknown[] }
return journal.entries.length
}
export async function pgProbe(dsn: string): Promise<PgProbeResult> {
const sql = postgres(dsn, { max: 1, connect_timeout: 5, onnotice: () => {} })
try {
await sql`select 1`
const vector = await sql`select 1 from pg_available_extensions where name = 'vector'`
let applied: number | null = null
try {
const rows = await sql`select count(*)::int as n from drizzle.__drizzle_migrations`
applied = rows[0].n as number
} catch {
applied = null
}
return {
ok: true,
pgvectorAvailable: vector.length > 0,
migrations: { applied, journal: journalMigrationCount() },
}
} catch (error) {
return { ok: false, error: getErrorMessage(error, 'connection failed') }
} finally {
await sql.end({ timeout: 1 })
}
}
export function redisPing(url: string, timeoutMs = 2000): Promise<{ ok: boolean; error?: string }> {
return new Promise((resolve) => {
let parsed: URL
try {
parsed = new URL(url)
} catch {
resolve({ ok: false, error: 'invalid REDIS_URL' })
return
}
const port = Number(parsed.port || 6379)
const secure = parsed.protocol === 'rediss:'
const socket = secure
? tls.connect({
host: parsed.hostname,
port,
servername: process.env.REDIS_TLS_SERVERNAME || parsed.hostname,
})
: net.connect({ host: parsed.hostname, port })
let buffer = ''
const done = (result: { ok: boolean; error?: string }) => {
socket.destroy()
resolve(result)
}
socket.setTimeout(timeoutMs, () => done({ ok: false, error: 'timeout' }))
socket.once('error', (error) => done({ ok: false, error: getErrorMessage(error) }))
socket.once(secure ? 'secureConnect' : 'connect', () => {
const auth = parsed.password
? `AUTH ${parsed.username || ''} ${parsed.password}\r\n`.replace('AUTH ', 'AUTH ')
: ''
socket.write(`${auth}PING\r\n`)
})
socket.on('data', (chunk) => {
buffer += chunk.toString()
if (buffer.includes('+PONG')) done({ ok: true })
else if (
buffer.includes('-ERR') ||
buffer.includes('-NOAUTH') ||
buffer.includes('-WRONGPASS')
)
done({ ok: false, error: buffer.split('\r\n')[0] })
})
})
}
export async function httpHealth(url: string, timeoutMs = 3000): Promise<boolean> {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) })
return res.ok
} catch {
return false
}
}
/** Polls a probe until it succeeds or the window elapses. */
export async function waitFor(
probe: () => Promise<boolean>,
totalMs: number,
intervalMs = 2000
): Promise<boolean> {
const deadline = Date.now() + totalMs
while (Date.now() < deadline) {
if (await probe()) return true
await sleep(intervalMs)
}
return probe()
}
+108
View File
@@ -0,0 +1,108 @@
import * as clack from '@clack/prompts'
import { exitWith } from './terminal.ts'
import { isRich, theme } from './theme.ts'
const SPINNER_FRAMES = ['◐', '◓', '◑', '◒']
function guardCancel<T>(value: T | symbol): T {
if (clack.isCancel(value)) {
clack.cancel('Setup cancelled.')
exitWith(130)
}
return value as T
}
export interface SelectOption<T extends string> {
value: T
label: string
hint?: string
}
export async function select<T extends string>(params: {
message: string
options: SelectOption<T>[]
initialValue?: T
}): Promise<T> {
return guardCancel(
await clack.select({
message: isRich() ? theme.accent(params.message) : params.message,
options: params.options.map((o) => ({
...o,
hint: o.hint && isRich() ? theme.muted(o.hint) : o.hint,
})),
initialValue: params.initialValue,
})
)
}
export async function multiselect<T extends string>(params: {
message: string
options: SelectOption<T>[]
initialValues?: T[]
}): Promise<T[]> {
return guardCancel(
await clack.multiselect({
message: isRich() ? theme.accent(params.message) : params.message,
options: params.options,
initialValues: params.initialValues,
required: false,
})
)
}
export async function text(params: {
message: string
placeholder?: string
initialValue?: string
defaultValue?: string
validate?: (value: string) => string | undefined
}): Promise<string> {
return guardCancel(
await clack.text({
message: isRich() ? theme.accent(params.message) : params.message,
placeholder: params.placeholder,
initialValue: params.initialValue,
defaultValue: params.defaultValue,
validate: params.validate,
})
)
}
export async function password(params: {
message: string
validate?: (value: string) => string | undefined
}): Promise<string> {
return guardCancel(
await clack.password({
message: isRich() ? theme.accent(params.message) : params.message,
validate: params.validate,
})
)
}
export async function confirm(params: {
message: string
initialValue?: boolean
}): Promise<boolean> {
return guardCancel(
await clack.confirm({
message: isRich() ? theme.accent(params.message) : params.message,
initialValue: params.initialValue,
})
)
}
export function spinner() {
if (!isRich() || !process.stdout.isTTY) return clack.spinner()
return clack.spinner({
frames: SPINNER_FRAMES,
delay: 90,
})
}
export function note(message: string, title?: string): void {
clack.note(message, title && isRich() ? theme.heading(title) : title)
}
export const outro = clack.outro
export const log = clack.log
+139
View File
@@ -0,0 +1,139 @@
import { spawnSync } from 'node:child_process'
import { docker } from './db.ts'
import { type Detection, REDIS_CONTAINER } from './detect.ts'
import { ensureDocker } from './docker.ts'
import { SetupError } from './errors.ts'
import { redisPing, waitFor } from './probes.ts'
import * as p from './prompter.ts'
import { glyph, theme } from './theme.ts'
const LOCAL_URL = 'redis://localhost:6379'
/**
* Host port the managed container actually publishes. `startManagedRedis` falls
* back to 6380 when 6379 is taken, so assuming the default adopts the wrong
* Redis — or fails the ping and then collides on the container name.
*/
function managedRedisUrl(): string | null {
const result = spawnSync('docker', ['port', REDIS_CONTAINER, '6379/tcp'], { encoding: 'utf8' })
if (result.status !== 0) return null
const port = result.stdout.trim().split('\n')[0]?.split(':').pop()
return port ? `redis://localhost:${port}` : null
}
async function pingWithSpinner(url: string, label: string): Promise<boolean> {
const spin = p.spinner()
spin.start(label)
const ping = await redisPing(url)
spin.stop(ping.ok ? 'Redis reachable' : `${glyph.warn} ${ping.error}`)
return ping.ok
}
async function startManagedRedis(detection: Detection): Promise<string> {
const hostPort = detection.redisPortOpen ? 6380 : 6379
const url = `redis://localhost:${hostPort}`
// A prior sim-redis (unhealthy, or bound to a stale port) would collide on the
// name. Unlike Postgres this container has no data volume, so removing it
// loses nothing — no prompt needed. `rm -f` is a no-op when none exists.
spawnSync('docker', ['rm', '-f', REDIS_CONTAINER], { stdio: 'ignore' })
docker([
'run',
'-d',
'--name',
REDIS_CONTAINER,
'--label',
'managed-by=sim-setup',
'-p',
`${hostPort}:6379`,
'redis:7-alpine',
])
const spin = p.spinner()
spin.start(`Starting ${REDIS_CONTAINER} container on :${hostPort}…`)
const healthy = await waitFor(async () => (await redisPing(url)).ok, 30_000, 1000)
spin.stop(
healthy
? `Redis running in ${REDIS_CONTAINER} on :${hostPort}`
: `${glyph.fail} container did not become healthy`
)
if (!healthy) {
throw new SetupError('the Redis container failed to start.', [
`inspect: ${theme.command(`docker logs ${REDIS_CONTAINER}`)}`,
`remove and retry: ${theme.command(`docker rm -f ${REDIS_CONTAINER}`)} then re-run the wizard`,
])
}
return url
}
async function promptRedisUrl(existing?: string): Promise<string> {
const url = await p.text({
message: 'REDIS_URL',
initialValue: existing,
placeholder: LOCAL_URL,
validate: (value) => (value ? undefined : 'required'),
})
if (!(await pingWithSpinner(url, 'Pinging Redis…'))) {
throw new SetupError(`Redis at ${url} is not answering.`, [
'fix the URL and re-run, or skip Redis — single-instance runs fine without it',
])
}
return url
}
/**
* Redis ladder, mirroring the Postgres one: reuse what's running (with
* consent), restart/start a wizard-managed container, or take a URL.
*/
export async function resolveRedis(detection: Detection, existing?: string): Promise<string> {
// A configured REDIS_URL wins over the port scan: it may point at a non-default
// port, or at a host that isn't this machine at all.
if (
existing &&
existing !== LOCAL_URL &&
(await pingWithSpinner(existing, `Pinging ${existing}…`))
) {
const keep = await p.confirm({ message: `Keep using ${existing}?`, initialValue: true })
if (keep) return existing
}
if (
detection.redisPortOpen &&
(await pingWithSpinner(LOCAL_URL, 'Redis found on :6379 — pinging…'))
) {
const adopt = await p.confirm({
message: 'Use the Redis already running on localhost:6379?',
initialValue: true,
})
if (adopt) return LOCAL_URL
}
if (detection.redisContainer?.managed) {
if (detection.redisContainer.state === 'stopped') docker(['start', REDIS_CONTAINER])
// Read the published port back rather than assuming the default.
const managedUrl = managedRedisUrl()
if (
managedUrl &&
(await pingWithSpinner(managedUrl, `Starting existing ${REDIS_CONTAINER} container…`))
) {
return managedUrl
}
p.log.warn(
`${REDIS_CONTAINER} exists but is not answering — starting a fresh one will replace it (it holds no data).`
)
}
const dockerAvailable = await ensureDocker(false)
const options: p.SelectOption<'container' | 'url'>[] = []
if (dockerAvailable) {
options.push({
value: 'container',
label: 'Start a Redis container for me',
hint: `redis:7-alpine, named ${REDIS_CONTAINER} — recommended`,
})
}
options.push({ value: 'url', label: 'Use an existing Redis', hint: 'paste a redis:// URL' })
if (!dockerAvailable) {
p.log.warn('Docker is not available, so the wizard cannot manage a Redis container for you.')
}
const choice = await p.select({ message: 'Where should Redis live?', options })
return choice === 'container' ? startManagedRedis(detection) : promptRedisUrl(existing)
}
+363
View File
@@ -0,0 +1,363 @@
import { browserKeyFlow } from './cli-auth.ts'
import type { Detection } from './detect.ts'
import {
type EnvFile,
generateSecret,
isPlaceholder,
isTruthy,
isUsableSecret,
SECRET_KEYS,
secretRequirement,
} from './env-files.ts'
import * as p from './prompter.ts'
import { link, theme } from './theme.ts'
import { FLAG_TWINS, hasMailProvider, LOGIN_PROVIDERS, SELF_HOST_UNLOCKS } from './twins.ts'
/** Reuses existing valid secrets (never regenerates them) and generates the rest. */
export function collectSecrets(existing: EnvFile): Record<string, string> {
const secrets: Record<string, string> = {}
const generated: string[] = []
const replaced: string[] = []
for (const key of SECRET_KEYS) {
const current = existing.vars.get(key)
if (current && isUsableSecret(key, current)) {
secrets[key] = current
} else {
secrets[key] = generateSecret()
// A key the app would reject never successfully encrypted anything, so
// replacing it cannot orphan existing ciphertext.
if (current && !isPlaceholder(current)) replaced.push(key)
else generated.push(key)
}
}
if (replaced.length > 0) {
const detail = replaced.map((key) => `${key} (${secretRequirement(key)})`).join(', ')
p.log.warn(`Replaced ${detail} — the app rejects the existing value at runtime.`)
}
if (generated.length > 0) {
p.log.step(`Generated ${generated.join(', ')}`)
}
return secrets
}
export async function promptCopilotKey(existing?: string): Promise<string | null> {
if (existing) {
const keep = await p.confirm({
message: 'COPILOT_API_KEY is already set — keep it?',
initialValue: true,
})
if (keep) return existing
}
p.log.info('Chat is how you talk to Sim — build and manage everything in natural language.')
const wants = await p.confirm({
message: 'Generate your Chat API key in the browser?',
initialValue: true,
})
if (!wants) {
p.log.info(theme.muted('Skipping — Chat stays disabled until COPILOT_API_KEY is set.'))
return null
}
const key = await browserKeyFlow(process.env.SIM_CLI_AUTH_ORIGIN ?? 'https://www.sim.ai')
if (!key) {
p.log.warn('No key received — re-run bun run setup to retry, or set COPILOT_API_KEY yourself.')
return null
}
return key
}
export async function promptLlmKeys(
detection: Detection,
custom: boolean
): Promise<Record<string, string>> {
const values: Record<string, string> = {}
if (detection.shellLlmKeys.length > 0) {
const adopt = await p.multiselect({
message: 'Found LLM API keys in your shell — copy into apps/sim/.env?',
options: detection.shellLlmKeys.map((key) => ({ value: key, label: key })),
initialValues: detection.shellLlmKeys,
})
for (const key of adopt) {
const value = process.env[key]
if (!value) throw new Error(`${key} disappeared from the environment mid-run`)
values[key] = value
}
}
if (detection.ollamaReachable) {
const useOllama = await p.confirm({
message: 'Ollama is running on :11434 — wire it up for local models?',
initialValue: true,
})
if (useOllama) values.OLLAMA_URL = 'http://localhost:11434'
}
if (custom && Object.keys(values).length === 0 && detection.shellLlmKeys.length === 0) {
p.log.info(
theme.muted('No LLM keys configured — you can add keys per-workspace in the UI later (BYOK).')
)
}
return values
}
type StorageBackend = 'local' | 's3' | 's3compat' | 'azure' | 'gcs'
function detectStorageBackend(vars: Map<string, string>): StorageBackend {
if (vars.get('AZURE_CONNECTION_STRING') || vars.get('AZURE_ACCOUNT_NAME')) return 'azure'
if (vars.get('S3_ENDPOINT')) return 's3compat'
if (vars.get('S3_BUCKET_NAME') || vars.get('AWS_REGION')) return 's3'
if (vars.get('GCS_BUCKET_NAME')) return 'gcs'
return 'local'
}
async function required(message: string, initialValue?: string): Promise<string> {
return p.text({ message, initialValue, validate: (v) => (v ? undefined : 'required') })
}
/**
* Custom-flow storage step. Local disk is the default; a cloud backend is
* strongly recommended for containerized deployments (uploads are ephemeral
* there). Returns the env vars for the chosen backend, or null to keep local.
*/
export async function promptStorage(
vars: Map<string, string>,
containerized: boolean
): Promise<Record<string, string> | null> {
const current = detectStorageBackend(vars)
const backend = await p.select<StorageBackend>({
message: 'File storage?',
options: [
{
value: 'local',
label: 'Local disk',
hint: containerized
? 'files live in the container — LOST on restart; fine only for evaluation'
: 'fine for local dev (external-fetch flows like Instagram publish need cloud storage)',
},
{ value: 's3', label: 'AWS S3', hint: 'region + bucket; keys optional with IAM/IRSA' },
{
value: 's3compat',
label: 'S3-compatible (R2, MinIO, B2)',
hint: 'custom endpoint — fully self-hostable with MinIO',
},
{ value: 'azure', label: 'Azure Blob', hint: 'connection string or account name + key' },
{
value: 'gcs',
label: 'Google Cloud Storage',
hint: 'bucket; credentials via ADC by default',
},
],
initialValue: current,
})
if (backend === 'local') return null
const values: Record<string, string> = {}
if (backend === 's3' || backend === 's3compat') {
if (backend === 's3compat') {
values.S3_ENDPOINT = await required(
'S3_ENDPOINT (e.g. https://<account>.r2.cloudflarestorage.com)',
vars.get('S3_ENDPOINT')
)
const pathStyle = await p.confirm({
message: 'Force path-style addressing? (required for MinIO/Ceph, not for R2)',
initialValue: false,
})
if (pathStyle) values.S3_FORCE_PATH_STYLE = 'true'
}
values.AWS_REGION = await required(
'AWS_REGION',
vars.get('AWS_REGION') ?? (backend === 's3compat' ? 'auto' : undefined)
)
values.S3_BUCKET_NAME = await required('S3_BUCKET_NAME', vars.get('S3_BUCKET_NAME'))
const accessKey = await p.password({
message: 'AWS_ACCESS_KEY_ID (empty = IAM/instance credential chain)',
})
if (accessKey) {
values.AWS_ACCESS_KEY_ID = accessKey
values.AWS_SECRET_ACCESS_KEY = await p.password({
message: 'AWS_SECRET_ACCESS_KEY',
validate: (v) => (v ? undefined : 'required when an access key id is set'),
})
}
} else if (backend === 'azure') {
const connectionString = await p.password({
message: 'AZURE_CONNECTION_STRING (empty = use account name + key)',
})
if (connectionString) {
values.AZURE_CONNECTION_STRING = connectionString
} else {
values.AZURE_ACCOUNT_NAME = await required(
'AZURE_ACCOUNT_NAME',
vars.get('AZURE_ACCOUNT_NAME')
)
values.AZURE_ACCOUNT_KEY = await p.password({
message: 'AZURE_ACCOUNT_KEY',
validate: (v) => (v ? undefined : 'required'),
})
}
values.AZURE_STORAGE_CONTAINER_NAME = await required(
'AZURE_STORAGE_CONTAINER_NAME',
vars.get('AZURE_STORAGE_CONTAINER_NAME') ?? 'sim-files'
)
} else {
values.GCS_BUCKET_NAME = await required('GCS_BUCKET_NAME', vars.get('GCS_BUCKET_NAME'))
p.log.info(
theme.muted(
'Credentials use Application Default Credentials unless GCS_CREDENTIALS_JSON is set.'
)
)
}
return values
}
const PROVIDER_CONSOLES: Record<string, string> = {
google: 'https://console.cloud.google.com/apis/credentials',
github: 'https://github.com/settings/developers',
microsoft: 'https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationsListBlade',
}
/** Sign-in providers step: credentials in, exact redirect URIs out. */
export async function promptSignInProviders(
vars: Map<string, string>,
appUrl: string
): Promise<Record<string, string>> {
const configured = LOGIN_PROVIDERS.filter((prov) => vars.get(prov.idKey)).map((prov) => prov.id)
const wanted = await p.multiselect({
message: 'Social sign-in providers? (email/password login works without any)',
options: LOGIN_PROVIDERS.map((prov) => ({
value: prov.id,
label: prov.label,
hint: configured.includes(prov.id) ? 'already configured' : undefined,
})),
initialValues: configured,
})
const values: Record<string, string> = {}
for (const id of wanted) {
const provider = LOGIN_PROVIDERS.find((prov) => prov.id === id)
if (!provider) throw new Error(`unknown provider ${id}`)
p.log.info(
`${provider.label}: create an OAuth app at ${link(PROVIDER_CONSOLES[id], PROVIDER_CONSOLES[id])}\n Redirect URI: ${theme.command(`${appUrl}/api/auth/callback/${id}`)}`
)
values[provider.idKey] = await p.text({
message: provider.idKey,
initialValue: vars.get(provider.idKey),
validate: (v) => (v ? undefined : 'required'),
})
values[provider.secretKey] = await p.password({
message: provider.secretKey,
validate: (v) => (v ? undefined : 'required'),
})
}
return values
}
/** Email step: console logging is the default; MailHog is the one-tap local option. */
export async function promptEmail(vars: Map<string, string>): Promise<Record<string, string>> {
const choice = await p.select({
message: 'Email sending?',
options: [
{
value: 'console',
label: 'None',
hint: 'emails are logged to the console — fine for local',
},
{ value: 'mailhog', label: 'MailHog (local)', hint: 'wires SMTP to localhost:1025' },
{ value: 'resend', label: 'Resend', hint: 'paste an API key' },
{ value: 'smtp', label: 'SMTP', hint: 'any SMTP relay' },
],
initialValue: hasMailProvider(vars) ? (vars.get('SMTP_HOST') ? 'smtp' : 'resend') : 'console',
})
if (choice === 'console') return {}
if (choice === 'mailhog') return { SMTP_HOST: 'localhost', SMTP_PORT: '1025' }
if (choice === 'resend') {
return {
RESEND_API_KEY: await p.password({
message: 'RESEND_API_KEY',
validate: (v) => (v ? undefined : 'required'),
}),
}
}
const values: Record<string, string> = {
SMTP_HOST: await p.text({
message: 'SMTP_HOST',
initialValue: vars.get('SMTP_HOST'),
validate: (v) => (v ? undefined : 'required'),
}),
SMTP_PORT: await p.text({ message: 'SMTP_PORT', initialValue: vars.get('SMTP_PORT') ?? '587' }),
}
const user = await p.text({
message: 'SMTP_USER (empty for unauthenticated relays)',
defaultValue: '',
})
if (user) {
values.SMTP_USER = user
values.SMTP_PASS = await p.password({ message: 'SMTP_PASS' })
}
return values
}
export interface SecurityStepResult {
sim: Record<string, string>
mirrorToRealtime: Record<string, string>
}
/** Auth loosening + admin key. DISABLE_AUTH must reach BOTH env files. */
export async function promptSecurity(vars: Map<string, string>): Promise<SecurityStepResult> {
const sim: Record<string, string> = {}
const mirrorToRealtime: Record<string, string> = {}
const disableAuth = await p.confirm({
message: 'Disable auth entirely? (anonymous access — ONLY for a private network)',
initialValue: isTruthy(vars.get('DISABLE_AUTH')),
})
if (disableAuth) {
p.log.warn('Anyone who can reach this instance has full access. Never expose it publicly.')
sim.DISABLE_AUTH = 'true'
mirrorToRealtime.DISABLE_AUTH = 'true'
}
const privateHosts = await p.confirm({
message:
'Allow DB/connector tools to reach private hosts? (Docker/K8s service names, localhost — loosens the SSRF guard)',
initialValue: isTruthy(vars.get('ALLOW_PRIVATE_DATABASE_HOSTS')),
})
if (privateHosts) sim.ALLOW_PRIVATE_DATABASE_HOSTS = 'true'
const existingAdminKey = vars.get('ADMIN_API_KEY')
if (!existingAdminKey || isPlaceholder(existingAdminKey)) {
const wantsAdmin = await p.confirm({
message: 'Generate an ADMIN_API_KEY? (enables the admin API for workflow export/import)',
initialValue: false,
})
if (wantsAdmin) {
sim.ADMIN_API_KEY = generateSecret()
p.log.step('Generated ADMIN_API_KEY')
}
}
return { sim, mirrorToRealtime }
}
/** Self-host feature unlocks — always writes BOTH members of each twin pair. */
export async function promptUnlocks(vars: Map<string, string>): Promise<Record<string, string>> {
const selected = await p.multiselect({
message: 'Unlock self-host features? (bypasses hosted plan gating)',
options: SELF_HOST_UNLOCKS.map((unlock) => ({
value: unlock.server,
label: unlock.label,
hint: unlock.hint || undefined,
})),
initialValues: SELF_HOST_UNLOCKS.filter((u) => isTruthy(vars.get(u.server))).map(
(u) => u.server
),
})
if (selected.length === 0) return {}
const flags = new Set(selected)
if (flags.has('ACCESS_CONTROL_ENABLED') && !flags.has('ORGANIZATIONS_ENABLED')) {
flags.add('ORGANIZATIONS_ENABLED')
p.log.info(theme.muted('Access control requires organizations — enabling both.'))
}
const values: Record<string, string> = {}
for (const server of flags) {
values[server] = 'true'
const twin = FLAG_TWINS.find((pair) => pair.server === server)
if (twin) values[twin.client] = 'true'
}
return values
}
+14
View File
@@ -0,0 +1,14 @@
/** Resets colors, shows the cursor, and disables raw mode — called on every exit path. */
export function restoreTerminal(): void {
if (process.stdout.isTTY) {
process.stdout.write('\x1b[0m\x1b[?25h')
}
if (process.stdin.isTTY && process.stdin.isRaw) {
process.stdin.setRawMode(false)
}
}
export function exitWith(code: number): never {
restoreTerminal()
process.exit(code)
}
+28
View File
@@ -0,0 +1,28 @@
import chalk from 'chalk'
const BRAND = '#e6e6e6'
export const isRich = () => chalk.level > 0
export const theme = {
accent: chalk.hex(BRAND),
heading: chalk.bold.hex(BRAND),
muted: chalk.hex('#8a8f98'),
success: chalk.hex('#33c482'),
warn: chalk.hex('#ffb020'),
error: chalk.hex('#e23d2d'),
command: chalk.hex(BRAND),
}
export const glyph = {
pass: theme.success('✓'),
warn: theme.warn('!'),
fail: theme.error('✗'),
skip: theme.muted('○'),
} as const
/** OSC 8 clickable terminal hyperlink; plain text when not a rich TTY. */
export function link(label: string, url: string): string {
if (!isRich() || !process.stdout.isTTY) return `${label} (${url})`
return `\x1b]8;;${url}\x1b\\${chalk.underline.hex(BRAND)(label)}\x1b]8;;\x1b\\`
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Server/client feature-flag pairs that must be set together — server code
* reads the bare var, the browser bundle reads the NEXT_PUBLIC_ twin
* (apps/sim/lib/core/config/env-flags.ts documents each). Note the one
* mismatched pair: DEPLOY_AS_BLOCK ↔ NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED.
*/
export const FLAG_TWINS: ReadonlyArray<{ server: string; client: string }> = [
{ server: 'BILLING_ENABLED', client: 'NEXT_PUBLIC_BILLING_ENABLED' },
{ server: 'ACCESS_CONTROL_ENABLED', client: 'NEXT_PUBLIC_ACCESS_CONTROL_ENABLED' },
{ server: 'ORGANIZATIONS_ENABLED', client: 'NEXT_PUBLIC_ORGANIZATIONS_ENABLED' },
{ server: 'WHITELABELING_ENABLED', client: 'NEXT_PUBLIC_WHITELABELING_ENABLED' },
{ server: 'AUDIT_LOGS_ENABLED', client: 'NEXT_PUBLIC_AUDIT_LOGS_ENABLED' },
{ server: 'DATA_RETENTION_ENABLED', client: 'NEXT_PUBLIC_DATA_RETENTION_ENABLED' },
{ server: 'DATA_DRAINS_ENABLED', client: 'NEXT_PUBLIC_DATA_DRAINS_ENABLED' },
{ server: 'FORKING_ENABLED', client: 'NEXT_PUBLIC_FORKING_ENABLED' },
{ server: 'INBOX_ENABLED', client: 'NEXT_PUBLIC_INBOX_ENABLED' },
{ server: 'DISABLE_INVITATIONS', client: 'NEXT_PUBLIC_DISABLE_INVITATIONS' },
{ server: 'DISABLE_PUBLIC_API', client: 'NEXT_PUBLIC_DISABLE_PUBLIC_API' },
{ server: 'SSO_ENABLED', client: 'NEXT_PUBLIC_SSO_ENABLED' },
{ server: 'EMAIL_PASSWORD_SIGNUP_ENABLED', client: 'NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED' },
{ server: 'E2B_ENABLED', client: 'NEXT_PUBLIC_E2B_ENABLED' },
{ server: 'DEPLOY_AS_BLOCK', client: 'NEXT_PUBLIC_CUSTOM_BLOCKS_ENABLED' },
]
/** Self-host feature unlocks offered by the wizard's Custom flow. */
export const SELF_HOST_UNLOCKS: ReadonlyArray<{ server: string; label: string; hint: string }> = [
{
server: 'ACCESS_CONTROL_ENABLED',
label: 'Access control',
hint: 'permission groups (implies organizations)',
},
{ server: 'ORGANIZATIONS_ENABLED', label: 'Organizations', hint: 'multi-workspace orgs' },
{ server: 'AUDIT_LOGS_ENABLED', label: 'Audit logs', hint: '' },
{ server: 'DATA_RETENTION_ENABLED', label: 'Data retention', hint: 'retention policies' },
{ server: 'DATA_DRAINS_ENABLED', label: 'Data drains', hint: 'export streams' },
{ server: 'FORKING_ENABLED', label: 'Workflow forking', hint: '' },
{ server: 'INBOX_ENABLED', label: 'Inbox', hint: '' },
{ server: 'WHITELABELING_ENABLED', label: 'Whitelabeling', hint: 'custom branding' },
{
server: 'DEPLOY_AS_BLOCK',
label: 'Deploy as block',
hint: 'publish workflows as reusable blocks',
},
]
const MAIL_PROVIDER_KEYS = [
'RESEND_API_KEY',
'AWS_SES_REGION',
'SMTP_HOST',
'AZURE_ACS_CONNECTION_STRING',
'GMAIL_CREDENTIALS_JSON',
] as const
export function hasMailProvider(vars: Map<string, string>): boolean {
return MAIL_PROVIDER_KEYS.some((key) => vars.get(key))
}
export const LOGIN_PROVIDERS = [
{ id: 'google', label: 'Google', idKey: 'GOOGLE_CLIENT_ID', secretKey: 'GOOGLE_CLIENT_SECRET' },
{ id: 'github', label: 'GitHub', idKey: 'GITHUB_CLIENT_ID', secretKey: 'GITHUB_CLIENT_SECRET' },
{
id: 'microsoft',
label: 'Microsoft',
idKey: 'MICROSOFT_CLIENT_ID',
secretKey: 'MICROSOFT_CLIENT_SECRET',
},
] as const
+193
View File
@@ -0,0 +1,193 @@
import { spawnSync } from 'node:child_process'
import { showBanner } from './banner.ts'
import { loadCheckContext, runChecks } from './checks.ts'
import { type Detection, runDetection } from './detect.ts'
import { archiveEnvFile, ROOT } from './env-files.ts'
import { runComposeMode } from './modes/compose.ts'
import { runDevMode } from './modes/dev.ts'
import { runK8sMode } from './modes/k8s.ts'
import { ensurePortsFree } from './ports.ts'
import * as p from './prompter.ts'
import { glyph, theme } from './theme.ts'
export type WizardMode = 'compose' | 'dev' | 'k8s'
export interface WizardFlags {
quick: boolean
mode?: WizardMode
}
async function handleExistingConfig(detection: Detection): Promise<'continue' | 'doctor'> {
// Root counts: a compose install writes only `.env`, so excluding it made a
// configured machine look unconfigured and silently re-run from scratch.
const present = Object.entries(detection.envFiles)
.filter(([, exists]) => exists)
.map(([target]) => (target === 'root' ? '.env' : `${target}/.env`))
if (present.length === 0) return 'continue'
const choice = await p.select({
message: `Found existing config (${present.join(', ')}) — what should we do?`,
options: [
{ value: 'keep', label: 'Keep it', hint: 'run doctor against the current setup and exit' },
{
value: 'review',
label: 'Review and update',
hint: 'walk the wizard with current values prefilled',
},
{ value: 'reset', label: 'Reset', hint: 'archive current .env files and start fresh' },
],
initialValue: 'keep',
})
if (choice === 'keep') return 'doctor'
if (choice === 'reset') {
for (const target of ['sim', 'realtime', 'db', 'root'] as const) {
const backup = archiveEnvFile(target)
if (backup) p.log.step(`Archived ${backup}`)
}
}
return 'continue'
}
const LOW_DOCKER_MEM_GB = 6
const LOW_DISK_GB = 15
async function selectMode(detection: Detection, flags: WizardFlags): Promise<WizardMode> {
if (flags.mode) return flags.mode
const { dockerMemGb } = detection.specs
const vm = dockerMemGb !== null ? ` · VM ${dockerMemGb}GB` : ''
const composeState = detection.dockerRunning ? `Docker ready${vm}` : 'Docker is NOT running'
const k8sState = detection.kubeContext
? `context: ${detection.kubeContext}${vm}`
: detection.binaries.kind
? `kind available${vm}`
: 'needs kind or Docker Desktop k8s'
return p.select({
message: 'How do you want to run Sim?',
options: [
{
value: 'compose',
label: 'Docker Compose',
// Self-host or just try Sim without touching the code.
hint: `Run a bundled Sim — self-hosting or evaluating · ${composeState}`,
},
{
value: 'dev',
label: 'Local dev (bun run dev:full)',
// Iterate on the source with hot reload.
hint: 'Work on Sim itself — contributing · app :3000 + realtime :3002',
},
{
value: 'k8s',
label: 'Kubernetes (helm)',
// Rehearse a real cluster deploy on kind / Docker Desktop.
hint: `Test a production-style deploy — self-hosting on k8s · ${k8sState}`,
},
],
initialValue: detection.dockerRunning ? 'compose' : 'dev',
})
}
/** Spec warnings before committing to a mode — informed choice, no silent degradation. */
function warnLowSpecs(detection: Detection, mode: WizardMode): void {
const { dockerMemGb, freeDiskGb } = detection.specs
if (
(mode === 'compose' || mode === 'k8s') &&
dockerMemGb !== null &&
dockerMemGb < LOW_DOCKER_MEM_GB
) {
p.log.warn(
`Docker's VM has only ${dockerMemGb}GB of memory${mode === 'k8s' ? ' — pods will likely sit Pending or OOM' : ' — containers may OOM'}. Raise it in Docker Desktop → Settings → Resources (8GB+ recommended).`
)
}
if ((mode === 'compose' || mode === 'k8s') && freeDiskGb !== null && freeDiskGb < LOW_DISK_GB) {
p.log.warn(`Only ${freeDiskGb}GB of free disk — image pulls need 5-10GB.`)
}
}
async function finalVerify(): Promise<void> {
const findings = await runChecks(loadCheckContext(true), ['live'])
for (const finding of findings) {
console.log(` ${glyph[finding.status]} ${finding.message}`)
}
}
export async function runWizard(flags: WizardFlags): Promise<void> {
// Detection is ~600ms of subprocess work and the banner is ~600ms of animation
// with nothing to do — overlap them so the spinner below usually resolves at once.
const detecting = runDetection()
await showBanner()
const spin = p.spinner()
spin.start('Looking at what you already have…')
const detection = await detecting
spin.stop(
`Detected: docker ${detection.dockerRunning ? '✓' : '✗'} · postgres ${detection.postgresPortOpen ? '✓' : '✗'} · ` +
`${detection.shellLlmKeys.length} shell LLM key${detection.shellLlmKeys.length === 1 ? '' : 's'}` +
(detection.kubeContext ? ` · kube: ${detection.kubeContext}` : '')
)
if ((await handleExistingConfig(detection)) === 'doctor') {
const { runDoctor } = await import('./doctor.ts')
process.exitCode = await runDoctor({ fix: false, json: false })
return
}
const quick =
flags.quick ||
(await p.select({
message: 'Setup style?',
options: [
{ value: 'quick', label: 'Quick', hint: 'sensible defaults, minimal questions' },
{
value: 'custom',
label: 'Custom',
hint: 'every option: redis, trigger.dev, image variants',
},
],
initialValue: 'quick',
})) === 'quick'
const mode = await selectMode(detection, flags)
warnLowSpecs(detection, mode)
let startDevNow = false
let devScript = 'dev:full'
if (mode === 'compose') await runComposeMode(detection, quick)
else if (mode === 'dev') {
const dev = await runDevMode(detection, quick)
startDevNow = dev.startNow
devScript = dev.script
} else await runK8sMode(detection)
if (mode !== 'k8s' && !startDevNow) await finalVerify()
const url = 'http://localhost:3000'
p.note(
[
mode === 'k8s' ? `port-forward, then open ${url}` : `open ${url}`,
'manage it: bun run sim start · stop · status · logs',
'check your setup: bun run sim doctor',
mode === 'dev' && !startDevNow ? `start Sim: bun run ${devScript}` : null,
`prefer a bare "sim"? ${theme.command('bun link')} once (needs ~/.bun/bin on PATH)`,
]
.filter(Boolean)
.join('\n'),
'Next steps'
)
p.outro(theme.accent('Sim is ready.'))
if (mode === 'compose' && process.platform === 'darwin') {
spawnSync('open', [url], { stdio: 'ignore' })
}
if (startDevNow) {
// dev:full binds 3000 (app) and 3002 (realtime) — resolve any conflict
// (e.g. another worktree's dev server) before starting, instead of spawning
// a server that fails to bind. If the user leaves the ports, skip the start.
if (await ensurePortsFree([3000, 3002])) {
const child = spawnSync('bun', ['run', devScript], { cwd: ROOT, stdio: 'inherit' })
process.exitCode = child.status ?? 0
} else {
p.log.warn(
`Ports still in use — Sim wasn't started. Free them, then run ${theme.command(`bun run ${devScript}`)}.`
)
}
}
}