Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

362 lines
13 KiB
TypeScript

import { spawnSync } from 'node:child_process'
import { DB_CONTAINER, type Detection } from './detect.ts'
import { ensureDocker } from './docker.ts'
import { generateSecret } from './env-files.ts'
import { SetupError } from './errors.ts'
import { pgProbe, waitFor } from './probes.ts'
import * as p from './prompter.ts'
import { glyph, theme } from './theme.ts'
const DEFAULT_DSN = 'postgresql://postgres:postgres@localhost:5432/simstudio'
/** 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) {
throw new Error(`docker ${args[0]} failed: ${result.stderr.trim() || result.stdout.trim()}`)
}
}
function dockerOutput(args: string[]): string | null {
const result = spawnSync('docker', args, { encoding: 'utf8' })
return result.status === 0 ? result.stdout.trim() : null
}
interface ManagedContainer {
running: boolean
dsn: string
}
/**
* Recovers everything needed to reach an existing managed container from Docker
* itself, so re-running the wizard is idempotent.
*
* Both facts used to be unrecoverable: the password is generated at creation and
* only lived in the env files the run wrote, and the host port varies (5433 when
* 5432 is taken). Reading them back turns "a container already exists" from a
* fatal name collision into a reuse.
*/
function inspectManagedContainer(): ManagedContainer | null {
const running = dockerOutput(['inspect', DB_CONTAINER, '--format', '{{.State.Running}}'])
if (running === null) return null
const env = dockerOutput([
'inspect',
DB_CONTAINER,
'--format',
'{{range .Config.Env}}{{println .}}{{end}}',
])
const password = env
?.split('\n')
.find((line) => line.startsWith('POSTGRES_PASSWORD='))
?.slice('POSTGRES_PASSWORD='.length)
if (!password) return null
// `docker port` only reports a published port while the container runs; the
// static config carries it either way.
const hostPort = dockerOutput([
'inspect',
DB_CONTAINER,
'--format',
'{{(index .HostConfig.PortBindings "5432/tcp" 0).HostPort}}',
])
if (!hostPort) return null
return {
running: running === 'true',
// 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),
}
}
/** Starts the container if needed and returns its DSN, or null if it won't answer. */
async function reuseManagedContainer(container: ManagedContainer): Promise<string | null> {
if (!container.running) docker(['start', DB_CONTAINER])
const spin = p.spinner()
spin.start(`Reusing existing ${DB_CONTAINER} container…`)
const healthy = await waitFor(async () => (await pgProbe(container.dsn)).ok, 30_000, 1500)
spin.stop(
healthy
? `Postgres running in ${DB_CONTAINER} on :${new URL(container.dsn).port}`
: `${glyph.warn} ${DB_CONTAINER} exists but is not answering`
)
return healthy ? container.dsn : null
}
async function probeWithSpinner(dsn: string, label: string): Promise<boolean> {
const spin = p.spinner()
spin.start(label)
const probe = await pgProbe(dsn)
if (probe.ok && probe.pgvectorAvailable === false) {
spin.stop(`${glyph.warn} connected, but pgvector is missing on that Postgres`)
return false
}
spin.stop(probe.ok ? 'database reachable (pgvector available)' : `${glyph.warn} ${probe.error}`)
return probe.ok
}
async function promptExternalDsn(): Promise<string> {
for (;;) {
const dsn = await p.text({
message: 'Postgres connection string (needs the pgvector extension)',
placeholder: DEFAULT_DSN,
validate: (value) => {
if (!value) return 'required'
try {
new URL(value)
return undefined
} catch {
return 'not a valid connection URL'
}
},
})
if (await probeWithSpinner(dsn, 'Testing connection…')) return dsn
const retry = await p.confirm({
message: 'Connection failed — try a different URL?',
initialValue: true,
})
if (!retry) {
throw new SetupError('no usable Postgres.', [
'install Docker — the wizard manages a pgvector container for you',
'or bring any Postgres with the pgvector extension and re-run with its connection string',
])
}
}
}
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<string> {
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 —
* the data volume outlives the container, so a silent recreate would quietly
* re-point setup at data the user may not expect.
*/
async function startManagedContainer(detection: Detection): Promise<string> {
const existing = inspectManagedContainer()
if (existing) {
const reused = await reuseManagedContainer(existing)
if (reused) return reused
const recreate = await p.confirm({
message: `${DB_CONTAINER} exists but is not answering. Remove and recreate it? Its data volume is kept.`,
initialValue: true,
})
if (!recreate) {
throw new SetupError(`the existing ${DB_CONTAINER} container is not usable.`, [
`inspect: ${theme.command(`docker logs ${DB_CONTAINER}`)}`,
`remove it: ${theme.command(`docker rm -f ${DB_CONTAINER}`)}`,
`start clean: ${theme.command(`docker volume rm ${DB_VOLUME}`)} drops its data too`,
])
}
docker(['rm', '-f', DB_CONTAINER])
}
const hostPort = detection.postgresPortOpen ? 5433 : 5432
// POSTGRES_PASSWORD only applies when initdb runs on an empty data directory.
// The volume outlives the container (sim down keeps it, so does `docker rm`),
// so once the container is gone the password it was created with is
// unrecoverable — inspectManagedContainer reads it from the container, not the
// volume. Running with a freshly generated password against an initialized
// volume starts a healthy Postgres that rejects every connection with
// "password authentication failed", which surfaces as a bogus "container did
// not become healthy". Ask instead of guessing.
const password = volumeInitialized()
? await resolveExistingVolume()
: generateSecret().slice(0, 24)
// 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',
'--name',
DB_CONTAINER,
'--label',
'managed-by=sim-setup',
'-v',
`${DB_VOLUME}:/var/lib/postgresql/data`,
'-e',
`POSTGRES_PASSWORD=${password}`,
'-e',
'POSTGRES_DB=simstudio',
'-p',
`${hostPort}:5432`,
'pgvector/pgvector:pg17',
])
const spin = p.spinner()
spin.start(`Starting ${DB_CONTAINER} container on :${hostPort}…`)
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}`,
[
`inspect: ${theme.command(`docker logs ${DB_CONTAINER}`)}`,
`remove and retry: ${theme.command(`docker rm -f ${DB_CONTAINER}`)} then re-run the wizard`,
]
)
}
spin.stop(`Postgres running in ${DB_CONTAINER} on :${hostPort}`)
return dsn
}
/**
* The mode-B database ladder: reuse a working DSN, offer (never silently adopt)
* a Postgres already on 5432, start/reuse the wizard-managed pgvector
* container, or take an external DSN. Adopting an existing database is always
* an explicit choice — migrations run against whatever is chosen here.
*/
export async function resolveDatabase(detection: Detection, existingDsn?: string): Promise<string> {
if (existingDsn && (await probeWithSpinner(existingDsn, 'Testing existing DATABASE_URL…'))) {
return existingDsn
}
// Any managed container, running or stopped — a running one used to fall
// through to `docker run` and die on the name collision.
if (detection.dbContainer?.managed) {
const existing = inspectManagedContainer()
const reused = existing && (await reuseManagedContainer(existing))
if (reused) return reused
}
if (
detection.postgresPortOpen &&
(await probeWithSpinner(DEFAULT_DSN, 'Postgres found on :5432 — testing default credentials…'))
) {
const adopt = await p.confirm({
message: `Use the existing Postgres on :5432? Migrations will run against its "simstudio" database — if that's your dev data, say no and get an isolated container instead.`,
initialValue: false,
})
if (adopt) return DEFAULT_DSN
}
const dockerAvailable = await ensureDocker(false)
const options: p.SelectOption<'container' | 'external'>[] = []
if (dockerAvailable) {
options.push({
value: 'container',
label: 'Start a Postgres container for me',
hint: `pgvector/pgvector:pg17, persistent volume, named ${DB_CONTAINER} — recommended`,
})
}
options.push({
value: 'external',
label: 'Use an existing Postgres',
hint: 'paste a connection string (needs pgvector)',
})
if (!dockerAvailable) {
p.log.warn('Docker is not available, so the wizard cannot manage a Postgres container for you.')
}
const choice = await p.select({ message: 'Where should the database live?', options })
return choice === 'container' ? startManagedContainer(detection) : promptExternalDsn()
}