mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Executable
+93
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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\\`
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}`)}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user