226 lines
8.5 KiB
JavaScript
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.")
|