From dcd57332b913339ad0301578884dc0f002be098e Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 12:33:14 -0700 Subject: [PATCH 01/14] feat(compose,setup): bundle Redis, always configure it, fix lifecycle detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose shipped no redis service at all — REDIS_URL was ${REDIS_URL:-} in both app and realtime, so every self-hosted stack ran without it. Storage silently falls back to PostgreSQL, but the pub/sub channels (live Chat task-status, table events) have no fallback, so live updates never arrived. - compose (prod + local): add a redis:7-alpine service with a healthcheck, default REDIS_URL to redis://redis:6379, and make app/realtime depend on it being healthy. Not published to the host — only the containers need it, and binding 6379 would collide with a local Redis. An external REDIS_URL in root .env still overrides. Deliberately not written into root .env: doctor pings REDIS_URL from the host, and a compose-internal hostname would fail that probe the same way DATABASE_URL would. - dev mode: configure Redis in quick too. Quick uses a new non-interactive ensureRedis (adopt whatever answers, else start the managed container, warn only if Docker is unavailable); custom keeps the ladder, with corrected copy — the old prompt claimed Redis was only for multi-replica. - lifecycle: detect compose stacks via 'docker compose ls' instead of probing '-f ps' in the working directory. Compose derives the project name from the directory it was started in, so the old probe found a stack only when run from the checkout that launched it (a globally linked sim never could) and listed the same stack once per candidate file. compose ls reports the real project and its config file, so one stack yields one install from anywhere; non-Sim projects are filtered by compose filename. Every compose op now runs in that stack's directory. - lifecycle: distinguish 'Docker unreachable' from 'nothing installed'. With the daemon down, status reported containers as 'absent' and suggested re-running setup; it now says Docker is down and marks state unknown. --- docker-compose.local.yml | 22 +++++++- docker-compose.prod.yml | 22 +++++++- scripts/setup/lifecycle.ts | 109 ++++++++++++++++++++++++++++++------- scripts/setup/modes/dev.ts | 24 +++++--- scripts/setup/redis.ts | 31 +++++++++++ 5 files changed, 175 insertions(+), 33 deletions(-) diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 8bb153c0f25..981c74514fb 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -21,7 +21,7 @@ services: - ENCRYPTION_KEY=${ENCRYPTION_KEY:-dev-encryption-key-at-least-32-chars} - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:-dev-internal-api-secret-min-32-chars} - - REDIS_URL=${REDIS_URL:-} + - REDIS_URL=${REDIS_URL:-redis://redis:6379} - COPILOT_API_KEY=${COPILOT_API_KEY} - SIM_AGENT_API_URL=${SIM_AGENT_API_URL} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} @@ -30,6 +30,8 @@ services: depends_on: db: condition: service_healthy + redis: + condition: service_healthy migrations: condition: service_completed_successfully realtime: @@ -55,10 +57,12 @@ services: - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000} - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:-dev-secret-at-least-32-characters-long} - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:-dev-internal-api-secret-min-32-chars} - - REDIS_URL=${REDIS_URL:-} + - REDIS_URL=${REDIS_URL:-redis://redis:6379} depends_on: db: condition: service_healthy + redis: + condition: service_healthy restart: unless-stopped ports: - '3002:3002' @@ -86,6 +90,20 @@ services: command: ['bun', 'run', 'db:migrate'] restart: 'no' + # Backs pub/sub (live Chat task-status and table events) and the shared caches. + # The app falls back to PostgreSQL for storage when REDIS_URL is unset, but the + # pub/sub channels have no fallback — without Redis, live status never streams. + # Not published to the host: only the app and realtime containers need it, and + # binding 6379 would collide with a Redis already running locally. + redis: + image: redis:7-alpine + restart: always + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 5s + retries: 5 + db: image: pgvector/pgvector:pg17 restart: always diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 785864517cc..dc968197ebb 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -30,7 +30,7 @@ services: - ENCRYPTION_KEY=${ENCRYPTION_KEY} - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} - INTERNAL_API_SECRET=${INTERNAL_API_SECRET} - - REDIS_URL=${REDIS_URL:-} + - REDIS_URL=${REDIS_URL:-redis://redis:6379} - COPILOT_API_KEY=${COPILOT_API_KEY} - SIM_AGENT_API_URL=${SIM_AGENT_API_URL} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} @@ -44,6 +44,8 @@ services: depends_on: db: condition: service_healthy + redis: + condition: service_healthy migrations: condition: service_completed_successfully realtime: @@ -74,10 +76,12 @@ services: - BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000} - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET} - INTERNAL_API_SECRET=${INTERNAL_API_SECRET} - - REDIS_URL=${REDIS_URL:-} + - REDIS_URL=${REDIS_URL:-redis://redis:6379} depends_on: db: condition: service_healthy + redis: + condition: service_healthy healthcheck: test: ['CMD', 'curl', '-fsS', 'http://127.0.0.1:3002/health'] interval: 90s @@ -96,6 +100,20 @@ services: command: ['bun', 'run', 'db:migrate'] restart: 'no' + # Backs pub/sub (live Chat task-status and table events) and the shared caches. + # The app falls back to PostgreSQL for storage when REDIS_URL is unset, but the + # pub/sub channels have no fallback — without Redis, live status never streams. + # Not published to the host: only the app and realtime containers need it, and + # binding 6379 would collide with a Redis already running locally. + redis: + image: redis:7-alpine + restart: unless-stopped + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 5s + timeout: 5s + retries: 5 + db: image: pgvector/pgvector:pg17 restart: unless-stopped diff --git a/scripts/setup/lifecycle.ts b/scripts/setup/lifecycle.ts index 05e083609a5..a0d1e3e20d6 100644 --- a/scripts/setup/lifecycle.ts +++ b/scripts/setup/lifecycle.ts @@ -1,4 +1,5 @@ import { spawnSync } from 'node:child_process' +import path from 'node:path' import { DB_CONTAINER, type Detection, REDIS_CONTAINER, runDetection } from './detect.ts' import { archiveEnvFile, ROOT } from './env-files.ts' import { SetupError } from './errors.ts' @@ -38,20 +39,25 @@ function shq(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } +/** Is the Docker daemon reachable? Distinguishes "nothing installed" from "can't see". */ +function dockerReachable(): boolean { + return spawnSync('docker', ['info'], { stdio: 'ignore' }).status === 0 +} + /** 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' }) +function dockerText(args: string[], cwd: string = ROOT): string | null { + const result = spawnSync('docker', args, { cwd, 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 +function dockerInherit(args: string[], cwd: string = ROOT): number { + return spawnSync('docker', args, { cwd, 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' }) +function dockerRun(args: string[], failMessage: string, cwd: string = ROOT): void { + const result = spawnSync('docker', args, { cwd, encoding: 'utf8' }) if (result.status !== 0) { throw new SetupError(`${failMessage}: ${result.stderr.trim() || result.stdout.trim()}`) } @@ -59,7 +65,11 @@ function dockerRun(args: string[], failMessage: string): void { interface ComposeInstall { kind: 'compose' + /** Absolute path to the compose file Docker recorded for the project. */ file: string + /** Directory the stack was brought up from — every compose op runs here. */ + dir: string + project: string } interface DevInstall { kind: 'dev' @@ -74,15 +84,48 @@ interface K8sInstall { } type Install = ComposeInstall | DevInstall | K8sInstall +interface ComposeProject { + Name: string + Status: string + ConfigFiles: string +} + /** - * 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. + * Ask Docker which compose projects exist rather than guessing from the working + * directory. Compose derives a project name from the directory it was started + * in, so probing `-f ps` only ever finds a stack when you happen to stand + * in the checkout that launched it — a globally linked `sim` would never see one + * — and it reports the same stack once per candidate file, since both files map + * to the same directory-derived project. `compose ls` records the real project + * and the exact config file, so one running stack yields exactly one install + * wherever it was started from. Projects whose compose file isn't one of ours + * (a devcontainer, an unrelated app) are filtered out by filename. */ function composeInstalls(): ComposeInstall[] { - return COMPOSE_FILES.filter((file) => dockerText(['compose', '-f', file, 'ps', '-aq'])).map( - (file) => ({ kind: 'compose', file }) - ) + const raw = dockerText(['compose', 'ls', '-a', '--format', 'json']) + if (!raw) return [] + let projects: ComposeProject[] + try { + projects = JSON.parse(raw) + } catch { + return [] + } + const installs: ComposeInstall[] = [] + for (const project of projects) { + // ConfigFiles is a comma-separated list when a stack was started with -f more than once. + const file = (project.ConfigFiles ?? '') + .split(',') + .map((entry) => entry.trim()) + .find((entry) => (COMPOSE_FILES as readonly string[]).includes(path.basename(entry))) + if (!file) continue + installs.push({ + kind: 'compose', + file, + dir: path.dirname(file), + project: project.Name, + }) + } + return installs } /** Dev mode owns the split env files and, usually, the managed Postgres/Redis. */ @@ -124,7 +167,8 @@ function detectInstalls(detection: Detection): Install[] { } function describeInstall(install: Install): string { - if (install.kind === 'compose') return `Docker Compose (${install.file})` + if (install.kind === 'compose') + return `Docker Compose (project ${install.project} in ${install.dir})` 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. @@ -165,7 +209,7 @@ 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') + dockerRun(['compose', '-f', install.file, 'up', '-d'], 'docker compose up failed', install.dir) spin.stop('Containers up') p.note( [`open ${APP_URL}`, 'follow logs: sim logs', 'stop: sim stop'].join('\n'), @@ -190,7 +234,7 @@ 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') + dockerRun(['compose', '-f', install.file, 'stop'], 'docker compose stop failed', install.dir) spin.stop('Containers stopped (data kept)') p.note(['start again: sim start', 'remove: sim down'].join('\n'), 'Stopped') return @@ -220,7 +264,11 @@ 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') + dockerRun( + ['compose', '-f', install.file, 'restart'], + 'docker compose restart failed', + install.dir + ) spin.stop('Containers restarted') p.note(`open ${APP_URL}`, 'Running') return @@ -237,7 +285,7 @@ function restart(install: Install): void { function showLogs(install: Install): void { if (install.kind === 'compose') { - dockerInherit(['compose', '-f', install.file, 'logs', '-f', '--tail', '100']) + dockerInherit(['compose', '-f', install.file, 'logs', '-f', '--tail', '100'], install.dir) return } if (install.kind === 'dev') { @@ -268,7 +316,7 @@ async function down(install: Install): Promise { return } if (install.kind === 'compose') { - dockerRun(['compose', '-f', install.file, 'down'], 'docker compose down failed') + dockerRun(['compose', '-f', install.file, 'down'], 'docker compose down failed', install.dir) p.log.step('Containers removed (volumes kept)') return } @@ -311,7 +359,11 @@ async function reset(install: Install | null): Promise { if (backup) p.log.step(`Archived ${backup}`) } if (install?.kind === 'compose') { - dockerRun(['compose', '-f', install.file, 'down', '-v'], 'docker compose down -v failed') + dockerRun( + ['compose', '-f', install.file, 'down', '-v'], + 'docker compose down -v failed', + install.dir + ) p.log.step('Containers and volumes removed') } else if (install?.kind === 'dev') { const names = managedNames(install) @@ -343,14 +395,29 @@ async function reset(install: Install | null): Promise { async function status(): Promise { const detection = await runDetection() const installs = detectInstalls(detection) + const docker = dockerReachable() console.log(`\n${theme.heading('◆ Sim status')}\n`) + // Every container probe goes through Docker, so when the daemon is down the + // honest answer is "unknown", not "absent" — and a compose stack is invisible + // entirely. Saying "no install detected" there sends the user to re-run setup + // for what is really a stopped Docker Desktop. + if (!docker) { + console.log( + ` ${glyph.warn} Docker is not reachable — container and Compose state below is unknown.` + ) + console.log(` ${theme.muted('start Docker Desktop (or OrbStack), then re-run this.')}\n`) + } if (installs.length === 0) { - console.log(` ${glyph.warn} No Sim install detected — run ${theme.command('sim setup')}.`) + console.log( + docker + ? ` ${glyph.warn} No Sim install detected — run ${theme.command('sim setup')}.` + : ` ${glyph.warn} No install detected, but that may just be Docker being down.` + ) return } for (const install of installs) console.log(` ${glyph.pass} ${describeInstall(install)}`) const containerState = (state: { state: 'running' | 'stopped' } | null) => - state ? state.state : 'absent' + docker ? (state ? state.state : 'absent') : 'unknown (docker down)' console.log() console.log(` postgres (${DB_CONTAINER}): ${containerState(detection.dbContainer)}`) console.log(` redis (${REDIS_CONTAINER}): ${containerState(detection.redisContainer)}`) diff --git a/scripts/setup/modes/dev.ts b/scripts/setup/modes/dev.ts index 4bc19d04b0b..306a009a039 100644 --- a/scripts/setup/modes/dev.ts +++ b/scripts/setup/modes/dev.ts @@ -7,7 +7,7 @@ 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 { ensureRedis, resolveRedis } from '../redis.ts' import { collectSecrets, promptCopilotKey, @@ -62,8 +62,8 @@ async function runMigrations(dsn: string): Promise { async function promptRedis(detection: Detection, existing?: string): Promise { const wants = await p.confirm({ message: - 'Configure Redis? (only needed for multi-replica — single instance runs fine without it)', - initialValue: Boolean(existing), + 'Configure Redis? (powers live Chat status and table events; storage falls back to Postgres)', + initialValue: true, }) if (!wants) return null return resolveRedis(detection, existing) @@ -121,12 +121,20 @@ export async function runDevMode( if (copilotKey) values.COPILOT_API_KEY = copilotKey Object.assign(values, await promptLlmKeys(detection, !quick)) + // Redis is set up in every mode, quick included. Storage falls back to + // PostgreSQL without it, but the pub/sub channels (live Chat task-status, + // table events) have no fallback — skipping it silently produces an install + // where live updates never arrive. Quick configures it with no questions at + // all; custom keeps the opt-out and the where-should-it-live ladder. + const redisUrl = quick + ? await ensureRedis(detection, simAfter.vars.get('REDIS_URL')) + : await promptRedis(detection, simAfter.vars.get('REDIS_URL')) + if (redisUrl) { + values.REDIS_URL = redisUrl + writeEnvValues('realtime', { REDIS_URL: redisUrl }) + } + if (!quick) { - const 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) diff --git a/scripts/setup/redis.ts b/scripts/setup/redis.ts index 59ee661f643..f8257448ef6 100644 --- a/scripts/setup/redis.ts +++ b/scripts/setup/redis.ts @@ -79,6 +79,37 @@ async function promptRedisUrl(existing?: string): Promise { return url } +/** + * Non-interactive Redis for quick mode: adopt whatever already answers, else + * start the managed container. Quick's contract is sensible defaults with no + * questions, and Redis is not optional enough to skip — pub/sub (live Chat + * status, table events) has no PostgreSQL fallback. Returns null only when + * nothing answers and Docker is unavailable, so the caller can warn instead of + * failing the whole setup. + */ +export async function ensureRedis(detection: Detection, existing?: string): Promise { + if (existing && (await redisPing(existing)).ok) return existing + if (detection.redisPortOpen && (await redisPing(LOCAL_URL)).ok) { + p.log.step(`Using the Redis already on :6379`) + return LOCAL_URL + } + if (detection.redisContainer?.managed) { + if (detection.redisContainer.state === 'stopped') docker(['start', REDIS_CONTAINER]) + const managedUrl = managedRedisUrl() + if (managedUrl && (await waitFor(async () => (await redisPing(managedUrl)).ok, 15_000, 500))) { + p.log.step(`Reusing ${REDIS_CONTAINER}`) + return managedUrl + } + } + if (!(await ensureDocker(false))) { + p.log.warn( + 'Docker is unavailable, so Redis was not configured — live Chat status and table events will not stream.' + ) + return null + } + return startManagedRedis(detection) +} + /** * Redis ladder, mirroring the Postgres one: reuse what's running (with * consent), restart/start a wizard-managed container, or take a URL. From 47fcf931b54eda30d714954e567c90c1d769d4e7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 12:43:25 -0700 Subject: [PATCH 02/14] fix(setup): don't start managed Postgres with a password the volume will ignore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POSTGRES_PASSWORD only applies when initdb runs on an empty data directory. The sim-postgres-data volume outlives its container (sim down keeps it, docker rm keeps it, and the wizard's own recreate path keeps it), and inspectManagedContainer recovers the password from the *container*, not the volume — so once the container is gone the password is unrecoverable. Setup then generated a fresh password and ran against the initialized volume. Postgres kept its original password and rejected every connection with 'password authentication failed for user postgres', which surfaced as a misleading 'container did not become healthy'. Detect an already-bootstrapped volume (PG_VERSION present) before choosing a password, and ask: supply the existing password, or delete the volume and start fresh (double-confirmed, since that destroys data). Refusing both fails with the exact docker volume rm command instead of looping. --- scripts/setup/db.ts | 91 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 88 insertions(+), 3 deletions(-) diff --git a/scripts/setup/db.ts b/scripts/setup/db.ts index a4b0a2f25d5..a195b425ea5 100644 --- a/scripts/setup/db.ts +++ b/scripts/setup/db.ts @@ -122,6 +122,81 @@ async function promptExternalDsn(): Promise { } } +const DB_VOLUME = 'sim-postgres-data' + +/** True once initdb has run in the volume — PG_VERSION only exists after bootstrap. */ +function volumeInitialized(): boolean { + if (spawnSync('docker', ['volume', 'inspect', DB_VOLUME], { stdio: 'ignore' }).status !== 0) { + return false + } + // Read the marker from inside the volume; the image is already local, so this + // costs nothing extra and beats assuming "volume exists" means "bootstrapped" + // (a failed first run leaves an empty volume behind). + return ( + spawnSync( + 'docker', + [ + 'run', + '--rm', + '-v', + `${DB_VOLUME}:/pgdata`, + '--entrypoint', + 'test', + 'pgvector/pgvector:pg17', + '-f', + '/pgdata/PG_VERSION', + ], + { stdio: 'ignore' } + ).status === 0 + ) +} + +/** + * The volume already holds a cluster whose password we cannot read back. Either + * the user supplies it, or the data goes — silently generating a new password + * would produce a container that never authenticates. + */ +async function resolveExistingVolume(): Promise { + p.log.warn( + `The ${DB_VOLUME} volume already contains a database, but its password is not recoverable — Postgres ignores POSTGRES_PASSWORD on an existing data directory.` + ) + const choice = await p.select({ + message: 'How should the wizard proceed?', + options: [ + { + value: 'password', + label: 'Keep the data — I have its password', + hint: 'from a previous .env, or your notes', + }, + { + value: 'wipe', + label: 'Delete the old data and start fresh', + hint: `removes the ${DB_VOLUME} volume — this cannot be undone`, + }, + ], + initialValue: 'password', + }) + if (choice === 'password') { + return p.password({ + message: `Password for the existing ${DB_VOLUME} database`, + validate: (value) => (value ? undefined : 'required'), + }) + } + const sure = await p.confirm({ + message: theme.error(`Permanently delete the ${DB_VOLUME} volume and all its data?`), + initialValue: false, + }) + if (!sure) { + throw new SetupError('kept the existing database volume, so setup cannot continue.', [ + 're-run and supply the password, or remove it yourself:', + theme.command(`docker volume rm ${DB_VOLUME}`), + ]) + } + docker(['volume', 'rm', DB_VOLUME]) + p.log.step(`Removed ${DB_VOLUME}`) + return generateSecret().slice(0, 24) +} + /** * Provisions the managed container, reconciling with one that already exists * rather than colliding on the name. Recreating is always an explicit choice — @@ -142,14 +217,24 @@ async function startManagedContainer(detection: Detection): Promise { 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`, + `start clean: ${theme.command(`docker volume rm ${DB_VOLUME}`)} drops its data too`, ]) } docker(['rm', '-f', DB_CONTAINER]) } - const password = generateSecret().slice(0, 24) const hostPort = detection.postgresPortOpen ? 5433 : 5432 + // POSTGRES_PASSWORD only applies when initdb runs on an empty data directory. + // The volume outlives the container (sim down keeps it, so does `docker rm`), + // so once the container is gone the password it was created with is + // unrecoverable — inspectManagedContainer reads it from the container, not the + // volume. Running with a freshly generated password against an initialized + // volume starts a healthy Postgres that rejects every connection with + // "password authentication failed", which surfaces as a bogus "container did + // not become healthy". Ask instead of guessing. + const password = volumeInitialized() + ? await resolveExistingVolume() + : generateSecret().slice(0, 24) const dsn = `postgresql://postgres:${password}@localhost:${hostPort}/simstudio` docker([ 'run', @@ -159,7 +244,7 @@ async function startManagedContainer(detection: Detection): Promise { '--label', 'managed-by=sim-setup', '-v', - 'sim-postgres-data:/var/lib/postgresql/data', + `${DB_VOLUME}:/var/lib/postgresql/data`, '-e', `POSTGRES_PASSWORD=${password}`, '-e', From 2243cc5f173ac2f88bc48ef81475dbb4d8a8d348 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 12:52:31 -0700 Subject: [PATCH 03/14] improvement(setup): default to Docker Compose and sharpen the run-mode copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose was listed first but only preselected when Docker happened to be running — with Docker stopped the cursor sat on 'Local dev', steering people toward a source checkout when they wanted to run Sim. Compose mode calls ensureDocker(true), which offers to start Docker Desktop, so a stopped daemon is no reason to change the default. Also tightens the hints to say what each mode is for: run bundled Sim (fastest way to start), work on Sim itself, test a production-style k8s deploy. --- scripts/setup/wizard.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/scripts/setup/wizard.ts b/scripts/setup/wizard.ts index 5e052fabe1a..fd62580c87f 100644 --- a/scripts/setup/wizard.ts +++ b/scripts/setup/wizard.ts @@ -66,23 +66,23 @@ async function selectMode(detection: Detection, flags: WizardFlags): Promise Date: Sat, 25 Jul 2026 12:59:12 -0700 Subject: [PATCH 04/14] fix(compose): point the browser socket at :3002 so it stops reconnecting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stack publishes the app on 3000 and realtime on 3002 with no reverse proxy between them, but NEXT_PUBLIC_SOCKET_URL defaulted to empty — which tells the browser client to use the page origin. :3000/socket.io answers 308 (a Next redirect), not a Socket.IO handshake, so the client failed and retried forever. Default it to http://localhost:3002; a proxied deployment overrides it (or sets it empty to use the page origin). Also give COPILOT_API_KEY and SIM_AGENT_API_URL empty defaults so every compose command stops printing 'variable is not set' warnings. The app already falls back to the prod copilot backend when SIM_AGENT_API_URL is blank. --- docker-compose.local.yml | 9 ++++++--- docker-compose.prod.yml | 17 ++++++++++------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 981c74514fb..eb9c31926b3 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -22,11 +22,14 @@ services: - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:-dev-internal-api-secret-min-32-chars} - REDIS_URL=${REDIS_URL:-redis://redis:6379} - - COPILOT_API_KEY=${COPILOT_API_KEY} - - SIM_AGENT_API_URL=${SIM_AGENT_API_URL} + - COPILOT_API_KEY=${COPILOT_API_KEY:-} + - SIM_AGENT_API_URL=${SIM_AGENT_API_URL:-} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - SOCKET_SERVER_URL=${SOCKET_SERVER_URL:-http://realtime:3002} - - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} + # Published on 3002 with no proxy in front, so the browser must target it + # directly; empty would fall back to the page origin, where /socket.io + # answers 308 and the socket reconnects forever. + - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002} depends_on: db: condition: service_healthy diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index dc968197ebb..de979c6ebb1 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -31,15 +31,18 @@ services: - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} - INTERNAL_API_SECRET=${INTERNAL_API_SECRET} - REDIS_URL=${REDIS_URL:-redis://redis:6379} - - COPILOT_API_KEY=${COPILOT_API_KEY} - - SIM_AGENT_API_URL=${SIM_AGENT_API_URL} + - COPILOT_API_KEY=${COPILOT_API_KEY:-} + - SIM_AGENT_API_URL=${SIM_AGENT_API_URL:-} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - SOCKET_SERVER_URL=${SOCKET_SERVER_URL:-http://realtime:3002} - # NEXT_PUBLIC_SOCKET_URL is read by the browser. Leaving it unset lets the - # client default to the page's own origin (assumes the reverse proxy routes - # /socket.io). Set it explicitly only when the realtime service is on a - # different host:port from the app (e.g. wss://socket.example.com). - - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} + # NEXT_PUBLIC_SOCKET_URL is read by the browser. This stack publishes the + # app on 3000 and realtime on 3002 with no reverse proxy between them, so + # it must point at 3002 — left empty the client falls back to the page + # origin, where /socket.io answers 308 instead of a handshake and the + # socket reconnects forever. Override when a proxy fronts both on one + # origin (set it to that origin, or empty to use the page origin), or when + # realtime is elsewhere (e.g. wss://socket.example.com). + - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002} - ADMISSION_GATE_MAX_INFLIGHT=${ADMISSION_GATE_MAX_INFLIGHT:-500} depends_on: db: From 6eec21045499db6b3dbc86bf4d8cc6f864c8643a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 13:04:58 -0700 Subject: [PATCH 05/14] feat(setup): pass SIM_AGENT_API_URL through, and warn on a half-set mothership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sim devs testing against a non-prod mothership export SIM_CLI_AUTH_ORIGIN so the Chat key is minted there, but nothing carried the matching backend URL into the install — the app kept defaulting to prod copilot, which rejects a staging key with 'Invalid API key'. Persist SIM_AGENT_API_URL when it is exported, so later docker compose up / dev runs stay on that backend instead of reverting to prod once the shell is gone: SIM_CLI_AUTH_ORIGIN=https://www.staging.sim.ai \ SIM_AGENT_API_URL=https://www.staging.copilot.sim.ai \ bun run setup Setting only the auth origin is the trap, so that combination warns. Neither set is the self-hoster default and stays silent — no prompts, no flags. --- scripts/setup/modes/compose.ts | 2 ++ scripts/setup/modes/dev.ts | 2 ++ scripts/setup/steps.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/scripts/setup/modes/compose.ts b/scripts/setup/modes/compose.ts index 3c3c8f09f92..a807439f115 100644 --- a/scripts/setup/modes/compose.ts +++ b/scripts/setup/modes/compose.ts @@ -8,6 +8,7 @@ import { httpHealth, waitFor } from '../probes.ts' import * as p from '../prompter.ts' import { collectSecrets, + mothershipOverride, promptCopilotKey, promptEmail, promptLlmKeys, @@ -60,6 +61,7 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom const copilotKey = await promptCopilotKey(root.vars.get('COPILOT_API_KEY')) if (copilotKey) values.COPILOT_API_KEY = copilotKey Object.assign(values, await promptLlmKeys(detection, !quick)) + Object.assign(values, mothershipOverride()) if (!quick) { const storage = await promptStorage(root.vars, true) if (storage) Object.assign(values, storage) diff --git a/scripts/setup/modes/dev.ts b/scripts/setup/modes/dev.ts index 306a009a039..d3d0a66bfae 100644 --- a/scripts/setup/modes/dev.ts +++ b/scripts/setup/modes/dev.ts @@ -10,6 +10,7 @@ import * as p from '../prompter.ts' import { ensureRedis, resolveRedis } from '../redis.ts' import { collectSecrets, + mothershipOverride, promptCopilotKey, promptEmail, promptLlmKeys, @@ -120,6 +121,7 @@ export async function runDevMode( const copilotKey = await promptCopilotKey(simAfter.vars.get('COPILOT_API_KEY')) if (copilotKey) values.COPILOT_API_KEY = copilotKey Object.assign(values, await promptLlmKeys(detection, !quick)) + Object.assign(values, mothershipOverride()) // Redis is set up in every mode, quick included. Storage falls back to // PostgreSQL without it, but the pub/sub channels (live Chat task-status, diff --git a/scripts/setup/steps.ts b/scripts/setup/steps.ts index 4c7015210b9..584188af70b 100644 --- a/scripts/setup/steps.ts +++ b/scripts/setup/steps.ts @@ -65,6 +65,32 @@ export async function promptCopilotKey(existing?: string): Promise { + const agentUrl = process.env.SIM_AGENT_API_URL + const authOrigin = process.env.SIM_CLI_AUTH_ORIGIN + if (authOrigin && !agentUrl) { + p.log.warn( + `SIM_CLI_AUTH_ORIGIN points the Chat key handoff at ${authOrigin}, but SIM_AGENT_API_URL is unset — the app will validate that key against production and reject it. Set both, or neither.` + ) + } + if (!agentUrl) return {} + p.log.step(`Using mothership ${agentUrl} (SIM_AGENT_API_URL)`) + return { SIM_AGENT_API_URL: agentUrl } +} + export async function promptLlmKeys( detection: Detection, custom: boolean From 54aadbf703da8737439443e45bfc2fe47ab533ce Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 13:10:28 -0700 Subject: [PATCH 06/14] fix(setup): survive a vanished port owner, and stop flagging our own containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two failures from one compose re-run: - 'Kill it for me' crashed setup with 'kill() failed: ESRCH: No such process'. The owner list is an lsof snapshot, so the process can exit before the signal lands — which is the outcome we wanted, not an error. ESRCH now counts as freed, EPERM warns that it must be stopped by hand, and anything else warns; the loop re-probes either way instead of aborting a setup that had already written .env. - Compose mode demanded 3000/3002 be free even when this stack was the one holding them, so re-running setup against a running install reported its own realtime container as a blocker and offered to kill Docker's listener. 'docker compose up -d' reconciles its own containers, so skip the check when the project already has some. A foreign process is still caught, and a foreign container still surfaces as a bind error from compose. --- scripts/setup/modes/compose.ts | 14 ++++++++++++++ scripts/setup/ports.ts | 27 +++++++++++++++++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/scripts/setup/modes/compose.ts b/scripts/setup/modes/compose.ts index a807439f115..7e18f92a9ab 100644 --- a/scripts/setup/modes/compose.ts +++ b/scripts/setup/modes/compose.ts @@ -25,6 +25,20 @@ import { glyph, theme } from '../theme.ts' * is fatal here: compose can't come up while the ports are held. */ async function ensureComposePortsFree(composeFile: string): Promise { + // This stack already holding 3000/3002 is not a conflict — `docker compose up + // -d` reconciles its own containers. Without this, re-running setup against a + // running install reports its own realtime container as a blocker and offers + // to kill Docker's listener, which is never the right move. A genuinely + // foreign process still gets caught below, and a foreign *container* surfaces + // as a clear bind error from compose itself. + const ours = spawnSync('docker', ['compose', '-f', composeFile, 'ps', '-q'], { + cwd: ROOT, + encoding: 'utf8', + }) + if (ours.status === 0 && ours.stdout.trim()) { + p.log.step('Existing Sim containers hold :3000/:3002 — compose will reconcile them') + return + } 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')}`, diff --git a/scripts/setup/ports.ts b/scripts/setup/ports.ts index 39811811814..78b4659e1fb 100644 --- a/scripts/setup/ports.ts +++ b/scripts/setup/ports.ts @@ -1,3 +1,4 @@ +import { getErrorMessage } from '@sim/utils/errors' import { type PortOwnerInfo, portOpen, portOwner } from './detect.ts' import { waitFor } from './probes.ts' import * as p from './prompter.ts' @@ -54,12 +55,30 @@ export async function ensurePortsFree(ports: number[]): Promise { if (choice === 'abort') return false if (choice === 'kill') { + const killed: string[] = [] for (const b of killable) { - if (b.owner) process.kill(b.owner.pid, 'SIGKILL') + if (!b.owner) continue + const label = `${b.owner.command} (pid ${b.owner.pid})` + try { + process.kill(b.owner.pid, 'SIGKILL') + killed.push(label) + } catch (error) { + // The owner list came from an lsof snapshot, so the process may have + // exited in between — that is the outcome we wanted, not a failure. + // A signal we're not allowed to send is worth saying out loud, but + // never fatal: the loop re-probes and offers the choice again. + const code = (error as NodeJS.ErrnoException).code + if (code === 'ESRCH') killed.push(`${label} — already gone`) + else if (code === 'EPERM') { + p.log.warn( + `Not allowed to kill ${label} — stop it yourself, then choose "check again".` + ) + } else { + p.log.warn(`Could not kill ${label}: ${getErrorMessage(error)}`) + } + } } - p.log.step( - `Killed ${killable.map((b) => `${b.owner?.command} (pid ${b.owner?.pid})`).join(', ')}` - ) + if (killed.length > 0) p.log.step(`Killed ${killed.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. From a28f49fcfc9dbd9bb053eabfb8bf7fcee4f14ba0 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 13:21:11 -0700 Subject: [PATCH 07/14] fix(csp,setup): permit the socket origin the client actually uses; encode DSN passwords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round on #5964: - The socket reconnect was a CSP bug, not a URL bug. getSocketUrl() already falls back to localhost:3002 for a localhost page, but generateRuntimeCSP gated that same fallback on isDev — and compose runs NODE_ENV=production, so connect-src omitted ws://localhost:3002 and the browser blocked the handshake. Key the fallback on the app URL being localhost instead, mirroring getSocketUrl. Revert the compose NEXT_PUBLIC_SOCKET_URL default: an explicit value suppresses the page-origin fallback that reverse-proxied self-hosts depend on, and ':-' treats empty as unset so the documented escape hatch could not work either. LOCALHOST_HOSTNAMES is duplicated locally because csp.ts is loaded by next.config.ts before @/ aliases resolve. - Percent-encode the password when building the Postgres DSN. A user-supplied password containing @ : / # does not merely re-parse to the wrong host — it fails to parse as a URL at all, so a correct password surfaced as a connection failure. - Tell 'Postgres rejected this password' apart from 'Postgres never started'. On the keep-the-volume path a wrong password left a healthy server and the old generic 'container did not become healthy' error, which is the confusion this change set exists to remove. Adds a CSP regression test for the unset-socket-URL production case; verified it fails against the previous condition. --- .../core/security/csp-socket-fallback.test.ts | 46 +++++++++++++++++++ apps/sim/lib/core/security/csp.ts | 27 ++++++++++- docker-compose.local.yml | 5 +- docker-compose.prod.yml | 14 +++--- scripts/setup/db.ts | 44 ++++++++++++++++-- 5 files changed, 120 insertions(+), 16 deletions(-) create mode 100644 apps/sim/lib/core/security/csp-socket-fallback.test.ts diff --git a/apps/sim/lib/core/security/csp-socket-fallback.test.ts b/apps/sim/lib/core/security/csp-socket-fallback.test.ts new file mode 100644 index 00000000000..407c1dd61f2 --- /dev/null +++ b/apps/sim/lib/core/security/csp-socket-fallback.test.ts @@ -0,0 +1,46 @@ +/** + * @vitest-environment node + * + * The bundled docker-compose stack runs NODE_ENV=production, serves the app from + * localhost, and leaves NEXT_PUBLIC_SOCKET_URL unset. getSocketUrl() falls back + * to localhost:3002 for a localhost page regardless of NODE_ENV, so the CSP has + * to permit that origin or the browser blocks the handshake and Socket.IO + * retries forever. Its own file because vi.mock is hoisted per-module and the + * sibling suite needs NEXT_PUBLIC_SOCKET_URL set. + */ +import { createEnvMock } from '@sim/testing' +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/config/env', () => + createEnvMock({ + NEXT_PUBLIC_APP_URL: 'http://localhost:3000', + NEXT_PUBLIC_SOCKET_URL: undefined, + }) +) + +vi.mock('@/lib/core/config/env-flags', () => ({ + isDev: false, + isHosted: false, + isReactGrabEnabled: false, +})) + +import { generateRuntimeCSP } from './csp' + +describe('generateRuntimeCSP — socket fallback on a localhost origin', () => { + it('permits the default socket origin when NEXT_PUBLIC_SOCKET_URL is unset', () => { + const csp = generateRuntimeCSP() + + expect(csp).toContain('http://localhost:3002') + expect(csp).toContain('ws://localhost:3002') + }) + + it('keeps the socket sources inside connect-src', () => { + const connectSrc = generateRuntimeCSP() + .split(';') + .map((directive) => directive.trim()) + .find((directive) => directive.startsWith('connect-src')) + + expect(connectSrc).toBeDefined() + expect(connectSrc).toContain('ws://localhost:3002') + }) +}) diff --git a/apps/sim/lib/core/security/csp.ts b/apps/sim/lib/core/security/csp.ts index 2e55e81da08..b7ac36a4832 100644 --- a/apps/sim/lib/core/security/csp.ts +++ b/apps/sim/lib/core/security/csp.ts @@ -16,6 +16,23 @@ function toWebSocketUrl(httpUrl: string): string { return httpUrl.replace('http://', 'ws://').replace('https://', 'wss://') } +/** + * Kept in sync with LOCALHOST_HOSTNAMES in ../utils/urls by hand: this module is + * loaded by next.config.ts before `@/` aliases resolve, so it cannot import from + * there (see the note above). + */ +const LOCALHOST_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1']) + +/** Mirrors getSocketUrl's localhost check — those origins fall back to DEFAULT_SOCKET_URL. */ +function isLocalhostUrl(url: string): boolean { + if (!url) return false + try { + return LOCALHOST_HOSTNAMES.has(new URL(url).hostname) + } catch { + return false + } +} + function getHostnameFromUrl(url: string | undefined): string[] { if (!url) return [] try { @@ -208,7 +225,15 @@ export function buildCSPString(directives: CSPDirectives): string { export function generateRuntimeCSP(): string { const appUrl = getEnv('NEXT_PUBLIC_APP_URL') || '' - const socketUrl = getEnv('NEXT_PUBLIC_SOCKET_URL') || (isDev ? DEFAULT_SOCKET_URL : '') + // Must permit whatever getSocketUrl() actually connects to, or the browser + // blocks the handshake and Socket.IO retries forever. That helper falls back + // to DEFAULT_SOCKET_URL whenever the page is served from localhost — which + // includes a production build (docker compose sets NODE_ENV=production), so + // keying this on isDev alone left the bundled stack with a CSP that forbade + // its own realtime port. A non-localhost origin still resolves to the page + // origin, which appUrl already covers, so nothing is loosened there. + const socketUrl = + getEnv('NEXT_PUBLIC_SOCKET_URL') || (isDev || isLocalhostUrl(appUrl) ? DEFAULT_SOCKET_URL : '') const socketWsUrl = socketUrl ? toWebSocketUrl(socketUrl) : '' const ollamaUrl = getEnv('OLLAMA_URL') || (isDev ? DEFAULT_OLLAMA_URL : '') diff --git a/docker-compose.local.yml b/docker-compose.local.yml index eb9c31926b3..78e2f591597 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -26,10 +26,7 @@ services: - SIM_AGENT_API_URL=${SIM_AGENT_API_URL:-} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - SOCKET_SERVER_URL=${SOCKET_SERVER_URL:-http://realtime:3002} - # Published on 3002 with no proxy in front, so the browser must target it - # directly; empty would fall back to the page origin, where /socket.io - # answers 308 and the socket reconnects forever. - - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002} + - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} depends_on: db: condition: service_healthy diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index de979c6ebb1..fa187e0aa47 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -35,14 +35,12 @@ services: - SIM_AGENT_API_URL=${SIM_AGENT_API_URL:-} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - SOCKET_SERVER_URL=${SOCKET_SERVER_URL:-http://realtime:3002} - # NEXT_PUBLIC_SOCKET_URL is read by the browser. This stack publishes the - # app on 3000 and realtime on 3002 with no reverse proxy between them, so - # it must point at 3002 — left empty the client falls back to the page - # origin, where /socket.io answers 308 instead of a handshake and the - # socket reconnects forever. Override when a proxy fronts both on one - # origin (set it to that origin, or empty to use the page origin), or when - # realtime is elsewhere (e.g. wss://socket.example.com). - - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002} + # NEXT_PUBLIC_SOCKET_URL is read by the browser. Leave it unset for this + # stack: the client already falls back to localhost:3002 for a localhost + # page, and a proxied deployment needs the page-origin fallback that an + # explicit value would suppress. Set it only when realtime is on a + # different host:port (e.g. wss://socket.example.com). + - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} - ADMISSION_GATE_MAX_INFLIGHT=${ADMISSION_GATE_MAX_INFLIGHT:-500} depends_on: db: diff --git a/scripts/setup/db.ts b/scripts/setup/db.ts index a195b425ea5..5794618a9c5 100644 --- a/scripts/setup/db.ts +++ b/scripts/setup/db.ts @@ -9,6 +9,18 @@ import { glyph, theme } from './theme.ts' const DEFAULT_DSN = 'postgresql://postgres:postgres@localhost:5432/simstudio' +/** Postgres' wire message when the password is wrong — a live server, not a dead one. */ +const AUTH_FAILURE = /password authentication failed/i + +/** + * Percent-encodes the password so characters that are structural in a URL + * (`@`, `:`, `/`, `#`, `?`) can't re-parse the DSN into a different host — which + * would fail a password that is actually correct. + */ +function buildDsn(password: string, hostPort: string | number): string { + return `postgresql://postgres:${encodeURIComponent(password)}@localhost:${hostPort}/simstudio` +} + export function docker(args: string[]): void { const result = spawnSync('docker', args, { encoding: 'utf8' }) if (result.status !== 0) { @@ -63,7 +75,9 @@ function inspectManagedContainer(): ManagedContainer | null { return { running: running === 'true', - dsn: `postgresql://postgres:${password}@localhost:${hostPort}/simstudio`, + // Read back from the container env verbatim, so it may be a password the + // user supplied for an existing volume — encode it like any other. + dsn: buildDsn(password, hostPort), } } @@ -235,7 +249,9 @@ async function startManagedContainer(detection: Detection): Promise { const password = volumeInitialized() ? await resolveExistingVolume() : generateSecret().slice(0, 24) - const dsn = `postgresql://postgres:${password}@localhost:${hostPort}/simstudio` + // A user-supplied password can contain @ : / # — raw interpolation would + // re-parse the DSN into a different host and fail a password that is correct. + const dsn = buildDsn(password, hostPort) docker([ 'run', '-d', @@ -255,9 +271,31 @@ async function startManagedContainer(detection: Detection): Promise { ]) const spin = p.spinner() spin.start(`Starting ${DB_CONTAINER} container on :${hostPort}…`) - const healthy = await waitFor(async () => (await pgProbe(dsn)).ok, 45_000, 1500) + let lastError = '' + const healthy = await waitFor( + async () => { + const probe = await pgProbe(dsn) + if (!probe.ok) lastError = probe.error ?? '' + return probe.ok + }, + 45_000, + 1500 + ) if (!healthy) { spin.stop(`${glyph.fail} container did not become healthy`) + // Postgres running and refusing the password is a different failure from + // Postgres never starting, and it is the likely one on the keep-the-volume + // path. Reporting it as "did not become healthy" is the exact confusion + // this whole change set exists to remove. + if (AUTH_FAILURE.test(lastError)) { + throw new SetupError( + `Postgres started, but rejected that password for the existing ${DB_VOLUME} volume.`, + [ + 're-run and enter the password the volume was created with', + `or discard the old data: ${theme.command(`docker rm -f ${DB_CONTAINER} && docker volume rm ${DB_VOLUME}`)}`, + ] + ) + } 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}`, From ccac475e31ca19f5d8f3f44e43b3f1bf31b09c60 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 13:32:57 -0700 Subject: [PATCH 08/14] improvement(setup): make k8s mode end somewhere usable, and show install progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things made k8s mode the least satisfying path. The services are ClusterIP, so a successful install left nothing on :3000 — 'Sim is ready' was true about the cluster and useless to the user, who had to notice and run a port-forward by hand. Compose opens a browser and dev offers to start the server; k8s now offers the forward the same way and runs it in the foreground so Ctrl-C ends it. Realtime gets its own forward (kubectl takes one resource per invocation) or the editor socket fails; it is a child in the same process group, so the terminal's Ctrl-C reaches it, and it is killed explicitly when the app forward exits. 'helm --wait' then blocked for minutes with a single static spinner, so a slow image pull looked identical to a wedged install. Run helm asynchronously and poll the cluster, so the spinner reports '3/3 pods ready · 1 starting'. CronJob-owned pods are excluded: the chart schedules a lot of them (36 on a running cluster here) and they finish as Completed, which would swamp the count and make readiness jitter for reasons unrelated to the install. Restarting pods are surfaced too — a cold cluster restarts realtime while Postgres comes up, and a silent spinner made that look like nothing was happening. --- scripts/setup/modes/k8s.ts | 150 +++++++++++++++++++++++++++++++++++-- 1 file changed, 142 insertions(+), 8 deletions(-) diff --git a/scripts/setup/modes/k8s.ts b/scripts/setup/modes/k8s.ts index f95eea2a328..27b2ec8a68a 100644 --- a/scripts/setup/modes/k8s.ts +++ b/scripts/setup/modes/k8s.ts @@ -1,4 +1,4 @@ -import { spawnSync } from 'node:child_process' +import { spawn, spawnSync } from 'node:child_process' import { getErrorMessage } from '@sim/utils/errors' import type { Detection } from '../detect.ts' import { ensureDocker } from '../docker.ts' @@ -8,6 +8,7 @@ import { waitFor } from '../probes.ts' import * as p from '../prompter.ts' import { glyph, theme } from '../theme.ts' +const APP_URL = 'http://localhost:3000' const RELEASE = 'sim-dev' const NAMESPACE = 'sim-dev' const LOCAL_CONTEXT_PREFIXES = ['kind-', 'docker-desktop', 'minikube', 'orbstack'] @@ -179,6 +180,102 @@ async function ensureLocalContext(detection: Detection): Promise { return 'kind-sim' } +interface PodProgress { + ready: number + total: number + detail: string +} + +/** + * One-line summary of what the cluster is doing, for the install spinner. Only + * long-running workloads count — the chart's CronJobs spawn short-lived pods + * that finish as Completed, and counting those makes "ready" jitter downward + * for reasons that have nothing to do with the install. + */ +function podProgress(context: string): PodProgress | null { + const result = spawnSync( + 'kubectl', + [ + 'get', + 'pods', + '--context', + context, + '-n', + NAMESPACE, + '-o', + 'jsonpath={range .items[*]}{.status.phase}{"\\t"}{.metadata.ownerReferences[0].kind}{"\\t"}{range .status.containerStatuses[*]}{.ready},{.state.waiting.reason}{" "}{end}{"\\n"}{end}', + ], + { encoding: 'utf8' } + ) + if (result.status !== 0) return null + const rows = result.stdout.split('\n').filter(Boolean) + if (rows.length === 0) return null + + let ready = 0 + let total = 0 + let pulling = 0 + let crashing = 0 + for (const row of rows) { + const [, ownerKind = '', containers = ''] = row.split('\t') + if (ownerKind === 'Job') continue + total++ + if (containers.includes('true,')) ready++ + if (containers.includes('ContainerCreating') || containers.includes('PodInitializing')) + pulling++ + if (containers.includes('CrashLoopBackOff') || containers.includes('ImagePullBackOff')) + crashing++ + } + if (total === 0) return null + const notes: string[] = [] + if (pulling > 0) notes.push(`${pulling} starting`) + // Restarts while Postgres comes up are normal on a cold cluster; say so rather + // than let a silent spinner imply nothing is happening. + if (crashing > 0) notes.push(`${crashing} restarting`) + return { ready, total, detail: notes.join(' · ') } +} + +/** + * `helm --wait` blocks for minutes with no output, so a slow image pull is + * indistinguishable from a wedged install — the reason the old spinner was + * unsatisfying. Run helm asynchronously and poll the cluster so the spinner + * reports what is actually happening. + */ +async function helmInstall( + args: string[], + input: string, + context: string, + spin: ReturnType +): Promise { + const child = spawn('helm', args, { cwd: ROOT, stdio: ['pipe', 'pipe', 'pipe'] }) + child.stdin.write(input) + child.stdin.end() + + let stderr = '' + let stdout = '' + child.stdout.on('data', (chunk) => { + stdout += chunk + }) + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + + const ticker = setInterval(() => { + const progress = podProgress(context) + if (!progress) return + const suffix = progress.detail ? ` · ${progress.detail}` : '' + spin.message(`${progress.ready}/${progress.total} pods ready${suffix}`) + }, 3000) + + const code = await new Promise((resolve) => { + child.once('close', (status) => resolve(status ?? 1)) + }) + clearInterval(ticker) + + if (code !== 0) { + throw new Error(`helm upgrade --install failed: ${stderr.trim() || stdout.trim()}`) + } +} + function existingReleaseSecrets(context: string): Record | null { const scope = ['--kube-context', context, '-n', NAMESPACE] const status = spawnSync('helm', ['status', RELEASE, ...scope], { stdio: 'ignore' }) @@ -239,8 +336,7 @@ export async function runK8sMode(detection: Detection): Promise { const spin = p.spinner() spin.start('helm upgrade --install (first run pulls images — this can take several minutes)…') try { - run( - 'helm', + await helmInstall( [ 'upgrade', '--install', @@ -259,8 +355,9 @@ export async function runK8sMode(detection: Detection): Promise { '--timeout', '15m', ], - 'helm upgrade --install failed', - secretValues(secrets) + secretValues(secrets), + context, + spin ) } catch (error) { spin.stop(`${glyph.fail} helm install failed`) @@ -289,10 +386,47 @@ export async function runK8sMode(detection: Detection): Promise { 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`, + `open ${APP_URL} (needs the port-forward below)`, + `pods: kubectl --context ${shq(context)} -n ${NAMESPACE} get pods`, + `app logs: kubectl --context ${shq(context)} -n ${NAMESPACE} logs deploy/${RELEASE}-app --tail 50`, + `forward: kubectl --context ${shq(context)} -n ${NAMESPACE} port-forward svc/${RELEASE}-app 3000:3000`, + `tear down: helm uninstall ${RELEASE} --kube-context ${shq(context)} -n ${NAMESPACE}`, ].join('\n'), 'Reach your cluster' ) + + await offerPortForward(context) +} + +/** + * The services are ClusterIP, so a healthy release is still unreachable from the + * host — "Sim is ready" with nothing on :3000 is the least satisfying way to end + * a setup. Offer the forward the same way dev mode offers to start the server, + * and run it in the foreground so Ctrl-C ends it. + * + * Realtime needs its own forward (one resource per invocation) or the editor's + * socket fails; it runs as a child in this process group, so the terminal's + * Ctrl-C reaches it too, and it is killed explicitly once the app forward exits. + */ +async function offerPortForward(context: string): Promise { + const forward = await p.confirm({ + message: `Port-forward now so you can open ${APP_URL}?`, + initialValue: true, + }) + if (!forward) { + p.log.info(theme.muted('Skipped — run the forward command above when you want to reach it.')) + return + } + + const scope = ['--context', context, '-n', NAMESPACE] + const realtime = spawn( + 'kubectl', + [...scope, 'port-forward', `svc/${RELEASE}-realtime`, '3002:3002'], + { stdio: 'ignore' } + ) + p.log.step(`Forwarding ${APP_URL} (app) and :3002 (realtime) — Ctrl-C to stop`) + spawnSync('kubectl', [...scope, 'port-forward', `svc/${RELEASE}-app`, '3000:3000'], { + stdio: 'inherit', + }) + realtime.kill() } From bb2fd2a4caff0a0c377be18fc97f68702ab7d849 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 13:51:14 -0700 Subject: [PATCH 09/14] fix(setup): identify Sim compose projects by content, not filename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor (High): composeInstalls treated any project whose config basename was docker-compose.prod.yml or docker-compose.local.yml as a Sim install. Those names are common, and sim reset runs 'compose down -v' — so a stranger's stack could have had its volumes destroyed. I introduced that reach. The previous ROOT-scoped '-f' probe was implicitly safe because it could only ever see the project in this checkout; switching to a global 'compose ls' to find stacks started elsewhere means projects must be identified by content instead. Read the config file Docker recorded and require a Sim marker (the published app image, or the app Dockerfile this repo builds), so both the prod and local variants match while an unrelated file with the same name does not. An unreadable or since-deleted file is left unmanaged rather than assumed ours. Verified against a decoy nginx compose file using our exact filename: ignored, while both real Sim compose files still match. --- scripts/setup/lifecycle.ts | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/scripts/setup/lifecycle.ts b/scripts/setup/lifecycle.ts index a0d1e3e20d6..e5c94ad71ca 100644 --- a/scripts/setup/lifecycle.ts +++ b/scripts/setup/lifecycle.ts @@ -1,4 +1,5 @@ import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' import path from 'node:path' import { DB_CONTAINER, type Detection, REDIS_CONTAINER, runDetection } from './detect.ts' import { archiveEnvFile, ROOT } from './env-files.ts' @@ -90,6 +91,33 @@ interface ComposeProject { ConfigFiles: string } +/** + * Markers that a compose file is actually Sim's: the published app image + * (docker-compose.prod.yml) or the app Dockerfile this repo builds + * (docker-compose.local.yml). + */ +const SIM_COMPOSE_MARKERS = ['ghcr.io/simstudioai/simstudio', 'docker/app.Dockerfile'] as const + +/** + * `docker-compose.prod.yml` is a common filename, so the name alone cannot say a + * project is ours — and `sim reset` runs `compose down -v`, which would destroy + * an unrelated stack's volumes. Read the file Docker recorded for the project and + * require a Sim marker inside it. The old ROOT-scoped `-f` probe was implicitly + * safe because it could only ever see the local project; discovering projects + * globally means identifying them by content instead. + */ +function isSimComposeFile(file: string): boolean { + if (!(COMPOSE_FILES as readonly string[]).includes(path.basename(file))) return false + try { + const contents = readFileSync(file, 'utf8') + return SIM_COMPOSE_MARKERS.some((marker) => contents.includes(marker)) + } catch { + // Unreadable or deleted since the stack started — better to not manage it + // than to guess from the filename. + return false + } +} + /** * Ask Docker which compose projects exist rather than guessing from the working * directory. Compose derives a project name from the directory it was started @@ -98,8 +126,7 @@ interface ComposeProject { * — and it reports the same stack once per candidate file, since both files map * to the same directory-derived project. `compose ls` records the real project * and the exact config file, so one running stack yields exactly one install - * wherever it was started from. Projects whose compose file isn't one of ours - * (a devcontainer, an unrelated app) are filtered out by filename. + * wherever it was started from. */ function composeInstalls(): ComposeInstall[] { const raw = dockerText(['compose', 'ls', '-a', '--format', 'json']) @@ -116,7 +143,7 @@ function composeInstalls(): ComposeInstall[] { const file = (project.ConfigFiles ?? '') .split(',') .map((entry) => entry.trim()) - .find((entry) => (COMPOSE_FILES as readonly string[]).includes(path.basename(entry))) + .find(isSimComposeFile) if (!file) continue installs.push({ kind: 'compose', From 1c5443eb1bcb95a97fc7421bd1bb4b6cdf1526cd Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 13:58:19 -0700 Subject: [PATCH 10/14] fix(setup): scope the compose port skip to published ports; print both k8s forwards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round on #5964: - ensureComposePortsFree skipped conflict handling whenever the project had any container running, so leftover db/redis (which publish neither app port) waved through a foreign process on :3000 — it then surfaced as a raw compose bind error instead of the prompt. Read the host ports the project actually publishes and skip only those; the remaining ports still get the full check. Reading from the containers rather than the file matters because what counts is what is bound right now. - The post-install note and the skip path documented only the app forward, while offerPortForward runs two. Skipping the prompt or copying the printed command left the editor's socket dead — the exact failure this change set exists to fix. Both commands now come from one forwardCommands() helper, so what is printed and what is run cannot drift. --- scripts/setup/modes/compose.ts | 64 ++++++++++++++++++++++++++-------- scripts/setup/modes/k8s.ts | 28 +++++++++++++-- 2 files changed, 74 insertions(+), 18 deletions(-) diff --git a/scripts/setup/modes/compose.ts b/scripts/setup/modes/compose.ts index 7e18f92a9ab..3ea52fa4b57 100644 --- a/scripts/setup/modes/compose.ts +++ b/scripts/setup/modes/compose.ts @@ -19,28 +19,62 @@ import { } from '../steps.ts' import { glyph, theme } from '../theme.ts' +const REQUIRED_PORTS = [3000, 3002] as const + +/** + * Host ports this compose project currently publishes. Read from the containers + * rather than assumed from the file, because what matters is what is bound right + * now — a project with only db/redis up publishes neither app port, so those + * still need the conflict check. + */ +function composePublishedPorts(composeFile: string): Set { + const ids = spawnSync('docker', ['compose', '-f', composeFile, 'ps', '-q'], { + cwd: ROOT, + encoding: 'utf8', + }) + const containers = ids.status === 0 ? ids.stdout.split('\n').filter(Boolean) : [] + if (containers.length === 0) return new Set() + + const inspect = spawnSync( + 'docker', + [ + 'inspect', + ...containers, + '--format', + '{{range $port, $bindings := .HostConfig.PortBindings}}{{range $bindings}}{{.HostPort}} {{end}}{{end}}', + ], + { encoding: 'utf8' } + ) + if (inspect.status !== 0) return new Set() + const published = new Set() + for (const token of inspect.stdout.split(/\s+/)) { + const port = Number(token) + if (Number.isInteger(port) && port > 0) published.add(port) + } + return published +} + /** * 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 { - // This stack already holding 3000/3002 is not a conflict — `docker compose up - // -d` reconciles its own containers. Without this, re-running setup against a - // running install reports its own realtime container as a blocker and offers - // to kill Docker's listener, which is never the right move. A genuinely - // foreign process still gets caught below, and a foreign *container* surfaces - // as a clear bind error from compose itself. - const ours = spawnSync('docker', ['compose', '-f', composeFile, 'ps', '-q'], { - cwd: ROOT, - encoding: 'utf8', - }) - if (ours.status === 0 && ours.stdout.trim()) { - p.log.step('Existing Sim containers hold :3000/:3002 — compose will reconcile them') - return + // A port this stack already publishes is not a conflict — `docker compose up + // -d` reconciles its own containers, and reporting the install's own realtime + // container as a blocker (offering to kill Docker's listener) is never right. + // Skip only the ports this project actually publishes: leftover db/redis + // containers must not wave through a foreign process sitting on 3000, which + // would otherwise surface as a raw compose bind error instead of the prompt. + const ours = composePublishedPorts(composeFile) + const toCheck = REQUIRED_PORTS.filter((port) => !ours.has(port)) + if (toCheck.length < REQUIRED_PORTS.length) { + const skipped = REQUIRED_PORTS.filter((port) => ours.has(port)) + p.log.step(`Existing Sim containers hold :${skipped.join(', :')} — compose will reconcile them`) } - if (await ensurePortsFree([3000, 3002])) return - throw new SetupError('ports 3000/3002 are in use', [ + if (toCheck.length === 0) return + if (await ensurePortsFree(toCheck)) return + throw new SetupError(`ports ${toCheck.map((port) => `:${port}`).join('/')} 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')}`, diff --git a/scripts/setup/modes/k8s.ts b/scripts/setup/modes/k8s.ts index 27b2ec8a68a..21ab9372f4b 100644 --- a/scripts/setup/modes/k8s.ts +++ b/scripts/setup/modes/k8s.ts @@ -386,10 +386,14 @@ export async function runK8sMode(detection: Detection): Promise { p.note( [ - `open ${APP_URL} (needs the port-forward below)`, + `open ${APP_URL} (needs both forwards below)`, `pods: kubectl --context ${shq(context)} -n ${NAMESPACE} get pods`, `app logs: kubectl --context ${shq(context)} -n ${NAMESPACE} logs deploy/${RELEASE}-app --tail 50`, - `forward: kubectl --context ${shq(context)} -n ${NAMESPACE} port-forward svc/${RELEASE}-app 3000:3000`, + // Both, always: the app alone loads but the editor's socket has nothing to + // reach, which is the reconnect failure this change set exists to fix. + ...forwardCommands(context).map( + (command, index) => `${index === 0 ? 'forward: ' : ' '} ${command}` + ), `tear down: helm uninstall ${RELEASE} --kube-context ${shq(context)} -n ${NAMESPACE}`, ].join('\n'), 'Reach your cluster' @@ -398,6 +402,16 @@ export async function runK8sMode(detection: Detection): Promise { await offerPortForward(context) } +/** + * Both forwards, in the order a user should run them. Kept in one place so the + * printed instructions and what the wizard actually runs cannot drift — the app + * alone leaves the editor's socket dead. + */ +function forwardCommands(context: string): string[] { + const scope = `kubectl --context ${shq(context)} -n ${NAMESPACE} port-forward` + return [`${scope} svc/${RELEASE}-app 3000:3000`, `${scope} svc/${RELEASE}-realtime 3002:3002`] +} + /** * The services are ClusterIP, so a healthy release is still unreachable from the * host — "Sim is ready" with nothing on :3000 is the least satisfying way to end @@ -414,7 +428,15 @@ async function offerPortForward(context: string): Promise { initialValue: true, }) if (!forward) { - p.log.info(theme.muted('Skipped — run the forward command above when you want to reach it.')) + p.log.info( + theme.muted( + `Skipped — run both forwards when you want to reach it (the second keeps the editor's socket alive):\n${forwardCommands( + context + ) + .map((command) => ` ${command}`) + .join('\n')}` + ) + ) return } From 02e93d84df7e29a4aee246820ce8e1e3ede1ea2a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 14:04:44 -0700 Subject: [PATCH 11/14] fix(setup): one source for the k8s forwards, and surface a dead realtime forward MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third round on the same theme, so fix it at the root rather than at another call site. - lifecycle's k8sReachHints (used by sim start/restart) still restated an app-only forward, recreating the dead editor socket the setup path had just been fixed for. forwardCommands is now exported and consumed there, so every place that tells a user how to reach a ClusterIP release derives it from one definition. - The realtime forward was spawned with stdio ignored and never checked, so a busy :3002 or a missing service killed it silently while the app forward kept running — indistinguishable from success until the editor won't connect. Keep its stderr, warn on an exit we did not ask for, and stay quiet on the intentional kill. --- scripts/setup/lifecycle.ts | 12 ++++++++---- scripts/setup/modes/k8s.ts | 22 ++++++++++++++++++++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/scripts/setup/lifecycle.ts b/scripts/setup/lifecycle.ts index e5c94ad71ca..5f264b9ff33 100644 --- a/scripts/setup/lifecycle.ts +++ b/scripts/setup/lifecycle.ts @@ -4,7 +4,7 @@ import path from 'node:path' 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 { forwardCommands, isLocalKubeContext } from './modes/k8s.ts' import { httpHealth } from './probes.ts' import * as p from './prompter.ts' import { glyph, theme } from './theme.ts' @@ -224,11 +224,15 @@ function managedNames(install: DevInstall): string[] { return names } +/** + * Reuses the wizard's forward commands rather than restating them — reaching a + * ClusterIP release needs both, and an app-only hint here would leave the + * editor's socket dead exactly the way the setup path used to. + */ 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`, + ...forwardCommands(context), + `kubectl --context ${shq(context)} -n ${K8S_NAMESPACE} get pods`, ].join('\n') } diff --git a/scripts/setup/modes/k8s.ts b/scripts/setup/modes/k8s.ts index 21ab9372f4b..59d31effd7c 100644 --- a/scripts/setup/modes/k8s.ts +++ b/scripts/setup/modes/k8s.ts @@ -407,7 +407,7 @@ export async function runK8sMode(detection: Detection): Promise { * printed instructions and what the wizard actually runs cannot drift — the app * alone leaves the editor's socket dead. */ -function forwardCommands(context: string): string[] { +export function forwardCommands(context: string): string[] { const scope = `kubectl --context ${shq(context)} -n ${NAMESPACE} port-forward` return [`${scope} svc/${RELEASE}-app 3000:3000`, `${scope} svc/${RELEASE}-realtime 3002:3002`] } @@ -444,11 +444,29 @@ async function offerPortForward(context: string): Promise { const realtime = spawn( 'kubectl', [...scope, 'port-forward', `svc/${RELEASE}-realtime`, '3002:3002'], - { stdio: 'ignore' } + // stderr is kept so a failure can be explained; a silently dead second + // forward looks exactly like a working setup until the editor won't connect. + { stdio: ['ignore', 'ignore', 'pipe'] } ) + let realtimeError = '' + realtime.stderr?.on('data', (chunk) => { + realtimeError += chunk + }) + // Only an exit we did not ask for is a problem — the kill below also fires this. + let stopping = false + realtime.once('exit', (code) => { + if (stopping || code === 0) return + p.log.warn( + `The realtime forward (:3002) stopped — the editor's socket will not connect. ${ + realtimeError.trim() || `Check that :3002 is free and svc/${RELEASE}-realtime exists.` + }` + ) + }) + p.log.step(`Forwarding ${APP_URL} (app) and :3002 (realtime) — Ctrl-C to stop`) spawnSync('kubectl', [...scope, 'port-forward', `svc/${RELEASE}-app`, '3000:3000'], { stdio: 'inherit', }) + stopping = true realtime.kill() } From 4550411aa05a0be9101ed7402a1ce4c64c17dbb5 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 14:15:35 -0700 Subject: [PATCH 12/14] fix(setup): pin the compose project on every lifecycle op MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit composeInstalls records the real project name from 'compose ls' and status and the destructive confirms print it, but every op ran 'compose -f ' with only cwd set — so Compose re-derived the project from that directory. The derived name is frequently not the recorded one: a directory is lowercased and stripped of dots (Sim.Demo_Test derives simdemo_test), and an explicit -p or COMPOSE_PROJECT_NAME at creation diverges outright. stop/down/reset could therefore act on a different project than the one named in the confirm, and reset runs 'down -v'. Route every op through composeArgs(), which pins '-p '. cwd stays, since the file's own relative paths still resolve against it. Verified with a stack started as -p pinned-name from a directory deriving simdemo_test: the old form found 0 of its containers, the pinned form finds them. --- scripts/setup/lifecycle.ts | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/scripts/setup/lifecycle.ts b/scripts/setup/lifecycle.ts index 5f264b9ff33..fa2ff6a5275 100644 --- a/scripts/setup/lifecycle.ts +++ b/scripts/setup/lifecycle.ts @@ -155,6 +155,21 @@ function composeInstalls(): ComposeInstall[] { return installs } +/** + * Compose args for an op on a detected install. `-p` is not optional: without it + * Compose re-derives the project from the working directory, and that name is + * frequently NOT the one `compose ls` reported — a directory is lowercased and + * stripped of dots (`Sim.Demo` becomes `simdemo`), and an explicit `-p` or + * COMPOSE_PROJECT_NAME at creation time diverges outright. Acting on a + * re-derived name means `stop`/`down`/`reset` can target a different project + * than the one named in the confirm — and `reset` runs `down -v`. Pinning the + * recorded name makes the op hit exactly what was detected; cwd stays because + * the file's own relative paths (build contexts, env_file) resolve against it. + */ +function composeArgs(install: ComposeInstall, ...verb: string[]): string[] { + return ['compose', '-p', install.project, '-f', install.file, ...verb] +} + /** 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 @@ -240,7 +255,7 @@ 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', install.dir) + dockerRun(composeArgs(install, 'up', '-d'), 'docker compose up failed', install.dir) spin.stop('Containers up') p.note( [`open ${APP_URL}`, 'follow logs: sim logs', 'stop: sim stop'].join('\n'), @@ -265,7 +280,7 @@ 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', install.dir) + dockerRun(composeArgs(install, 'stop'), 'docker compose stop failed', install.dir) spin.stop('Containers stopped (data kept)') p.note(['start again: sim start', 'remove: sim down'].join('\n'), 'Stopped') return @@ -295,11 +310,7 @@ 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', - install.dir - ) + dockerRun(composeArgs(install, 'restart'), 'docker compose restart failed', install.dir) spin.stop('Containers restarted') p.note(`open ${APP_URL}`, 'Running') return @@ -316,7 +327,7 @@ function restart(install: Install): void { function showLogs(install: Install): void { if (install.kind === 'compose') { - dockerInherit(['compose', '-f', install.file, 'logs', '-f', '--tail', '100'], install.dir) + dockerInherit(composeArgs(install, 'logs', '-f', '--tail', '100'), install.dir) return } if (install.kind === 'dev') { @@ -347,7 +358,7 @@ async function down(install: Install): Promise { return } if (install.kind === 'compose') { - dockerRun(['compose', '-f', install.file, 'down'], 'docker compose down failed', install.dir) + dockerRun(composeArgs(install, 'down'), 'docker compose down failed', install.dir) p.log.step('Containers removed (volumes kept)') return } @@ -390,11 +401,7 @@ async function reset(install: Install | null): Promise { if (backup) p.log.step(`Archived ${backup}`) } if (install?.kind === 'compose') { - dockerRun( - ['compose', '-f', install.file, 'down', '-v'], - 'docker compose down -v failed', - install.dir - ) + dockerRun(composeArgs(install, 'down', '-v'), 'docker compose down -v failed', install.dir) p.log.step('Containers and volumes removed') } else if (install?.kind === 'dev') { const names = managedNames(install) From 84d9fe99991ebe6947455632b5523d3dba5ba4d6 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 14:17:36 -0700 Subject: [PATCH 13/14] fix(setup): warn on both halves of a mothership mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mothershipOverride warned only when SIM_CLI_AUTH_ORIGIN was set without SIM_AGENT_API_URL, while its own copy said to set both or neither. The reverse is the same failure mirrored: with only SIM_AGENT_API_URL set, the Chat key is still minted against the default prod auth origin and then validated against the override, which rejects it — silently, which is exactly what this helper exists to prevent. Warn on either asymmetry, and read the default origin from one constant shared with the handoff so the message can't claim an origin the code no longer uses. --- scripts/setup/steps.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/setup/steps.ts b/scripts/setup/steps.ts index 584188af70b..ab4c27fafe8 100644 --- a/scripts/setup/steps.ts +++ b/scripts/setup/steps.ts @@ -13,6 +13,9 @@ import * as p from './prompter.ts' import { link, theme } from './theme.ts' import { FLAG_TWINS, hasMailProvider, LOGIN_PROVIDERS, SELF_HOST_UNLOCKS } from './twins.ts' +/** Where the Chat key is minted when SIM_CLI_AUTH_ORIGIN is unset. */ +const DEFAULT_CLI_AUTH_ORIGIN = 'https://www.sim.ai' + /** Reuses existing valid secrets (never regenerates them) and generates the rest. */ export function collectSecrets(existing: EnvFile): Record { const secrets: Record = {} @@ -57,7 +60,7 @@ export async function promptCopilotKey(existing?: string): Promise { const agentUrl = process.env.SIM_AGENT_API_URL const authOrigin = process.env.SIM_CLI_AUTH_ORIGIN + // Either half alone produces the same cross-environment rejection, just in + // opposite directions — mint here, validate there. Warning on only one of them + // would leave the other silent while the copy claims both matter. if (authOrigin && !agentUrl) { p.log.warn( - `SIM_CLI_AUTH_ORIGIN points the Chat key handoff at ${authOrigin}, but SIM_AGENT_API_URL is unset — the app will validate that key against production and reject it. Set both, or neither.` + `SIM_CLI_AUTH_ORIGIN mints the Chat key at ${authOrigin}, but SIM_AGENT_API_URL is unset — the app validates against production, which will reject that key. Set both, or neither.` + ) + } else if (agentUrl && !authOrigin) { + p.log.warn( + `SIM_AGENT_API_URL points the app at ${agentUrl}, but SIM_CLI_AUTH_ORIGIN is unset — the Chat key is minted at ${DEFAULT_CLI_AUTH_ORIGIN}, which that backend will reject. Set both, or neither.` ) } if (!agentUrl) return {} From 910f367c0702c8062bef57238f6e9ee1d1af0cd8 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 25 Jul 2026 14:25:15 -0700 Subject: [PATCH 14/14] fix(setup): warn about a half-set mothership before minting the key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mothershipOverride ran two steps after promptCopilotKey, so a half-set override minted a key against one environment, stored it, and only then warned that the other environment would reject it. Worse on a re-run: promptCopilotKey offers to keep an existing COPILOT_API_KEY and defaults to yes, so the bad key survives. Move the override ahead of the key prompt in both compose and dev, so the warning arrives while it can still change the outcome — the user can abort and set the missing half before anything is minted. Nothing in the override depends on the key, so the order is free. --- scripts/setup/modes/compose.ts | 5 ++++- scripts/setup/modes/dev.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/setup/modes/compose.ts b/scripts/setup/modes/compose.ts index 3ea52fa4b57..6e07706f48d 100644 --- a/scripts/setup/modes/compose.ts +++ b/scripts/setup/modes/compose.ts @@ -106,10 +106,13 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom const root = readEnvFile('root') const values = collectSecrets(root) + // Before the key is minted: a half-set override mints against one environment + // and validates against the other, and warning afterwards is too late — the + // bad key is already stored, and the next run offers to keep it. + Object.assign(values, mothershipOverride()) const copilotKey = await promptCopilotKey(root.vars.get('COPILOT_API_KEY')) if (copilotKey) values.COPILOT_API_KEY = copilotKey Object.assign(values, await promptLlmKeys(detection, !quick)) - Object.assign(values, mothershipOverride()) if (!quick) { const storage = await promptStorage(root.vars, true) if (storage) Object.assign(values, storage) diff --git a/scripts/setup/modes/dev.ts b/scripts/setup/modes/dev.ts index d3d0a66bfae..d0545e064ee 100644 --- a/scripts/setup/modes/dev.ts +++ b/scripts/setup/modes/dev.ts @@ -118,10 +118,13 @@ export async function runDevMode( const simAfter = readEnvFile('sim') const values: Record = {} + // Before the key is minted: a half-set override mints against one environment + // and validates against the other, and warning afterwards is too late — the + // bad key is already stored, and the next run offers to keep it. + Object.assign(values, mothershipOverride()) const copilotKey = await promptCopilotKey(simAfter.vars.get('COPILOT_API_KEY')) if (copilotKey) values.COPILOT_API_KEY = copilotKey Object.assign(values, await promptLlmKeys(detection, !quick)) - Object.assign(values, mothershipOverride()) // Redis is set up in every mode, quick included. Storage falls back to // PostgreSQL without it, but the pub/sub channels (live Chat task-status,