diff --git a/apps/sim/lib/cli-auth/code-store.ts b/apps/sim/lib/cli-auth/code-store.ts index ac77a09961..5388501881 100644 --- a/apps/sim/lib/cli-auth/code-store.ts +++ b/apps/sim/lib/cli-auth/code-store.ts @@ -1,6 +1,5 @@ -import { createHash } from 'node:crypto' import { safeCompare } from '@sim/security/compare' -import { sha256Hex } from '@sim/security/hash' +import { sha256Base64Url, sha256Hex } from '@sim/security/hash' import { generateShortId } from '@sim/utils/id' import { getRedisClient } from '@/lib/core/config/redis' @@ -40,9 +39,9 @@ function codeKey(code: string): string { return `cli:auth:code:${sha256Hex(code)}` } -/** Base64url rather than hex: RFC 7636 defines the PKCE challenge that way. */ +/** Shares its implementation with the CLI so the two sides cannot drift apart. */ function challengeFor(verifier: string): string { - return createHash('sha256').update(verifier).digest('base64url') + return sha256Base64Url(verifier) } /** Returns the plaintext code, which is never stored. */ diff --git a/packages/security/src/hash.ts b/packages/security/src/hash.ts index 2d9cd96442..8ce50b5f1a 100644 --- a/packages/security/src/hash.ts +++ b/packages/security/src/hash.ts @@ -15,3 +15,18 @@ export function sha256Hex(input: string | Uint8Array): string { } return hash.digest('hex') } + +/** + * SHA-256 digest, base64url-encoded. The encoding PKCE specifies for a code + * challenge (RFC 7636), so both sides of a challenge/verifier exchange can + * derive it from one implementation instead of two that must stay in step. + */ +export function sha256Base64Url(input: string | Uint8Array): string { + const hash = createHash('sha256') + if (typeof input === 'string') { + hash.update(input, 'utf8') + } else { + hash.update(input) + } + return hash.digest('base64url') +} diff --git a/scripts/setup/checks.ts b/scripts/setup/checks.ts index 67e99943d4..c06ec859b0 100644 --- a/scripts/setup/checks.ts +++ b/scripts/setup/checks.ts @@ -5,9 +5,11 @@ import { generateSecret, isPlaceholder, isTruthy, + isUsableSecret, readEnvFile, SECRET_KEYS, SHARED_KEYS, + secretRequirement, writeEnvValues, } from './env-files.ts' import { httpHealth, pgProbe, redisPing } from './probes.ts' @@ -153,11 +155,11 @@ function checkSchema(ctx: CheckContext): Finding[] { }) continue } - if (MIN_32_KEYS.has(key) && value.length < 32) { + if (MIN_32_KEYS.has(key) && !isUsableSecret(key, value)) { findings.push({ group: 'schema', status: 'fail', - message: `${rel(file)}: ${key} is shorter than 32 chars — the realtime server rejects it`, + message: `${rel(file)}: ${key} ${secretRequirement(key)}`, fix: 'generate a new one with `openssl rand -hex 32` (rotating it invalidates existing sessions/encrypted data)', }) continue @@ -436,9 +438,8 @@ function checkCoherence(ctx: CheckContext): Finding[] { return findings } -async function checkLive(ctx: CheckContext): Promise { +async function checkDatabase(sim: EnvFile): Promise { const findings: Finding[] = [] - const sim = ctx.env.sim const dsn = sim.vars.get('DATABASE_URL') const dsnPassword = (() => { try { @@ -497,55 +498,75 @@ async function checkLive(ctx: CheckContext): Promise { }) } - const redisUrl = sim.vars.get('REDIS_URL') - if (redisUrl) { - const ping = await redisPing(redisUrl) - findings.push( - ping.ok - ? { group: 'live', status: 'pass', message: 'redis reachable' } - : { - group: 'live', - status: 'fail', - message: `redis unreachable: ${ping.error}`, - fix: 'fix REDIS_URL or remove it (optional for single-replica)', - } - ) - } - - for (const [label, port, url] of [ - ['app', 3000, 'http://localhost:3000/api/health'], - ['realtime', 3002, 'http://localhost:3002/health'], - ] as const) { - if (!(await portOpen(port))) { - findings.push({ group: 'live', status: 'skip', message: `${label}: not running on :${port}` }) - } else if (await httpHealth(url)) { - findings.push({ group: 'live', status: 'pass', message: `${label} healthy on :${port}` }) - } else { - findings.push({ - group: 'live', - status: 'fail', - message: `${label}: something is on :${port} but ${url} is not answering`, - fix: 'check the dev server logs', - }) - } - } - - const ollamaUrl = sim.vars.get('OLLAMA_URL') - if (ollamaUrl) { - findings.push( - (await httpHealth(`${ollamaUrl.replace(/\/$/, '')}/api/tags`)) - ? { group: 'live', status: 'pass', message: 'ollama reachable' } - : { - group: 'live', - status: 'warn', - message: 'OLLAMA_URL is set but Ollama is not answering', - fix: 'start Ollama or remove OLLAMA_URL', - } - ) - } return findings } +async function checkRedis(sim: EnvFile): Promise { + const redisUrl = sim.vars.get('REDIS_URL') + if (!redisUrl) return [] + const ping = await redisPing(redisUrl) + return [ + ping.ok + ? { group: 'live', status: 'pass', message: 'redis reachable' } + : { + group: 'live', + status: 'fail', + message: `redis unreachable: ${ping.error}`, + fix: 'fix REDIS_URL or remove it (optional for single-replica)', + }, + ] +} + +async function checkService(label: string, port: number, url: string): Promise { + if (!(await portOpen(port))) { + return [{ group: 'live', status: 'skip', message: `${label}: not running on :${port}` }] + } + if (await httpHealth(url)) { + return [{ group: 'live', status: 'pass', message: `${label} healthy on :${port}` }] + } + return [ + { + group: 'live', + status: 'fail', + message: `${label}: something is on :${port} but ${url} is not answering`, + fix: 'check the dev server logs', + }, + ] +} + +async function checkOllama(sim: EnvFile): Promise { + const ollamaUrl = sim.vars.get('OLLAMA_URL') + if (!ollamaUrl) return [] + return [ + (await httpHealth(`${ollamaUrl.replace(/\/$/, '')}/api/tags`)) + ? { group: 'live', status: 'pass', message: 'ollama reachable' } + : { + group: 'live', + status: 'warn', + message: 'OLLAMA_URL is set but Ollama is not answering', + fix: 'start Ollama or remove OLLAMA_URL', + }, + ] +} + +/** + * The five probes are independent, so they run concurrently — serially this is + * the sum of every timeout (~17s worst case) on a command whose whole job is to + * tell you what's broken. Results are concatenated in a fixed order so the + * report stays deterministic regardless of which probe settles first. + */ +async function checkLive(ctx: CheckContext): Promise { + const sim = ctx.env.sim + const [database, redis, app, realtime, ollama] = await Promise.all([ + checkDatabase(sim), + checkRedis(sim), + checkService('app', 3000, 'http://localhost:3000/api/health'), + checkService('realtime', 3002, 'http://localhost:3002/health'), + checkOllama(sim), + ]) + return [...database, ...redis, ...app, ...realtime, ...ollama] +} + export async function runChecks(ctx: CheckContext, groups?: CheckGroup[]): Promise { const findings: Finding[] = [ ...checkFiles(ctx), diff --git a/scripts/setup/cli-auth.ts b/scripts/setup/cli-auth.ts index d24c9cfbfb..7a9811107a 100644 --- a/scripts/setup/cli-auth.ts +++ b/scripts/setup/cli-auth.ts @@ -1,6 +1,9 @@ import { spawnSync } from 'node:child_process' -import { createHash, randomBytes } from 'node:crypto' import http from 'node:http' +import { sha256Base64Url } from '@sim/security/hash' +import { generateSecureToken } from '@sim/security/tokens' +import { generateShortId } from '@sim/utils/id' +import { generateRandomHex } from '@sim/utils/random' import * as p from './prompter.ts' import { link, theme } from './theme.ts' @@ -18,8 +21,8 @@ function openBrowser(url: string): void { * through the browser — so a code intercepted in transit cannot be redeemed. */ function createPkcePair(): { verifier: string; challenge: string } { - const verifier = randomBytes(32).toString('base64url') - return { verifier, challenge: createHash('sha256').update(verifier).digest('base64url') } + const verifier = generateSecureToken(32) + return { verifier, challenge: sha256Base64Url(verifier) } } /** No O/0 or I/1 — this exists to be compared by eye against a browser tab. */ @@ -35,9 +38,8 @@ const PAIRING_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' * a code you don't recognise, the approval isn't yours. */ function createPairingCode(): string { - const bytes = randomBytes(8) - const chars = Array.from(bytes, (byte) => PAIRING_ALPHABET[byte % PAIRING_ALPHABET.length]) - return `${chars.slice(0, 4).join('')}-${chars.slice(4).join('')}` + const chars = generateShortId(8, PAIRING_ALPHABET) + return `${chars.slice(0, 4)}-${chars.slice(4)}` } export interface CodeListener { @@ -56,7 +58,7 @@ export interface CodeListener { * mismatch. */ export function startCodeListener(origin: string): Promise { - const state = randomBytes(16).toString('hex') + const state = generateRandomHex(32) const { verifier, challenge } = createPkcePair() const pairingCode = createPairingCode() diff --git a/scripts/setup/db.ts b/scripts/setup/db.ts index af73c7aed5..3d530a5a75 100644 --- a/scripts/setup/db.ts +++ b/scripts/setup/db.ts @@ -5,7 +5,7 @@ import { generateSecret } from './env-files.ts' import { SetupError } from './errors.ts' import { pgProbe, waitFor } from './probes.ts' import * as p from './prompter.ts' -import { theme } from './theme.ts' +import { glyph, theme } from './theme.ts' const DEFAULT_DSN = 'postgresql://postgres:postgres@localhost:5432/simstudio' @@ -21,12 +21,10 @@ async function probeWithSpinner(dsn: string, label: string): Promise { spin.start(label) const probe = await pgProbe(dsn) if (probe.ok && probe.pgvectorAvailable === false) { - spin.stop(`${theme.warn('!')} connected, but pgvector is missing on that Postgres`) + spin.stop(`${glyph.warn} connected, but pgvector is missing on that Postgres`) return false } - spin.stop( - probe.ok ? 'database reachable (pgvector available)' : `${theme.warn('!')} ${probe.error}` - ) + spin.stop(probe.ok ? 'database reachable (pgvector available)' : `${glyph.warn} ${probe.error}`) return probe.ok } @@ -84,7 +82,7 @@ async function startManagedContainer(detection: Detection): Promise { spin.start(`Starting ${DB_CONTAINER} container on :${hostPort}…`) const healthy = await waitFor(async () => (await pgProbe(dsn)).ok, 45_000, 1500) if (!healthy) { - spin.stop(`${theme.error('✗')} container did not become healthy`) + spin.stop(`${glyph.fail} container did not become healthy`) const logs = spawnSync('docker', ['logs', '--tail', '20', DB_CONTAINER], { encoding: 'utf8' }) throw new SetupError( `the Postgres container failed to start. Last logs:\n${logs.stdout}${logs.stderr}`, @@ -113,9 +111,7 @@ async function restartManagedContainer(existingDsn: string | undefined): Promise const spin = p.spinner() spin.start(`Starting existing ${DB_CONTAINER} container…`) const healthy = await waitFor(async () => (await pgProbe(existingDsn)).ok, 30_000, 1500) - spin.stop( - healthy ? `${DB_CONTAINER} running` : `${theme.error('✗')} ${DB_CONTAINER} did not come up` - ) + spin.stop(healthy ? `${DB_CONTAINER} running` : `${glyph.fail} ${DB_CONTAINER} did not come up`) if (!healthy) throw new Error(`${DB_CONTAINER} started but is not answering on ${existingDsn}`) return existingDsn } diff --git a/scripts/setup/detect.ts b/scripts/setup/detect.ts index 5fa46bbff7..c426157606 100644 --- a/scripts/setup/detect.ts +++ b/scripts/setup/detect.ts @@ -1,7 +1,7 @@ import { spawnSync } from 'node:child_process' import net from 'node:net' import os from 'node:os' -import { ENV_PATHS, ROOT, readEnvFile } from './env-files.ts' +import { ROOT, readEnvFile } from './env-files.ts' export const MANAGED_LABEL = 'managed-by=sim-setup' export const DB_CONTAINER = 'sim-postgres' @@ -147,5 +147,3 @@ export async function runDetection(): Promise { specs: detectSpecs(dockerRunning), } } - -export { ENV_PATHS } diff --git a/scripts/setup/docker.ts b/scripts/setup/docker.ts index 9a5c9d63cb..1a17f0ac9d 100644 --- a/scripts/setup/docker.ts +++ b/scripts/setup/docker.ts @@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process' import { SetupError } from './errors.ts' import { waitFor } from './probes.ts' import * as p from './prompter.ts' -import { theme } from './theme.ts' +import { glyph, theme } from './theme.ts' const INSTALL_HINTS = [ 'install Docker Desktop: https://docker.com/products/docker-desktop', @@ -56,7 +56,7 @@ export async function ensureDocker(required: boolean): Promise { const spin = p.spinner() spin.start('Waiting for the Docker daemon…') const up = await waitFor(async () => daemonUp(), 90_000, 2000) - spin.stop(up ? 'Docker is running' : `${theme.error('✗')} daemon did not come up`) + spin.stop(up ? 'Docker is running' : `${glyph.fail} daemon did not come up`) if (!up) { throw new SetupError('Docker Desktop did not start within 90s.', [ 'first-ever launch needs a GUI license acceptance — open Docker Desktop manually once, then re-run', diff --git a/scripts/setup/env-files.ts b/scripts/setup/env-files.ts index b83d5c3ea5..e461305564 100644 --- a/scripts/setup/env-files.ts +++ b/scripts/setup/env-files.ts @@ -1,7 +1,7 @@ -import { randomBytes } from 'node:crypto' import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { generateRandomHex } from '@sim/utils/random' export const ROOT = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '../..') @@ -130,14 +130,46 @@ export function archiveEnvFile(target: EnvTarget): string | null { } export function generateSecret(): string { - return randomBytes(32).toString('hex') + return generateRandomHex(64) +} + +/** + * `ENCRYPTION_KEY` and `API_ENCRYPTION_KEY` are read as raw AES-256 material, + * so the app requires exactly 64 hex characters and throws on anything else + * (`lib/core/security/encryption.ts`, `lib/api-key/crypto.ts`). A merely-long + * passphrase passes a length check and then fails every encryption path at + * runtime, so those two are validated on format rather than length. + * + * Lives here so setup (which replaces an unusable secret) and doctor (which + * reports one) apply the same rule — they disagreed while it was duplicated. + */ +const HEX_KEY_PATTERN = /^[0-9a-f]{64}$/i +const HEX_SECRET_KEYS = new Set(['ENCRYPTION_KEY', 'API_ENCRYPTION_KEY']) + +export function isUsableSecret(key: string, value: string): boolean { + if (isPlaceholder(value)) return false + return HEX_SECRET_KEYS.has(key) ? HEX_KEY_PATTERN.test(value) : value.length >= 32 +} + +/** Human-readable reason a secret is unusable, for doctor's finding message. */ +export function secretRequirement(key: string): string { + return HEX_SECRET_KEYS.has(key) + ? 'must be exactly 64 hex characters (32-byte AES key)' + : 'must be at least 32 characters' } export function isPlaceholder(value: string): boolean { return PLACEHOLDER_VALUES.has(value) || value.startsWith('your_') } -/** Mirrors the app's isTruthy env coercion (apps/sim/lib/core/config/env.ts). */ +/** + * Mirrors the app's `isTruthy` (apps/sim/lib/core/config/env.ts:633) exactly — + * `true` or `1` only. The app's separate `envBoolean` additionally accepts + * `yes`/`on`, but feature flags read through `isTruthy`, so accepting the wider + * set here made the wizard and doctor report a flag as on that the app treats + * as off. + */ export function isTruthy(value: string | undefined): boolean { - return value !== undefined && ['true', '1', 'yes', 'on'].includes(value.toLowerCase()) + if (value === undefined) return false + return value.toLowerCase() === 'true' || value === '1' } diff --git a/scripts/setup/modes/compose.ts b/scripts/setup/modes/compose.ts index 861634a3ca..d0a036a085 100644 --- a/scripts/setup/modes/compose.ts +++ b/scripts/setup/modes/compose.ts @@ -15,7 +15,7 @@ import { promptStorage, promptUnlocks, } from '../steps.ts' -import { theme } from '../theme.ts' +import { glyph, theme } from '../theme.ts' interface BusyPort { port: number @@ -158,7 +158,7 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom const realtimeHealthy = appHealthy && (await waitFor(() => httpHealth('http://localhost:3002/health'), 60_000, 2000)) if (!appHealthy || !realtimeHealthy) { - spin.stop(`${theme.error('✗')} services did not become healthy`) + spin.stop(`${glyph.fail} services did not become healthy`) throw new SetupError( `${!appHealthy ? 'the app (:3000)' : 'realtime (:3002)'} never answered its health check.`, [ diff --git a/scripts/setup/modes/dev.ts b/scripts/setup/modes/dev.ts index ef28027de2..4bc19d04b0 100644 --- a/scripts/setup/modes/dev.ts +++ b/scripts/setup/modes/dev.ts @@ -18,7 +18,7 @@ import { promptStorage, promptUnlocks, } from '../steps.ts' -import { theme } from '../theme.ts' +import { glyph, theme } from '../theme.ts' const APP_URL = 'http://localhost:3000' @@ -38,7 +38,7 @@ async function runMigrations(dsn: string): Promise { spin.stop('Migrations applied') return } - spin.stop(`${theme.error('✗')} migrations failed`) + spin.stop(`${glyph.fail} migrations failed`) const error = truncate(`${result.stdout}\n${result.stderr}`.trim(), 2000) const probe = await pgProbe(dsn) const applied = probe.ok ? (probe.migrations?.applied ?? 0) : 0 diff --git a/scripts/setup/modes/k8s.ts b/scripts/setup/modes/k8s.ts index 5b0def520d..d4e908f6ec 100644 --- a/scripts/setup/modes/k8s.ts +++ b/scripts/setup/modes/k8s.ts @@ -5,7 +5,7 @@ import { ensureDocker } from '../docker.ts' import { generateSecret, ROOT } from '../env-files.ts' import { SetupError } from '../errors.ts' import * as p from '../prompter.ts' -import { theme } from '../theme.ts' +import { glyph, theme } from '../theme.ts' const RELEASE = 'sim-dev' const NAMESPACE = 'sim-dev' @@ -153,7 +153,7 @@ export async function runK8sMode(detection: Detection): Promise { secretValues(secrets) ) } catch (error) { - spin.stop(`${theme.error('✗')} helm install failed`) + spin.stop(`${glyph.fail} helm install failed`) throw new SetupError(getErrorMessage(error), [ `pod status: ${theme.command(`kubectl -n ${NAMESPACE} get pods`)}`, `stuck pods: ${theme.command(`kubectl -n ${NAMESPACE} describe pod | tail -20`)}`, @@ -169,7 +169,7 @@ export async function runK8sMode(detection: Detection): Promise { cwd: ROOT, }) if (test.status !== 0) { - testSpin.stop(`${theme.error('✗')} helm test failed`) + testSpin.stop(`${glyph.fail} helm test failed`) throw new SetupError(`helm test failed:\n${test.stdout}${test.stderr}`, [ `pod status: ${theme.command(`kubectl -n ${NAMESPACE} get pods`)}`, `app logs: ${theme.command(`kubectl -n ${NAMESPACE} logs deploy/${RELEASE}-app --tail 50`)}`, diff --git a/scripts/setup/probes.ts b/scripts/setup/probes.ts index 8b54f0db18..7c86e0063b 100644 --- a/scripts/setup/probes.ts +++ b/scripts/setup/probes.ts @@ -14,7 +14,7 @@ export interface PgProbeResult { migrations?: { applied: number | null; journal: number } } -export function journalMigrationCount(): number { +function journalMigrationCount(): number { const journalPath = path.join(ROOT, 'packages/db/migrations/meta/_journal.json') const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as { entries: unknown[] } return journal.entries.length diff --git a/scripts/setup/prompter.ts b/scripts/setup/prompter.ts index f9fe99b93d..705d850ac2 100644 --- a/scripts/setup/prompter.ts +++ b/scripts/setup/prompter.ts @@ -104,12 +104,5 @@ export function note(message: string, title?: string): void { clack.note(message, title && isRich() ? theme.heading(title) : title) } -export const intro = clack.intro export const outro = clack.outro export const log = clack.log -export const isCancel = clack.isCancel - -export function cancelAndExit(): never { - clack.cancel('Setup cancelled.') - exitWith(130) -} diff --git a/scripts/setup/redis.ts b/scripts/setup/redis.ts index 05e9b88103..f7ac9780d5 100644 --- a/scripts/setup/redis.ts +++ b/scripts/setup/redis.ts @@ -5,7 +5,7 @@ import { ensureDocker } from './docker.ts' import { SetupError } from './errors.ts' import { redisPing, waitFor } from './probes.ts' import * as p from './prompter.ts' -import { theme } from './theme.ts' +import { glyph, theme } from './theme.ts' const LOCAL_URL = 'redis://localhost:6379' @@ -25,7 +25,7 @@ async function pingWithSpinner(url: string, label: string): Promise { const spin = p.spinner() spin.start(label) const ping = await redisPing(url) - spin.stop(ping.ok ? 'Redis reachable' : `${theme.warn('!')} ${ping.error}`) + spin.stop(ping.ok ? 'Redis reachable' : `${glyph.warn} ${ping.error}`) return ping.ok } @@ -49,7 +49,7 @@ async function startManagedRedis(detection: Detection): Promise { spin.stop( healthy ? `Redis running in ${REDIS_CONTAINER} on :${hostPort}` - : `${theme.error('✗')} container did not become healthy` + : `${glyph.fail} container did not become healthy` ) if (!healthy) { throw new SetupError('the Redis container failed to start.', [ diff --git a/scripts/setup/steps.ts b/scripts/setup/steps.ts index 544eb9d4eb..a6969bb3cc 100644 --- a/scripts/setup/steps.ts +++ b/scripts/setup/steps.ts @@ -1,25 +1,17 @@ import { browserKeyFlow } from './cli-auth.ts' import type { Detection } from './detect.ts' -import { type EnvFile, generateSecret, isPlaceholder, isTruthy, SECRET_KEYS } from './env-files.ts' +import { + type EnvFile, + generateSecret, + isPlaceholder, + isTruthy, + isUsableSecret, + SECRET_KEYS, +} from './env-files.ts' import * as p from './prompter.ts' import { link, theme } from './theme.ts' import { FLAG_TWINS, hasMailProvider, LOGIN_PROVIDERS, SELF_HOST_UNLOCKS } from './twins.ts' -/** - * `ENCRYPTION_KEY` and `API_ENCRYPTION_KEY` are read as raw AES-256 material, - * so the app requires exactly 64 hex characters and throws on anything else - * (`lib/core/security/encryption.ts`, `lib/api-key/crypto.ts`). A merely-long - * passphrase passes a length check here and then fails every encryption path at - * runtime, so those two are validated on format rather than length. - */ -const HEX_KEY_PATTERN = /^[0-9a-f]{64}$/i -const HEX_SECRET_KEYS = new Set(['ENCRYPTION_KEY', 'API_ENCRYPTION_KEY']) - -function isUsableSecret(key: string, value: string): boolean { - if (isPlaceholder(value)) return false - return HEX_SECRET_KEYS.has(key) ? HEX_KEY_PATTERN.test(value) : value.length >= 32 -} - /** Reuses existing valid secrets (never regenerates them) and generates the rest. */ export function collectSecrets(existing: EnvFile): Record { const secrets: Record = {} diff --git a/scripts/setup/wizard.ts b/scripts/setup/wizard.ts index 869df140f0..addbaa19da 100644 --- a/scripts/setup/wizard.ts +++ b/scripts/setup/wizard.ts @@ -105,11 +105,14 @@ async function finalVerify(): Promise { } export async function runWizard(flags: WizardFlags): Promise { + // Detection is ~600ms of subprocess work and the banner is ~600ms of animation + // with nothing to do — overlap them so the spinner below usually resolves at once. + const detecting = runDetection() await showBanner() const spin = p.spinner() spin.start('Looking at what you already have…') - const detection = await runDetection() + const detection = await detecting spin.stop( `Detected: docker ${detection.dockerRunning ? '✓' : '✗'} · postgres ${detection.postgresPortOpen ? '✓' : '✗'} · ` + `${detection.shellLlmKeys.length} shell LLM key${detection.shellLlmKeys.length === 1 ? '' : 's'}` +