Files

226 lines
8.5 KiB
JavaScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
//
// `pnpm dev`: the whole app on this machine, against the fake platform, with demo data.
//
// 1. Postgres with the three roles a linked managed database exposes, in Docker (compose.dev.yaml),
// unless POSTGRES_URL, POSTGRES_READER_URL and POSTGRES_MIGRATE_URL are set in your shell
// 2. the fake platform of @conusai-kit/testing -- Blob, OCR, the AI gateway, audit, usage -- in this process
// 3. the libraries and the worker, built; then the migrate job, then the demo data (`pnpm seed`)
// 4. the worker, and the web shell under `next dev`
//
// It reads no file: a value you want different is a variable in your shell (`DEV_WEB_PORT=3200 pnpm dev`,
// `POSTGRES_URL=... pnpm dev`). The values it uses are printed. The fake platform is selected on purpose
// (CONUSAI_FAKE_PLATFORM=1) and the processes run with NODE_ENV=development, which is the only mode
// the app accepts that switch in (config/src/env.ts). Ctrl+C stops everything but Postgres, whose data
// stays; `docker compose -f compose.yaml -f compose.dev.yaml down` stops that too (add -v to drop the data).
import { spawn, spawnSync } from "node:child_process"
import { createHash, randomBytes } from "node:crypto"
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { basename, dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { startFakePlatform, validReply } from "@conusai-kit/testing/fake-platform"
const root = join(dirname(fileURLToPath(import.meta.url)), "..")
const say = (line) => console.log(`dev: ${line}`)
const port = (name, fallback) => {
const value = Number.parseInt(process.env[name] ?? "", 10)
return Number.isInteger(value) && value > 0 && value < 65_536 ? value : fallback
}
const ports = {
web: port("DEV_WEB_PORT", 3000),
platform: port("DEV_PLATFORM_PORT", 4010),
postgres: port("DEV_POSTGRES_PORT", 55_433),
}
/** The session secret, made once and kept (`.dev/` is ignored), so a restart does not sign everybody out. */
function sessionSecret() {
if (process.env.AUTH_SESSION_SECRET) return process.env.AUTH_SESSION_SECRET
const file = join(root, ".dev", "session-secret")
if (existsSync(file)) return readFileSync(file, "utf8").trim()
mkdirSync(dirname(file), { recursive: true })
const secret = randomBytes(32).toString("base64url")
writeFileSync(file, `${secret}\n`, { mode: 0o600 })
return secret
}
const url = (role) => `postgresql://appdev_${role}:appdev_${role}@127.0.0.1:${ports.postgres}/appdev`
const ownDatabase = ["POSTGRES_URL", "POSTGRES_READER_URL", "POSTGRES_MIGRATE_URL"].every((name) => process.env[name])
const platformUrl = `http://127.0.0.1:${ports.platform}`
const environment = {
...process.env,
NODE_ENV: "development",
CONUSAI_FAKE_PLATFORM: "1",
CONUSAI_API_URL: `${platformUrl}/api`,
CONUSAI_API_TOKEN: "dev-token",
CONUSAI_PROJECT_ID: "1",
CONUSAI_ORG_ID: "dev",
CONUSAI_PUBLIC_URL: `http://127.0.0.1:${ports.web}`,
CONUSAI_RELEASE: "dev",
POSTGRES_URL: process.env.POSTGRES_URL ?? url("app"),
POSTGRES_READER_URL: process.env.POSTGRES_READER_URL ?? url("reader"),
POSTGRES_MIGRATE_URL: process.env.POSTGRES_MIGRATE_URL ?? url("owner"),
AUTH_SESSION_SECRET: sessionSecret(),
AUTH_DEV_LOGIN: "1",
AUTH_INSECURE_COOKIES: "1",
DEV_POSTGRES_PORT: String(ports.postgres),
// The development Postgres is the pgvector image (compose.dev.yaml), so the knowledge module's
// vectors live there unless the shell chose otherwise.
KNOWLEDGE_VECTOR_STORE: process.env.KNOWLEDGE_VECTOR_STORE ?? "pgvector",
}
/** The environment of every process but the migrate job: the owner's URL is the migrate job's alone. */
const { POSTGRES_MIGRATE_URL: ownerUrl, ...withoutOwner } = environment
/** Runs a command to its end, printing what it prints; stops the run when it fails. */
function run(label, command, args, options = {}) {
say(label)
const result = spawnSync(command, args, { cwd: root, env: withoutOwner, stdio: "inherit", ...options })
if (result.status !== 0) {
console.error(`dev: ${label} failed${result.error ? `: ${result.error.message}` : ""}`)
process.exit(result.status ?? 1)
}
}
const compose = [
"compose",
"-p",
`${basename(root)}-dev-${createHash("sha1").update(root).digest("hex").slice(0, 6)}`,
"-f",
"compose.yaml",
"-f",
"compose.dev.yaml",
]
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
async function startPostgres() {
if (ownDatabase) {
say("using the Postgres your POSTGRES_URL, POSTGRES_READER_URL and POSTGRES_MIGRATE_URL name")
return
}
if (spawnSync("docker", ["info"], { stdio: "ignore" }).status !== 0) {
console.error(
"dev: Docker is not running, and Postgres is started in Docker. Start it, or set POSTGRES_URL, POSTGRES_READER_URL and POSTGRES_MIGRATE_URL to a database with the three roles of dev/postgres-roles.sql.",
)
process.exit(1)
}
run(`starting Postgres on 127.0.0.1:${ports.postgres}`, "docker", [...compose, "up", "-d", "--wait", "postgres"], {
env: environment, // compose interpolates every service of the file, the migrate job's owner URL included
})
// Healthy means the server answers; the roles are created by the init script, which restarts the
// server. "Ready" is the app's own login working.
for (let attempt = 0; ; attempt += 1) {
const probe = spawnSync(
"docker",
[
...compose,
"exec",
"-T",
"postgres",
"psql",
"-U",
"appdev_app",
"-h",
"127.0.0.1",
"-d",
"appdev",
"-c",
"select 1",
],
{ cwd: root, env: withoutOwner, stdio: "ignore" },
)
if (probe.status === 0) return
if (attempt > 120) {
console.error("dev: Postgres did not accept the app's login in a minute")
process.exit(1)
}
await sleep(500)
}
}
const children = []
function start(name, command, args, cwd) {
const child = spawn(command, args, { cwd, env: withoutOwner, detached: true, stdio: ["ignore", "pipe", "pipe"] })
for (const stream of [child.stdout, child.stderr]) {
let pending = ""
stream.on("data", (chunk) => {
pending += chunk.toString("utf8")
const lines = pending.split("\n")
pending = lines.pop() ?? ""
for (const line of lines) console.log(`[${name}] ${line}`)
})
}
child.on("exit", (code, signal) => {
if (!stopping) {
console.error(`dev: ${name} stopped (${signal ?? code}); stopping the rest`)
void stop(1)
}
})
children.push(child)
}
let stopping = false
let platform
async function stop(code = 0) {
if (stopping) return
stopping = true
for (const child of children) {
try {
if (child.pid !== undefined) process.kill(-child.pid, "SIGTERM")
} catch {
// already gone
}
}
await platform?.close().catch(() => undefined)
process.exit(code)
}
process.on("SIGINT", () => void stop(0))
process.on("SIGTERM", () => void stop(0))
await startPostgres()
platform = await startFakePlatform(ports.platform)
// Nothing scripts the gateway in a development run, so a structured request is answered with the
// smallest value its schema accepts and what is built on a model runs to its end.
platform.ai.respond(validReply)
say(`the fake platform is at ${platform.url}`)
run("building the libraries and the worker", "pnpm", ["run", "build:libs"])
run("building the worker", "pnpm", ["--filter", "@app/worker", "run", "build"])
run("migrating the database", "node", ["apps/worker/dist/migrate.js"], {
env: { ...withoutOwner, POSTGRES_MIGRATE_URL: ownerUrl },
})
run("writing the demo data", "node", ["apps/worker/dist/seed-main.js"])
start("worker", "node", ["apps/worker/dist/main.js"], root)
start(
"web",
process.execPath,
["node_modules/next/dist/bin/next", "dev", "-p", String(ports.web), "-H", "127.0.0.1"],
join(root, "apps", "web"),
)
for (let attempt = 0; ; attempt += 1) {
try {
if ((await fetch(`${environment.CONUSAI_PUBLIC_URL}/api/ready`)).ok) break
} catch {
// not yet
}
if (attempt > 240) {
console.error("dev: the web shell was not ready in two minutes")
await stop(1)
}
await sleep(500)
}
say("ready. Sign in as one of the demo people:")
for (const [name, email] of [
["Ada, owner of Acme Review and admin of Northwind", "ada@example.test"],
["Grace, owner of Northwind", "grace@example.test"],
["Linus, a viewer in Acme Review", "linus@example.test"],
]) {
say(` ${environment.CONUSAI_PUBLIC_URL}/auth/dev-login?email=${email} ${name}`)
}
say("Ctrl+C stops it.")