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 8bb153c0f25..78e2f591597 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -21,15 +21,17 @@ 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:-} - - COPILOT_API_KEY=${COPILOT_API_KEY} - - SIM_AGENT_API_URL=${SIM_AGENT_API_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} - SOCKET_SERVER_URL=${SOCKET_SERVER_URL:-http://realtime:3002} - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-} 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..fa187e0aa47 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -30,20 +30,23 @@ services: - ENCRYPTION_KEY=${ENCRYPTION_KEY} - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} - INTERNAL_API_SECRET=${INTERNAL_API_SECRET} - - REDIS_URL=${REDIS_URL:-} - - COPILOT_API_KEY=${COPILOT_API_KEY} - - SIM_AGENT_API_URL=${SIM_AGENT_API_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} - 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 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: condition: service_healthy + redis: + condition: service_healthy migrations: condition: service_completed_successfully realtime: @@ -74,10 +77,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 +101,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/db.ts b/scripts/setup/db.ts index a4b0a2f25d5..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), } } @@ -122,6 +136,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,15 +231,27 @@ 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 - const dsn = `postgresql://postgres:${password}@localhost:${hostPort}/simstudio` + // 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) + // 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', @@ -159,7 +260,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', @@ -170,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}`, diff --git a/scripts/setup/lifecycle.ts b/scripts/setup/lifecycle.ts index 05e083609a5..fa2ff6a5275 100644 --- a/scripts/setup/lifecycle.ts +++ b/scripts/setup/lifecycle.ts @@ -1,8 +1,10 @@ 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' 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' @@ -38,20 +40,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 +66,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 +85,89 @@ interface K8sInstall { } type Install = ComposeInstall | DevInstall | K8sInstall +interface ComposeProject { + Name: string + Status: string + 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 + /** - * 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. + * `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 + * 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. */ 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(isSimComposeFile) + if (!file) continue + installs.push({ + kind: 'compose', + file, + dir: path.dirname(file), + project: project.Name, + }) + } + 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. */ @@ -124,7 +209,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. @@ -153,11 +239,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') } @@ -165,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') + 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'), @@ -190,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') + 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 @@ -220,7 +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') + dockerRun(composeArgs(install, 'restart'), 'docker compose restart failed', install.dir) spin.stop('Containers restarted') p.note(`open ${APP_URL}`, 'Running') return @@ -237,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']) + dockerInherit(composeArgs(install, 'logs', '-f', '--tail', '100'), install.dir) return } if (install.kind === 'dev') { @@ -268,7 +358,7 @@ async function down(install: Install): Promise { return } if (install.kind === 'compose') { - dockerRun(['compose', '-f', install.file, 'down'], 'docker compose down failed') + dockerRun(composeArgs(install, 'down'), 'docker compose down failed', install.dir) p.log.step('Containers removed (volumes kept)') return } @@ -311,7 +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') + 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) @@ -343,14 +433,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/compose.ts b/scripts/setup/modes/compose.ts index 3c3c8f09f92..6e07706f48d 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, @@ -18,14 +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 { - if (await ensurePortsFree([3000, 3002])) return - throw new SetupError('ports 3000/3002 are in use', [ + // 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 (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')}`, @@ -57,6 +106,10 @@ 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)) diff --git a/scripts/setup/modes/dev.ts b/scripts/setup/modes/dev.ts index 4bc19d04b0b..d0545e064ee 100644 --- a/scripts/setup/modes/dev.ts +++ b/scripts/setup/modes/dev.ts @@ -7,9 +7,10 @@ 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, + mothershipOverride, promptCopilotKey, promptEmail, promptLlmKeys, @@ -62,8 +63,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) @@ -117,16 +118,28 @@ 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)) + // 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/modes/k8s.ts b/scripts/setup/modes/k8s.ts index f95eea2a328..59d31effd7c 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,87 @@ 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 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`, + // 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' ) + + 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. + */ +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`] +} + +/** + * 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 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 + } + + const scope = ['--context', context, '-n', NAMESPACE] + const realtime = spawn( + 'kubectl', + [...scope, 'port-forward', `svc/${RELEASE}-realtime`, '3002:3002'], + // 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() } 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. 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. diff --git a/scripts/setup/steps.ts b/scripts/setup/steps.ts index 4c7015210b9..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 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 {} + p.log.step(`Using mothership ${agentUrl} (SIM_AGENT_API_URL)`) + return { SIM_AGENT_API_URL: agentUrl } +} + export async function promptLlmKeys( detection: Detection, custom: boolean 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