mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(setup): reuse shared helpers, parallelize probes, drop dead code
- PKCE verifier/state/pairing code now use generateSecureToken, generateRandomHex and generateShortId instead of hand-rolled randomBytes; the pairing loop's modulo was unbiased only because 256 % 32 == 0 - new sha256Base64Url in @sim/security/hash so both sides of the PKCE exchange derive the challenge from one implementation - isUsableSecret moved beside SECRET_KEYS so setup and doctor apply the same rule; doctor previously passed a key setup would replace - isTruthy narrowed to true/1, matching the app it claims to mirror — it accepted yes/on, so a flag could read on in doctor and off in the app - checkLive runs its five probes concurrently (~17s serial worst case) - detection overlaps the banner animation instead of queueing behind it - glyph.fail/glyph.warn at 13 sites that bypassed the constant; removed unused prompter exports, a dead ENV_PATHS re-export, and an unused export keyword
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
import { createHash } from 'node:crypto'
|
|
||||||
import { safeCompare } from '@sim/security/compare'
|
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 { generateShortId } from '@sim/utils/id'
|
||||||
import { getRedisClient } from '@/lib/core/config/redis'
|
import { getRedisClient } from '@/lib/core/config/redis'
|
||||||
|
|
||||||
@@ -40,9 +39,9 @@ function codeKey(code: string): string {
|
|||||||
return `cli:auth:code:${sha256Hex(code)}`
|
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 {
|
function challengeFor(verifier: string): string {
|
||||||
return createHash('sha256').update(verifier).digest('base64url')
|
return sha256Base64Url(verifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the plaintext code, which is never stored. */
|
/** Returns the plaintext code, which is never stored. */
|
||||||
|
|||||||
@@ -15,3 +15,18 @@ export function sha256Hex(input: string | Uint8Array): string {
|
|||||||
}
|
}
|
||||||
return hash.digest('hex')
|
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')
|
||||||
|
}
|
||||||
|
|||||||
+71
-50
@@ -5,9 +5,11 @@ import {
|
|||||||
generateSecret,
|
generateSecret,
|
||||||
isPlaceholder,
|
isPlaceholder,
|
||||||
isTruthy,
|
isTruthy,
|
||||||
|
isUsableSecret,
|
||||||
readEnvFile,
|
readEnvFile,
|
||||||
SECRET_KEYS,
|
SECRET_KEYS,
|
||||||
SHARED_KEYS,
|
SHARED_KEYS,
|
||||||
|
secretRequirement,
|
||||||
writeEnvValues,
|
writeEnvValues,
|
||||||
} from './env-files.ts'
|
} from './env-files.ts'
|
||||||
import { httpHealth, pgProbe, redisPing } from './probes.ts'
|
import { httpHealth, pgProbe, redisPing } from './probes.ts'
|
||||||
@@ -153,11 +155,11 @@ function checkSchema(ctx: CheckContext): Finding[] {
|
|||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (MIN_32_KEYS.has(key) && value.length < 32) {
|
if (MIN_32_KEYS.has(key) && !isUsableSecret(key, value)) {
|
||||||
findings.push({
|
findings.push({
|
||||||
group: 'schema',
|
group: 'schema',
|
||||||
status: 'fail',
|
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)',
|
fix: 'generate a new one with `openssl rand -hex 32` (rotating it invalidates existing sessions/encrypted data)',
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
@@ -436,9 +438,8 @@ function checkCoherence(ctx: CheckContext): Finding[] {
|
|||||||
return findings
|
return findings
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkLive(ctx: CheckContext): Promise<Finding[]> {
|
async function checkDatabase(sim: EnvFile): Promise<Finding[]> {
|
||||||
const findings: Finding[] = []
|
const findings: Finding[] = []
|
||||||
const sim = ctx.env.sim
|
|
||||||
const dsn = sim.vars.get('DATABASE_URL')
|
const dsn = sim.vars.get('DATABASE_URL')
|
||||||
const dsnPassword = (() => {
|
const dsnPassword = (() => {
|
||||||
try {
|
try {
|
||||||
@@ -497,55 +498,75 @@ async function checkLive(ctx: CheckContext): Promise<Finding[]> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
return findings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkRedis(sim: EnvFile): Promise<Finding[]> {
|
||||||
|
const redisUrl = sim.vars.get('REDIS_URL')
|
||||||
|
if (!redisUrl) return []
|
||||||
|
const ping = await redisPing(redisUrl)
|
||||||
|
return [
|
||||||
|
ping.ok
|
||||||
|
? { group: 'live', status: 'pass', message: 'redis reachable' }
|
||||||
|
: {
|
||||||
|
group: 'live',
|
||||||
|
status: 'fail',
|
||||||
|
message: `redis unreachable: ${ping.error}`,
|
||||||
|
fix: 'fix REDIS_URL or remove it (optional for single-replica)',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkService(label: string, port: number, url: string): Promise<Finding[]> {
|
||||||
|
if (!(await portOpen(port))) {
|
||||||
|
return [{ group: 'live', status: 'skip', message: `${label}: not running on :${port}` }]
|
||||||
|
}
|
||||||
|
if (await httpHealth(url)) {
|
||||||
|
return [{ group: 'live', status: 'pass', message: `${label} healthy on :${port}` }]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
group: 'live',
|
||||||
|
status: 'fail',
|
||||||
|
message: `${label}: something is on :${port} but ${url} is not answering`,
|
||||||
|
fix: 'check the dev server logs',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkOllama(sim: EnvFile): Promise<Finding[]> {
|
||||||
|
const ollamaUrl = sim.vars.get('OLLAMA_URL')
|
||||||
|
if (!ollamaUrl) return []
|
||||||
|
return [
|
||||||
|
(await httpHealth(`${ollamaUrl.replace(/\/$/, '')}/api/tags`))
|
||||||
|
? { group: 'live', status: 'pass', message: 'ollama reachable' }
|
||||||
|
: {
|
||||||
|
group: 'live',
|
||||||
|
status: 'warn',
|
||||||
|
message: 'OLLAMA_URL is set but Ollama is not answering',
|
||||||
|
fix: 'start Ollama or remove OLLAMA_URL',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The five probes are independent, so they run concurrently — serially this is
|
||||||
|
* the sum of every timeout (~17s worst case) on a command whose whole job is to
|
||||||
|
* tell you what's broken. Results are concatenated in a fixed order so the
|
||||||
|
* report stays deterministic regardless of which probe settles first.
|
||||||
|
*/
|
||||||
|
async function checkLive(ctx: CheckContext): Promise<Finding[]> {
|
||||||
|
const sim = ctx.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<Finding[]> {
|
export async function runChecks(ctx: CheckContext, groups?: CheckGroup[]): Promise<Finding[]> {
|
||||||
const findings: Finding[] = [
|
const findings: Finding[] = [
|
||||||
...checkFiles(ctx),
|
...checkFiles(ctx),
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { spawnSync } from 'node:child_process'
|
import { spawnSync } from 'node:child_process'
|
||||||
import { createHash, randomBytes } from 'node:crypto'
|
|
||||||
import http from 'node:http'
|
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 * as p from './prompter.ts'
|
||||||
import { link, theme } from './theme.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.
|
* through the browser — so a code intercepted in transit cannot be redeemed.
|
||||||
*/
|
*/
|
||||||
function createPkcePair(): { verifier: string; challenge: string } {
|
function createPkcePair(): { verifier: string; challenge: string } {
|
||||||
const verifier = randomBytes(32).toString('base64url')
|
const verifier = generateSecureToken(32)
|
||||||
return { verifier, challenge: createHash('sha256').update(verifier).digest('base64url') }
|
return { verifier, challenge: sha256Base64Url(verifier) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** No O/0 or I/1 — this exists to be compared by eye against a browser tab. */
|
/** 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.
|
* a code you don't recognise, the approval isn't yours.
|
||||||
*/
|
*/
|
||||||
function createPairingCode(): string {
|
function createPairingCode(): string {
|
||||||
const bytes = randomBytes(8)
|
const chars = generateShortId(8, PAIRING_ALPHABET)
|
||||||
const chars = Array.from(bytes, (byte) => PAIRING_ALPHABET[byte % PAIRING_ALPHABET.length])
|
return `${chars.slice(0, 4)}-${chars.slice(4)}`
|
||||||
return `${chars.slice(0, 4).join('')}-${chars.slice(4).join('')}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CodeListener {
|
export interface CodeListener {
|
||||||
@@ -56,7 +58,7 @@ export interface CodeListener {
|
|||||||
* mismatch.
|
* mismatch.
|
||||||
*/
|
*/
|
||||||
export function startCodeListener(origin: string): Promise<CodeListener> {
|
export function startCodeListener(origin: string): Promise<CodeListener> {
|
||||||
const state = randomBytes(16).toString('hex')
|
const state = generateRandomHex(32)
|
||||||
const { verifier, challenge } = createPkcePair()
|
const { verifier, challenge } = createPkcePair()
|
||||||
const pairingCode = createPairingCode()
|
const pairingCode = createPairingCode()
|
||||||
|
|
||||||
|
|||||||
+5
-9
@@ -5,7 +5,7 @@ import { generateSecret } from './env-files.ts'
|
|||||||
import { SetupError } from './errors.ts'
|
import { SetupError } from './errors.ts'
|
||||||
import { pgProbe, waitFor } from './probes.ts'
|
import { pgProbe, waitFor } from './probes.ts'
|
||||||
import * as p from './prompter.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'
|
const DEFAULT_DSN = 'postgresql://postgres:postgres@localhost:5432/simstudio'
|
||||||
|
|
||||||
@@ -21,12 +21,10 @@ async function probeWithSpinner(dsn: string, label: string): Promise<boolean> {
|
|||||||
spin.start(label)
|
spin.start(label)
|
||||||
const probe = await pgProbe(dsn)
|
const probe = await pgProbe(dsn)
|
||||||
if (probe.ok && probe.pgvectorAvailable === false) {
|
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
|
return false
|
||||||
}
|
}
|
||||||
spin.stop(
|
spin.stop(probe.ok ? 'database reachable (pgvector available)' : `${glyph.warn} ${probe.error}`)
|
||||||
probe.ok ? 'database reachable (pgvector available)' : `${theme.warn('!')} ${probe.error}`
|
|
||||||
)
|
|
||||||
return probe.ok
|
return probe.ok
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +82,7 @@ async function startManagedContainer(detection: Detection): Promise<string> {
|
|||||||
spin.start(`Starting ${DB_CONTAINER} container on :${hostPort}…`)
|
spin.start(`Starting ${DB_CONTAINER} container on :${hostPort}…`)
|
||||||
const healthy = await waitFor(async () => (await pgProbe(dsn)).ok, 45_000, 1500)
|
const healthy = await waitFor(async () => (await pgProbe(dsn)).ok, 45_000, 1500)
|
||||||
if (!healthy) {
|
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' })
|
const logs = spawnSync('docker', ['logs', '--tail', '20', DB_CONTAINER], { encoding: 'utf8' })
|
||||||
throw new SetupError(
|
throw new SetupError(
|
||||||
`the Postgres container failed to start. Last logs:\n${logs.stdout}${logs.stderr}`,
|
`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()
|
const spin = p.spinner()
|
||||||
spin.start(`Starting existing ${DB_CONTAINER} container…`)
|
spin.start(`Starting existing ${DB_CONTAINER} container…`)
|
||||||
const healthy = await waitFor(async () => (await pgProbe(existingDsn)).ok, 30_000, 1500)
|
const healthy = await waitFor(async () => (await pgProbe(existingDsn)).ok, 30_000, 1500)
|
||||||
spin.stop(
|
spin.stop(healthy ? `${DB_CONTAINER} running` : `${glyph.fail} ${DB_CONTAINER} did not come up`)
|
||||||
healthy ? `${DB_CONTAINER} running` : `${theme.error('✗')} ${DB_CONTAINER} did not come up`
|
|
||||||
)
|
|
||||||
if (!healthy) throw new Error(`${DB_CONTAINER} started but is not answering on ${existingDsn}`)
|
if (!healthy) throw new Error(`${DB_CONTAINER} started but is not answering on ${existingDsn}`)
|
||||||
return existingDsn
|
return existingDsn
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { spawnSync } from 'node:child_process'
|
import { spawnSync } from 'node:child_process'
|
||||||
import net from 'node:net'
|
import net from 'node:net'
|
||||||
import os from 'node:os'
|
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 MANAGED_LABEL = 'managed-by=sim-setup'
|
||||||
export const DB_CONTAINER = 'sim-postgres'
|
export const DB_CONTAINER = 'sim-postgres'
|
||||||
@@ -147,5 +147,3 @@ export async function runDetection(): Promise<Detection> {
|
|||||||
specs: detectSpecs(dockerRunning),
|
specs: detectSpecs(dockerRunning),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ENV_PATHS }
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process'
|
|||||||
import { SetupError } from './errors.ts'
|
import { SetupError } from './errors.ts'
|
||||||
import { waitFor } from './probes.ts'
|
import { waitFor } from './probes.ts'
|
||||||
import * as p from './prompter.ts'
|
import * as p from './prompter.ts'
|
||||||
import { theme } from './theme.ts'
|
import { glyph, theme } from './theme.ts'
|
||||||
|
|
||||||
const INSTALL_HINTS = [
|
const INSTALL_HINTS = [
|
||||||
'install Docker Desktop: https://docker.com/products/docker-desktop',
|
'install Docker Desktop: https://docker.com/products/docker-desktop',
|
||||||
@@ -56,7 +56,7 @@ export async function ensureDocker(required: boolean): Promise<boolean> {
|
|||||||
const spin = p.spinner()
|
const spin = p.spinner()
|
||||||
spin.start('Waiting for the Docker daemon…')
|
spin.start('Waiting for the Docker daemon…')
|
||||||
const up = await waitFor(async () => daemonUp(), 90_000, 2000)
|
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) {
|
if (!up) {
|
||||||
throw new SetupError('Docker Desktop did not start within 90s.', [
|
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',
|
'first-ever launch needs a GUI license acceptance — open Docker Desktop manually once, then re-run',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { randomBytes } from 'node:crypto'
|
|
||||||
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { generateRandomHex } from '@sim/utils/random'
|
||||||
|
|
||||||
export const ROOT = path.resolve(fileURLToPath(new URL('.', import.meta.url)), '../..')
|
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 {
|
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<string>(['ENCRYPTION_KEY', 'API_ENCRYPTION_KEY'])
|
||||||
|
|
||||||
|
export function isUsableSecret(key: string, value: string): boolean {
|
||||||
|
if (isPlaceholder(value)) return false
|
||||||
|
return HEX_SECRET_KEYS.has(key) ? HEX_KEY_PATTERN.test(value) : value.length >= 32
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable reason a secret is unusable, for doctor's finding message. */
|
||||||
|
export function secretRequirement(key: string): string {
|
||||||
|
return HEX_SECRET_KEYS.has(key)
|
||||||
|
? 'must be exactly 64 hex characters (32-byte AES key)'
|
||||||
|
: 'must be at least 32 characters'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPlaceholder(value: string): boolean {
|
export function isPlaceholder(value: string): boolean {
|
||||||
return PLACEHOLDER_VALUES.has(value) || value.startsWith('your_')
|
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 {
|
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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
promptStorage,
|
promptStorage,
|
||||||
promptUnlocks,
|
promptUnlocks,
|
||||||
} from '../steps.ts'
|
} from '../steps.ts'
|
||||||
import { theme } from '../theme.ts'
|
import { glyph, theme } from '../theme.ts'
|
||||||
|
|
||||||
interface BusyPort {
|
interface BusyPort {
|
||||||
port: number
|
port: number
|
||||||
@@ -158,7 +158,7 @@ export async function runComposeMode(detection: Detection, quick: boolean): Prom
|
|||||||
const realtimeHealthy =
|
const realtimeHealthy =
|
||||||
appHealthy && (await waitFor(() => httpHealth('http://localhost:3002/health'), 60_000, 2000))
|
appHealthy && (await waitFor(() => httpHealth('http://localhost:3002/health'), 60_000, 2000))
|
||||||
if (!appHealthy || !realtimeHealthy) {
|
if (!appHealthy || !realtimeHealthy) {
|
||||||
spin.stop(`${theme.error('✗')} services did not become healthy`)
|
spin.stop(`${glyph.fail} services did not become healthy`)
|
||||||
throw new SetupError(
|
throw new SetupError(
|
||||||
`${!appHealthy ? 'the app (:3000)' : 'realtime (:3002)'} never answered its health check.`,
|
`${!appHealthy ? 'the app (:3000)' : 'realtime (:3002)'} never answered its health check.`,
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
promptStorage,
|
promptStorage,
|
||||||
promptUnlocks,
|
promptUnlocks,
|
||||||
} from '../steps.ts'
|
} from '../steps.ts'
|
||||||
import { theme } from '../theme.ts'
|
import { glyph, theme } from '../theme.ts'
|
||||||
|
|
||||||
const APP_URL = 'http://localhost:3000'
|
const APP_URL = 'http://localhost:3000'
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ async function runMigrations(dsn: string): Promise<void> {
|
|||||||
spin.stop('Migrations applied')
|
spin.stop('Migrations applied')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
spin.stop(`${theme.error('✗')} migrations failed`)
|
spin.stop(`${glyph.fail} migrations failed`)
|
||||||
const error = truncate(`${result.stdout}\n${result.stderr}`.trim(), 2000)
|
const error = truncate(`${result.stdout}\n${result.stderr}`.trim(), 2000)
|
||||||
const probe = await pgProbe(dsn)
|
const probe = await pgProbe(dsn)
|
||||||
const applied = probe.ok ? (probe.migrations?.applied ?? 0) : 0
|
const applied = probe.ok ? (probe.migrations?.applied ?? 0) : 0
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ensureDocker } from '../docker.ts'
|
|||||||
import { generateSecret, ROOT } from '../env-files.ts'
|
import { generateSecret, ROOT } from '../env-files.ts'
|
||||||
import { SetupError } from '../errors.ts'
|
import { SetupError } from '../errors.ts'
|
||||||
import * as p from '../prompter.ts'
|
import * as p from '../prompter.ts'
|
||||||
import { theme } from '../theme.ts'
|
import { glyph, theme } from '../theme.ts'
|
||||||
|
|
||||||
const RELEASE = 'sim-dev'
|
const RELEASE = 'sim-dev'
|
||||||
const NAMESPACE = 'sim-dev'
|
const NAMESPACE = 'sim-dev'
|
||||||
@@ -153,7 +153,7 @@ export async function runK8sMode(detection: Detection): Promise<void> {
|
|||||||
secretValues(secrets)
|
secretValues(secrets)
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
spin.stop(`${theme.error('✗')} helm install failed`)
|
spin.stop(`${glyph.fail} helm install failed`)
|
||||||
throw new SetupError(getErrorMessage(error), [
|
throw new SetupError(getErrorMessage(error), [
|
||||||
`pod status: ${theme.command(`kubectl -n ${NAMESPACE} get pods`)}`,
|
`pod status: ${theme.command(`kubectl -n ${NAMESPACE} get pods`)}`,
|
||||||
`stuck pods: ${theme.command(`kubectl -n ${NAMESPACE} describe pod <name> | tail -20`)}`,
|
`stuck pods: ${theme.command(`kubectl -n ${NAMESPACE} describe pod <name> | tail -20`)}`,
|
||||||
@@ -169,7 +169,7 @@ export async function runK8sMode(detection: Detection): Promise<void> {
|
|||||||
cwd: ROOT,
|
cwd: ROOT,
|
||||||
})
|
})
|
||||||
if (test.status !== 0) {
|
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}`, [
|
throw new SetupError(`helm test failed:\n${test.stdout}${test.stderr}`, [
|
||||||
`pod status: ${theme.command(`kubectl -n ${NAMESPACE} get pods`)}`,
|
`pod status: ${theme.command(`kubectl -n ${NAMESPACE} get pods`)}`,
|
||||||
`app logs: ${theme.command(`kubectl -n ${NAMESPACE} logs deploy/${RELEASE}-app --tail 50`)}`,
|
`app logs: ${theme.command(`kubectl -n ${NAMESPACE} logs deploy/${RELEASE}-app --tail 50`)}`,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export interface PgProbeResult {
|
|||||||
migrations?: { applied: number | null; journal: number }
|
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 journalPath = path.join(ROOT, 'packages/db/migrations/meta/_journal.json')
|
||||||
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as { entries: unknown[] }
|
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as { entries: unknown[] }
|
||||||
return journal.entries.length
|
return journal.entries.length
|
||||||
|
|||||||
@@ -104,12 +104,5 @@ export function note(message: string, title?: string): void {
|
|||||||
clack.note(message, title && isRich() ? theme.heading(title) : title)
|
clack.note(message, title && isRich() ? theme.heading(title) : title)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const intro = clack.intro
|
|
||||||
export const outro = clack.outro
|
export const outro = clack.outro
|
||||||
export const log = clack.log
|
export const log = clack.log
|
||||||
export const isCancel = clack.isCancel
|
|
||||||
|
|
||||||
export function cancelAndExit(): never {
|
|
||||||
clack.cancel('Setup cancelled.')
|
|
||||||
exitWith(130)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ensureDocker } from './docker.ts'
|
|||||||
import { SetupError } from './errors.ts'
|
import { SetupError } from './errors.ts'
|
||||||
import { redisPing, waitFor } from './probes.ts'
|
import { redisPing, waitFor } from './probes.ts'
|
||||||
import * as p from './prompter.ts'
|
import * as p from './prompter.ts'
|
||||||
import { theme } from './theme.ts'
|
import { glyph, theme } from './theme.ts'
|
||||||
|
|
||||||
const LOCAL_URL = 'redis://localhost:6379'
|
const LOCAL_URL = 'redis://localhost:6379'
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ async function pingWithSpinner(url: string, label: string): Promise<boolean> {
|
|||||||
const spin = p.spinner()
|
const spin = p.spinner()
|
||||||
spin.start(label)
|
spin.start(label)
|
||||||
const ping = await redisPing(url)
|
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
|
return ping.ok
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ async function startManagedRedis(detection: Detection): Promise<string> {
|
|||||||
spin.stop(
|
spin.stop(
|
||||||
healthy
|
healthy
|
||||||
? `Redis running in ${REDIS_CONTAINER} on :${hostPort}`
|
? `Redis running in ${REDIS_CONTAINER} on :${hostPort}`
|
||||||
: `${theme.error('✗')} container did not become healthy`
|
: `${glyph.fail} container did not become healthy`
|
||||||
)
|
)
|
||||||
if (!healthy) {
|
if (!healthy) {
|
||||||
throw new SetupError('the Redis container failed to start.', [
|
throw new SetupError('the Redis container failed to start.', [
|
||||||
|
|||||||
+8
-16
@@ -1,25 +1,17 @@
|
|||||||
import { browserKeyFlow } from './cli-auth.ts'
|
import { browserKeyFlow } from './cli-auth.ts'
|
||||||
import type { Detection } from './detect.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 * as p from './prompter.ts'
|
||||||
import { link, theme } from './theme.ts'
|
import { link, theme } from './theme.ts'
|
||||||
import { FLAG_TWINS, hasMailProvider, LOGIN_PROVIDERS, SELF_HOST_UNLOCKS } from './twins.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. */
|
/** Reuses existing valid secrets (never regenerates them) and generates the rest. */
|
||||||
export function collectSecrets(existing: EnvFile): Record<string, string> {
|
export function collectSecrets(existing: EnvFile): Record<string, string> {
|
||||||
const secrets: Record<string, string> = {}
|
const secrets: Record<string, string> = {}
|
||||||
|
|||||||
@@ -105,11 +105,14 @@ async function finalVerify(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runWizard(flags: WizardFlags): Promise<void> {
|
export async function runWizard(flags: WizardFlags): Promise<void> {
|
||||||
|
// Detection is ~600ms of subprocess work and the banner is ~600ms of animation
|
||||||
|
// with nothing to do — overlap them so the spinner below usually resolves at once.
|
||||||
|
const detecting = runDetection()
|
||||||
await showBanner()
|
await showBanner()
|
||||||
|
|
||||||
const spin = p.spinner()
|
const spin = p.spinner()
|
||||||
spin.start('Looking at what you already have…')
|
spin.start('Looking at what you already have…')
|
||||||
const detection = await runDetection()
|
const detection = await detecting
|
||||||
spin.stop(
|
spin.stop(
|
||||||
`Detected: docker ${detection.dockerRunning ? '✓' : '✗'} · postgres ${detection.postgresPortOpen ? '✓' : '✗'} · ` +
|
`Detected: docker ${detection.dockerRunning ? '✓' : '✗'} · postgres ${detection.postgresPortOpen ? '✓' : '✗'} · ` +
|
||||||
`${detection.shellLlmKeys.length} shell LLM key${detection.shellLlmKeys.length === 1 ? '' : 's'}` +
|
`${detection.shellLlmKeys.length} shell LLM key${detection.shellLlmKeys.length === 1 ? '' : 's'}` +
|
||||||
|
|||||||
Reference in New Issue
Block a user