diff --git a/cli/src/checks/database-check.ts b/cli/src/checks/database-check.ts index 864409b0..27fc815e 100644 --- a/cli/src/checks/database-check.ts +++ b/cli/src/checks/database-check.ts @@ -35,23 +35,32 @@ export async function databaseCheck(config: PaperclipConfig): Promise { + fs.mkdirSync(dataDir, { recursive: true }); + }, + }; + } + return { name: "Database", - status: "warn", - message: `PGlite data directory does not exist: ${dataDir}`, - canRepair: true, - repair: () => { - fs.mkdirSync(dataDir, { recursive: true }); - }, + status: "pass", + message: `Embedded PostgreSQL configured at ${dataDir} (port ${config.database.embeddedPostgresPort})`, }; } return { name: "Database", - status: "pass", - message: `PGlite data directory exists: ${dataDir}`, + status: "fail", + message: `Unknown database mode: ${String(config.database.mode)}`, + canRepair: false, + repairHint: "Run `paperclip configure --section database`", }; } diff --git a/cli/src/prompts/database.ts b/cli/src/prompts/database.ts index 4b6de544..28c34c41 100644 --- a/cli/src/prompts/database.ts +++ b/cli/src/prompts/database.ts @@ -5,7 +5,7 @@ export async function promptDatabase(): Promise { const mode = await p.select({ message: "Database mode", options: [ - { value: "pglite" as const, label: "PGlite (embedded, no setup needed)", hint: "recommended" }, + { value: "embedded-postgres" as const, label: "Embedded PostgreSQL (managed locally)", hint: "recommended" }, { value: "postgres" as const, label: "PostgreSQL (external server)" }, ], }); @@ -30,19 +30,43 @@ export async function promptDatabase(): Promise { process.exit(0); } - return { mode: "postgres", connectionString, pgliteDataDir: "./data/pglite" }; + return { + mode: "postgres", + connectionString, + embeddedPostgresDataDir: "./data/embedded-postgres", + embeddedPostgresPort: 54329, + }; } - const pgliteDataDir = await p.text({ - message: "PGlite data directory", - defaultValue: "./data/pglite", - placeholder: "./data/pglite", + const embeddedPostgresDataDir = await p.text({ + message: "Embedded PostgreSQL data directory", + defaultValue: "./data/embedded-postgres", + placeholder: "./data/embedded-postgres", }); - if (p.isCancel(pgliteDataDir)) { + if (p.isCancel(embeddedPostgresDataDir)) { p.cancel("Setup cancelled."); process.exit(0); } - return { mode: "pglite", pgliteDataDir: pgliteDataDir || "./data/pglite" }; + const embeddedPostgresPort = await p.text({ + message: "Embedded PostgreSQL port", + defaultValue: "54329", + placeholder: "54329", + validate: (val) => { + const n = Number(val); + if (!Number.isInteger(n) || n < 1 || n > 65535) return "Port must be an integer between 1 and 65535"; + }, + }); + + if (p.isCancel(embeddedPostgresPort)) { + p.cancel("Setup cancelled."); + process.exit(0); + } + + return { + mode: "embedded-postgres", + embeddedPostgresDataDir: embeddedPostgresDataDir || "./data/embedded-postgres", + embeddedPostgresPort: Number(embeddedPostgresPort || "54329"), + }; } diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 5a91f0be..db53ef3e 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -2,9 +2,9 @@ Paperclip uses PostgreSQL via [Drizzle ORM](https://orm.drizzle.team/). There are three ways to run the database, from simplest to most production-ready. -## 1. Embedded (PGlite) — zero config +## 1. Embedded PostgreSQL — zero config -If you don't set `DATABASE_URL`, the server automatically starts an embedded PostgreSQL instance powered by [PGlite](https://pglite.dev/). No installation, no Docker, no setup. +If you don't set `DATABASE_URL`, the server automatically starts an embedded PostgreSQL instance and manages a local data directory. ```sh pnpm dev @@ -12,19 +12,14 @@ pnpm dev That's it. On first start the server: -1. Creates a `./data/pglite/` directory for storage -2. Pushes the Drizzle schema to create all tables -3. Starts serving requests +1. Creates a `./server/data/embedded-postgres/` directory for storage +2. Ensures the `paperclip` database exists +3. Runs migrations automatically for empty databases +4. Starts serving requests -Data persists across restarts in the `./data/pglite/` directory. To reset the database, delete that directory. +Data persists across restarts in `./server/data/embedded-postgres/`. To reset local dev data, delete that directory. -**Limitations:** - -- Single connection only (no concurrent access from multiple processes) -- Slower than native PostgreSQL (runs in WebAssembly) -- Not suitable for production - -This mode is ideal for getting started, local development, and demos. +This mode is ideal for local development and one-command installs. ## 2. Local PostgreSQL (Docker) @@ -116,11 +111,11 @@ See [Supabase pricing](https://supabase.com/pricing) for current details. ## Switching between modes -The database mode is controlled entirely by the `DATABASE_URL` environment variable: +The database mode is controlled by `DATABASE_URL`: | `DATABASE_URL` | Mode | |---|---| -| Not set | Embedded PGlite (`./data/pglite/`) | +| Not set | Embedded PostgreSQL (`./server/data/embedded-postgres/`) | | `postgres://...localhost...` | Local Docker PostgreSQL | | `postgres://...supabase.com...` | Hosted Supabase | diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 60e05b70..b1de3ad2 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -19,14 +19,14 @@ pnpm dev This starts: - API server: `http://localhost:3100` -- UI: `http://localhost:5173` +- UI: served by the API server in dev middleware mode (same origin as API) ## Database in Dev (Auto-Handled) For local development, leave `DATABASE_URL` unset. -The server will automatically use embedded PGlite and persist data at: +The server will automatically use embedded PostgreSQL and persist data at: -- `./data/pglite` +- `./data/embedded-postgres` No Docker or external database is required for this mode. @@ -49,10 +49,10 @@ Expected: To wipe local dev data and start fresh: ```sh -rm -rf data/pglite +rm -rf server/data/embedded-postgres pnpm dev ``` ## Optional: Use External Postgres -If you set `DATABASE_URL`, the server will use that instead of embedded PGlite. +If you set `DATABASE_URL`, the server will use that instead of embedded PostgreSQL. diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index b732c568..a6ebe02a 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -42,7 +42,7 @@ These decisions close open questions from `SPEC.md` for V1. | Auth | Session auth for board, API keys for agents | | Budget period | Monthly UTC calendar window | | Budget enforcement | Soft alerts + hard limit auto-pause | -| Deployment modes | Embedded PGlite default; Docker/hosted Postgres supported | +| Deployment modes | Embedded PostgreSQL default; Docker/hosted Postgres supported | ## 4. Current Baseline (Repo Snapshot) @@ -50,7 +50,7 @@ As of 2026-02-17, the repo already includes: - Node + TypeScript backend with REST CRUD for `agents`, `projects`, `goals`, `issues`, `activity` - React UI pages for dashboard/agents/projects/goals/issues lists -- PostgreSQL schema via Drizzle with embedded PGlite fallback when `DATABASE_URL` is unset +- PostgreSQL schema via Drizzle with embedded PostgreSQL fallback when `DATABASE_URL` is unset V1 implementation extends this baseline into a company-centric, governance-aware control plane. @@ -86,13 +86,13 @@ V1 implementation extends this baseline into a company-centric, governance-aware - `server/`: REST API, auth, orchestration services - `ui/`: Board operator interface -- `packages/db/`: Drizzle schema, migrations, DB clients (Postgres and PGlite) +- `packages/db/`: Drizzle schema, migrations, DB clients (Postgres) - `packages/shared/`: Shared API types, validators, constants ## 6.2 Data Stores - Primary: PostgreSQL -- Local default: embedded PGlite at `./data/pglite` +- Local default: embedded PostgreSQL at `./server/data/embedded-postgres` - Optional local prod-like: Docker Postgres - Optional hosted: Supabase/Postgres-compatible @@ -754,7 +754,7 @@ V1 is complete only when all criteria are true: 6. Budget hard limit auto-pauses an agent and prevents new invocations. 7. Dashboard shows accurate counts/spend from live DB data. 8. Every mutation is auditable in activity log. -9. App runs with embedded PGlite by default and with external Postgres via `DATABASE_URL`. +9. App runs with embedded PostgreSQL by default and with external Postgres via `DATABASE_URL`. ## 20. Post-V1 Backlog (Explicitly Deferred) diff --git a/package.json b/package.json index 10cdd8f0..8b430942 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "dev": "pnpm --parallel --filter @paperclip/server --filter @paperclip/ui dev", + "dev": "PAPERCLIP_UI_DEV_MIDDLEWARE=true pnpm --filter @paperclip/server dev", "dev:server": "pnpm --filter @paperclip/server dev", "dev:ui": "pnpm --filter @paperclip/ui dev", "build": "pnpm -r build", diff --git a/packages/db/package.json b/packages/db/package.json index 0113e717..7e788ddc 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -14,7 +14,6 @@ "seed": "tsx src/seed.ts" }, "dependencies": { - "@electric-sql/pglite": "^0.3.15", "@paperclip/shared": "workspace:*", "drizzle-orm": "^0.38.4", "postgres": "^3.4.5" diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index c0c4191d..ceb1313b 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -1,8 +1,6 @@ -import { mkdirSync } from "node:fs"; import { drizzle as drizzlePg } from "drizzle-orm/postgres-js"; -import { drizzle as drizzlePglite } from "drizzle-orm/pglite"; +import { migrate as migratePg } from "drizzle-orm/postgres-js/migrator"; import postgres from "postgres"; -import { PGlite } from "@electric-sql/pglite"; import * as schema from "./schema/index.js"; export function createDb(url: string) { @@ -10,17 +8,66 @@ export function createDb(url: string) { return drizzlePg(sql, { schema }); } -export async function createPgliteDb(dataDir: string) { - mkdirSync(dataDir, { recursive: true }); - const client = new PGlite(dataDir); - const db = drizzlePglite({ client, schema }); +export type MigrationBootstrapResult = + | { migrated: true; reason: "migrated-empty-db"; tableCount: 0 } + | { migrated: false; reason: "already-migrated"; tableCount: number } + | { migrated: false; reason: "not-empty-no-migration-journal"; tableCount: number }; - // Auto-push schema to PGlite on startup (like drizzle-kit push) - const { pushSchema } = await import("drizzle-kit/api"); - const { apply } = await pushSchema(schema, db as any); - await apply(); +export async function migratePostgresIfEmpty(url: string): Promise { + const sql = postgres(url, { max: 1 }); - return db; + try { + const journal = await sql<{ regclass: string | null }[]>` + select to_regclass('public.__drizzle_migrations') as regclass + `; + + const tableCountResult = await sql<{ count: number }[]>` + select count(*)::int as count + from information_schema.tables + where table_schema = 'public' + and table_type = 'BASE TABLE' + `; + + const tableCount = tableCountResult[0]?.count ?? 0; + + if (journal[0]?.regclass) { + return { migrated: false, reason: "already-migrated", tableCount }; + } + + if (tableCount > 0) { + return { migrated: false, reason: "not-empty-no-migration-journal", tableCount }; + } + + const db = drizzlePg(sql); + const migrationsFolder = new URL("./migrations", import.meta.url).pathname; + await migratePg(db, { migrationsFolder }); + + return { migrated: true, reason: "migrated-empty-db", tableCount: 0 }; + } finally { + await sql.end(); + } +} + +export async function ensurePostgresDatabase( + url: string, + databaseName: string, +): Promise<"created" | "exists"> { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(databaseName)) { + throw new Error(`Unsafe database name: ${databaseName}`); + } + + const sql = postgres(url, { max: 1 }); + try { + const existing = await sql<{ one: number }[]>` + select 1 as one from pg_database where datname = ${databaseName} limit 1 + `; + if (existing.length > 0) return "exists"; + + await sql.unsafe(`create database "${databaseName}"`); + return "created"; + } finally { + await sql.end(); + } } export type Db = ReturnType; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 8d9db76f..f782e8fd 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,2 +1,8 @@ -export { createDb, createPgliteDb, type Db } from "./client.js"; +export { + createDb, + ensurePostgresDatabase, + migratePostgresIfEmpty, + type MigrationBootstrapResult, + type Db, +} from "./client.js"; export * from "./schema/index.js"; diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index 412401e5..f6e0b36c 100644 --- a/packages/db/src/migrate.ts +++ b/packages/db/src/migrate.ts @@ -1,23 +1,17 @@ import { migrate as migratePg } from "drizzle-orm/postgres-js/migrator"; -import { migrate as migratePglite } from "drizzle-orm/pglite/migrator"; import postgres from "postgres"; -import { PGlite } from "@electric-sql/pglite"; import { drizzle as drizzlePg } from "drizzle-orm/postgres-js"; -import { drizzle as drizzlePglite } from "drizzle-orm/pglite"; const migrationsFolder = new URL("./migrations", import.meta.url).pathname; const url = process.env.DATABASE_URL; -if (url) { - const sql = postgres(url, { max: 1 }); - const db = drizzlePg(sql); - await migratePg(db, { migrationsFolder }); - await sql.end(); -} else { - const client = new PGlite("./data/pglite"); - const db = drizzlePglite({ client }); - await migratePglite(db, { migrationsFolder }); - await client.close(); +if (!url) { + throw new Error("DATABASE_URL is required for db:migrate"); } +const sql = postgres(url, { max: 1 }); +const db = drizzlePg(sql); +await migratePg(db, { migrationsFolder }); +await sql.end(); + console.log("Migrations complete"); diff --git a/packages/shared/src/config-schema.ts b/packages/shared/src/config-schema.ts index e6489c1a..243a4c58 100644 --- a/packages/shared/src/config-schema.ts +++ b/packages/shared/src/config-schema.ts @@ -12,9 +12,10 @@ export const llmConfigSchema = z.object({ }); export const databaseConfigSchema = z.object({ - mode: z.enum(["pglite", "postgres"]), + mode: z.enum(["embedded-postgres", "postgres"]).default("embedded-postgres"), connectionString: z.string().optional(), - pgliteDataDir: z.string().default("./data/pglite"), + embeddedPostgresDataDir: z.string().default("./data/embedded-postgres"), + embeddedPostgresPort: z.number().int().min(1).max(65535).default(54329), }); export const loggingConfigSchema = z.object({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 472a6866..a9618258 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,15 +45,12 @@ importers: packages/db: dependencies: - '@electric-sql/pglite': - specifier: ^0.3.15 - version: 0.3.15 '@paperclip/shared': specifier: workspace:* version: link:../shared drizzle-orm: specifier: ^0.38.4 - version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4) + version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4) postgres: specifier: ^3.4.5 version: 3.4.8 @@ -89,9 +86,12 @@ importers: '@paperclip/shared': specifier: workspace:* version: link:../packages/shared + detect-port: + specifier: ^2.1.0 + version: 2.1.0 drizzle-orm: specifier: ^0.38.4 - version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4) + version: 0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4) express: specifier: ^5.1.0 version: 5.2.1 @@ -107,6 +107,10 @@ importers: zod: specifier: ^3.24.2 version: 3.25.76 + optionalDependencies: + embedded-postgres: + specifier: ^18.1.0-beta.16 + version: 18.1.0-beta.16 devDependencies: '@types/express': specifier: ^5.0.0 @@ -126,6 +130,9 @@ importers: typescript: specifier: ^5.7.3 version: 5.9.3 + vite: + specifier: ^6.1.0 + version: 6.4.1(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) vitest: specifier: ^3.0.5 version: 3.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) @@ -294,6 +301,54 @@ packages: '@electric-sql/pglite@0.3.15': resolution: {integrity: sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==} + '@embedded-postgres/darwin-arm64@18.1.0-beta.16': + resolution: {integrity: sha512-tU/syLOamFZdXMC+p7AYczmFKIiolFlZ8y3Qb7KonX37O07ezc/OSDiQ641sheV3X0WPf9V10qyK8c81rleDdw==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@embedded-postgres/darwin-x64@18.1.0-beta.16': + resolution: {integrity: sha512-4zHNCscGJt/3pmkpLCuU/IpMJzwENM6OqSHE+WWkOoNqYid49ZnmgB1ltOelgZgRoPRIy/HDEnrMeuVxQHBhEw==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@embedded-postgres/linux-arm64@18.1.0-beta.16': + resolution: {integrity: sha512-G0f/reVFc7svqncDQL7blwKulzYIYsz+o/3TEtAJOaGMXYkD8Swzv9RFKfEJOr9+IVRwCmoFFppMeBnUwMoGZg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@embedded-postgres/linux-arm@18.1.0-beta.16': + resolution: {integrity: sha512-aB1t95YGnqay8swh70s7zo587Nog90UL9yUYrzMMNMq40Qfrq9aWiBn0+6vCxp1rSFaPyJMPvW62/rIKLFBkKg==} + engines: {node: '>=16'} + cpu: [arm] + os: [linux] + + '@embedded-postgres/linux-ia32@18.1.0-beta.16': + resolution: {integrity: sha512-gMTIryUMnwyLancs34gXqaiuXIFMgn8RGYZ2wJZEuXpW0SQ/cE07kFINmoQO1sN+5T/IR0KvMXPzxo867wO3ew==} + engines: {node: '>=16'} + cpu: [ia32] + os: [linux] + + '@embedded-postgres/linux-ppc64@18.1.0-beta.16': + resolution: {integrity: sha512-wTglX0bZVBretiUJrZUO/EmEP8w7jC+i8ZAEKesHHIqIDXEV2F9l+6aTjWt2wRu5SAJ6gpe2RbcKvJ6x+6m1Qw==} + engines: {node: '>=16'} + cpu: [ppc64] + os: [linux] + + '@embedded-postgres/linux-x64@18.1.0-beta.16': + resolution: {integrity: sha512-+GIabpHh7QV2AcYBuzyQC41AYczSFphxfHy4ccTPIPrp6OSthZXH+A9fymjQzOiDHg9+1UeYET00Aj7sScjXrg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@embedded-postgres/windows-x64@18.1.0-beta.16': + resolution: {integrity: sha512-v6AXH1zi6YyoqPM6U7mX08prJ33yD9gqsbo3YdtPi8FDx0C/y9sYa3aQVf/3blPJvHyERYbY8fZnYXbb79Lo0Q==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -1825,6 +1880,10 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + address@2.0.3: + resolution: {integrity: sha512-XNAb/a6TCqou+TufU8/u11HCu9x1gYvOoxLwtlXgIqmkrYQADVv6ljyW2zwiPhHz9R1gItAWpuDrdJMmrOBFEA==} + engines: {node: '>= 16.0.0'} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1836,6 +1895,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + async-exit-hook@2.0.1: + resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} + engines: {node: '>=0.12.0'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -1967,6 +2030,11 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + detect-port@2.1.0: + resolution: {integrity: sha512-epZuWb/6Q62L+nDHJc/hQAqf8pylsqgk3BpZXVBx1CDnr3nkrVNn73Uu1rXcFzkNcc+hkP3whuOg7JZYaQB65Q==} + engines: {node: '>= 16.0.0'} + hasBin: true + dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} @@ -2076,6 +2144,10 @@ packages: electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + embedded-postgres@18.1.0-beta.16: + resolution: {integrity: sha512-TDp7Ld0h84x5fzIIZFyreYWqZrxUNjuXB6OxqJCmV6PodB2vzQ+1hlL6n4uK1de7bIAxYt5OkDykwcuIONQdQg==} + engines: {node: '>=16'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -2430,6 +2502,40 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.11.0: + resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.11.0: + resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.11.0: + resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.18.0: + resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2454,6 +2560,22 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + postgres@3.4.8: resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} engines: {node: '>=12'} @@ -2892,6 +3014,10 @@ packages: utf-8-validate: optional: true + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3025,7 +3151,32 @@ snapshots: '@drizzle-team/brocli@0.10.2': {} - '@electric-sql/pglite@0.3.15': {} + '@electric-sql/pglite@0.3.15': + optional: true + + '@embedded-postgres/darwin-arm64@18.1.0-beta.16': + optional: true + + '@embedded-postgres/darwin-x64@18.1.0-beta.16': + optional: true + + '@embedded-postgres/linux-arm64@18.1.0-beta.16': + optional: true + + '@embedded-postgres/linux-arm@18.1.0-beta.16': + optional: true + + '@embedded-postgres/linux-ia32@18.1.0-beta.16': + optional: true + + '@embedded-postgres/linux-ppc64@18.1.0-beta.16': + optional: true + + '@embedded-postgres/linux-x64@18.1.0-beta.16': + optional: true + + '@embedded-postgres/windows-x64@18.1.0-beta.16': + optional: true '@esbuild-kit/core-utils@3.3.2': dependencies: @@ -4367,6 +4518,8 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + address@2.0.3: {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -4375,6 +4528,9 @@ snapshots: assertion-error@2.0.1: {} + async-exit-hook@2.0.1: + optional: true + asynckit@0.4.0: {} atomic-sleep@1.0.0: {} @@ -4487,6 +4643,10 @@ snapshots: detect-node-es@1.1.0: {} + detect-port@2.1.0: + dependencies: + address: 2.0.3 + dezalgo@1.0.4: dependencies: asap: 2.0.6 @@ -4501,10 +4661,11 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(postgres@3.4.8)(react@19.2.4): + drizzle-orm@0.38.4(@electric-sql/pglite@0.3.15)(@types/react@19.2.14)(pg@8.18.0)(postgres@3.4.8)(react@19.2.4): optionalDependencies: '@electric-sql/pglite': 0.3.15 '@types/react': 19.2.14 + pg: 8.18.0 postgres: 3.4.8 react: 19.2.4 @@ -4518,6 +4679,23 @@ snapshots: electron-to-chromium@1.5.286: {} + embedded-postgres@18.1.0-beta.16: + dependencies: + async-exit-hook: 2.0.1 + pg: 8.18.0 + optionalDependencies: + '@embedded-postgres/darwin-arm64': 18.1.0-beta.16 + '@embedded-postgres/darwin-x64': 18.1.0-beta.16 + '@embedded-postgres/linux-arm': 18.1.0-beta.16 + '@embedded-postgres/linux-arm64': 18.1.0-beta.16 + '@embedded-postgres/linux-ia32': 18.1.0-beta.16 + '@embedded-postgres/linux-ppc64': 18.1.0-beta.16 + '@embedded-postgres/linux-x64': 18.1.0-beta.16 + '@embedded-postgres/windows-x64': 18.1.0-beta.16 + transitivePeerDependencies: + - pg-native + optional: true + encodeurl@2.0.0: {} enhanced-resolve@5.19.0: @@ -4900,6 +5078,48 @@ snapshots: pathval@2.0.1: {} + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.11.0: + optional: true + + pg-int8@1.0.1: + optional: true + + pg-pool@3.11.0(pg@8.18.0): + dependencies: + pg: 8.18.0 + optional: true + + pg-protocol@1.11.0: + optional: true + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + optional: true + + pg@8.18.0: + dependencies: + pg-connection-string: 2.11.0 + pg-pool: 3.11.0(pg@8.18.0) + pg-protocol: 1.11.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + optional: true + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + optional: true + picocolors@1.1.1: {} picomatch@4.0.3: {} @@ -4937,6 +5157,20 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: + optional: true + + postgres-bytea@1.0.1: + optional: true + + postgres-date@1.0.7: + optional: true + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + optional: true + postgres@3.4.8: {} process-warning@5.0.0: {} @@ -5413,6 +5647,9 @@ snapshots: ws@8.19.0: {} + xtend@4.0.2: + optional: true + yallist@3.1.1: {} zod@3.25.76: {} diff --git a/server/package.json b/server/package.json index 1b8105f7..a759ed63 100644 --- a/server/package.json +++ b/server/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "tsx watch src/index.ts", + "dev": "tsx watch --ignore ../ui/node_modules --ignore ../ui/.vite --ignore ../ui/dist src/index.ts", "build": "tsc", "start": "node dist/index.js", "typecheck": "tsc --noEmit" @@ -12,6 +12,7 @@ "dependencies": { "@paperclip/db": "workspace:*", "@paperclip/shared": "workspace:*", + "detect-port": "^2.1.0", "drizzle-orm": "^0.38.4", "express": "^5.1.0", "pino": "^9.6.0", @@ -19,6 +20,9 @@ "ws": "^8.19.0", "zod": "^3.24.2" }, + "optionalDependencies": { + "embedded-postgres": "^18.1.0-beta.16" + }, "devDependencies": { "@types/express": "^5.0.0", "@types/express-serve-static-core": "^5.0.0", @@ -26,6 +30,7 @@ "supertest": "^7.0.0", "tsx": "^4.19.2", "typescript": "^5.7.3", + "vite": "^6.1.0", "vitest": "^3.0.5" } } diff --git a/server/src/app.ts b/server/src/app.ts index b13f08e0..1deed1bf 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,5 +1,6 @@ import express, { Router } from "express"; import path from "node:path"; +import fs from "node:fs"; import { fileURLToPath } from "node:url"; import type { Db } from "@paperclip/db"; import { httpLogger, errorHandler } from "./middleware/index.js"; @@ -15,7 +16,9 @@ import { costRoutes } from "./routes/costs.js"; import { activityRoutes } from "./routes/activity.js"; import { dashboardRoutes } from "./routes/dashboard.js"; -export function createApp(db: Db, opts: { serveUi: boolean }) { +type UiMode = "none" | "static" | "vite-dev"; + +export async function createApp(db: Db, opts: { uiMode: UiMode }) { const app = express(); app.use(express.json()); @@ -36,16 +39,40 @@ export function createApp(db: Db, opts: { serveUi: boolean }) { api.use(dashboardRoutes(db)); app.use("/api", api); - // SPA fallback for serving the UI build - if (opts.serveUi) { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + if (opts.uiMode === "static") { + // Serve built UI from ui/dist in production. const uiDist = path.resolve(__dirname, "../../ui/dist"); app.use(express.static(uiDist)); - app.get("*", (_req, res) => { + app.get(/.*/, (_req, res) => { res.sendFile(path.join(uiDist, "index.html")); }); } + if (opts.uiMode === "vite-dev") { + const uiRoot = path.resolve(__dirname, "../../ui"); + const { createServer: createViteServer } = await import("vite"); + const vite = await createViteServer({ + root: uiRoot, + appType: "spa", + server: { + middlewareMode: true, + }, + }); + + app.use(vite.middlewares); + app.get(/.*/, async (req, res, next) => { + try { + const templatePath = path.resolve(uiRoot, "index.html"); + const template = fs.readFileSync(templatePath, "utf-8"); + const html = await vite.transformIndexHtml(req.originalUrl, template); + res.status(200).set({ "Content-Type": "text/html" }).end(html); + } catch (err) { + next(err); + } + }); + } + app.use(errorHandler); return app; diff --git a/server/src/config.ts b/server/src/config.ts index 07b0115d..b684d901 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -1,28 +1,40 @@ import { readConfigFile } from "./config-file.js"; +type DatabaseMode = "embedded-postgres" | "postgres"; + export interface Config { port: number; + databaseMode: DatabaseMode; databaseUrl: string | undefined; + embeddedPostgresDataDir: string; + embeddedPostgresPort: number; serveUi: boolean; + uiDevMiddleware: boolean; heartbeatSchedulerEnabled: boolean; heartbeatSchedulerIntervalMs: number; } export function loadConfig(): Config { const fileConfig = readConfigFile(); + const fileDatabaseMode = + (fileConfig?.database.mode === "postgres" ? "postgres" : "embedded-postgres") as DatabaseMode; const fileDbUrl = - fileConfig?.database.mode === "postgres" - ? fileConfig.database.connectionString + fileDatabaseMode === "postgres" + ? fileConfig?.database.connectionString : undefined; return { port: Number(process.env.PORT) || fileConfig?.server.port || 3100, + databaseMode: fileDatabaseMode, databaseUrl: process.env.DATABASE_URL ?? fileDbUrl, + embeddedPostgresDataDir: fileConfig?.database.embeddedPostgresDataDir ?? "./data/embedded-postgres", + embeddedPostgresPort: fileConfig?.database.embeddedPostgresPort ?? 54329, serveUi: process.env.SERVE_UI !== undefined ? process.env.SERVE_UI === "true" : fileConfig?.server.serveUi ?? false, + uiDevMiddleware: process.env.PAPERCLIP_UI_DEV_MIDDLEWARE === "true", heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false", heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000), }; diff --git a/server/src/index.ts b/server/src/index.ts index f87de840..71f02c54 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,26 +1,163 @@ +import { existsSync, readFileSync, rmSync } from "node:fs"; import { createServer } from "node:http"; import { resolve } from "node:path"; -import { createDb, createPgliteDb } from "@paperclip/db"; +import { + createDb, + ensurePostgresDatabase, + migratePostgresIfEmpty, +} from "@paperclip/db"; +import detectPort from "detect-port"; import { createApp } from "./app.js"; import { loadConfig } from "./config.js"; import { logger } from "./middleware/logger.js"; import { setupLiveEventsWebSocketServer } from "./realtime/live-events-ws.js"; import { heartbeatService } from "./services/index.js"; +import { printStartupBanner } from "./startup-banner.js"; + +type EmbeddedPostgresInstance = { + initialise(): Promise; + start(): Promise; + stop(): Promise; +}; + +type EmbeddedPostgresCtor = new (opts: { + databaseDir: string; + user: string; + password: string; + port: number; + persistent: boolean; +}) => EmbeddedPostgresInstance; const config = loadConfig(); let db; +let embeddedPostgres: EmbeddedPostgresInstance | null = null; +let embeddedPostgresStartedByThisProcess = false; +let migrationSummary = "skipped"; +let startupDbInfo: + | { mode: "external-postgres"; connectionString: string } + | { mode: "embedded-postgres"; dataDir: string; port: number }; if (config.databaseUrl) { + const migration = await migratePostgresIfEmpty(config.databaseUrl); + if (migration.migrated) { + logger.info("Empty PostgreSQL database detected; applied migrations"); + migrationSummary = "applied (empty database)"; + } else if (migration.reason === "not-empty-no-migration-journal") { + logger.warn( + { tableCount: migration.tableCount }, + "PostgreSQL has existing tables but no migration journal; skipped auto-migrate", + ); + migrationSummary = "skipped (existing schema, no migration journal)"; + } else { + migrationSummary = "already applied"; + } + db = createDb(config.databaseUrl); + logger.info("Using external PostgreSQL via DATABASE_URL/config"); + startupDbInfo = { mode: "external-postgres", connectionString: config.databaseUrl }; } else { - const dataDir = resolve("./data/pglite"); - logger.info(`No DATABASE_URL set — using embedded PGlite (${dataDir})`); - db = await createPgliteDb(dataDir); - logger.info("PGlite ready, schema pushed"); + const moduleName = "embedded-postgres"; + let EmbeddedPostgres: EmbeddedPostgresCtor; + try { + const mod = await import(moduleName); + EmbeddedPostgres = mod.default as EmbeddedPostgresCtor; + } catch { + throw new Error( + "Embedded PostgreSQL mode requires optional dependency `embedded-postgres`. Install optional dependencies or set DATABASE_URL for external Postgres.", + ); + } + + const dataDir = resolve(config.embeddedPostgresDataDir); + const port = config.embeddedPostgresPort; + + if (config.databaseMode === "postgres") { + logger.warn("Database mode is postgres but no connection string was set; falling back to embedded PostgreSQL"); + } + + logger.info(`No DATABASE_URL set — using embedded PostgreSQL (${dataDir}) on port ${port}`); + embeddedPostgres = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + }); + const clusterVersionFile = resolve(dataDir, "PG_VERSION"); + if (!existsSync(clusterVersionFile)) { + await embeddedPostgres.initialise(); + } else { + logger.info(`Embedded PostgreSQL cluster already exists (${clusterVersionFile}); skipping init`); + } + + const postmasterPidFile = resolve(dataDir, "postmaster.pid"); + const isPidRunning = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }; + + const getRunningPid = (): number | null => { + if (!existsSync(postmasterPidFile)) return null; + try { + const pidLine = readFileSync(postmasterPidFile, "utf8").split("\n")[0]?.trim(); + const pid = Number(pidLine); + if (!Number.isInteger(pid) || pid <= 0) return null; + if (!isPidRunning(pid)) return null; + return pid; + } catch { + return null; + } + }; + + const runningPid = getRunningPid(); + if (runningPid) { + logger.warn({ pid: runningPid }, "Embedded PostgreSQL already running; reusing existing process"); + } else { + if (existsSync(postmasterPidFile)) { + logger.warn("Removing stale embedded PostgreSQL lock file"); + rmSync(postmasterPidFile, { force: true }); + } + await embeddedPostgres.start(); + embeddedPostgresStartedByThisProcess = true; + } + + const embeddedAdminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; + const dbStatus = await ensurePostgresDatabase(embeddedAdminConnectionString, "paperclip"); + if (dbStatus === "created") { + logger.info("Created embedded PostgreSQL database: paperclip"); + } + + const embeddedConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + const migration = await migratePostgresIfEmpty(embeddedConnectionString); + if (migration.migrated) { + logger.info("Empty embedded PostgreSQL database detected; applied migrations"); + migrationSummary = "applied (empty database)"; + } else if (migration.reason === "not-empty-no-migration-journal") { + logger.warn( + { tableCount: migration.tableCount }, + "Embedded PostgreSQL has existing tables but no migration journal; skipped auto-migrate", + ); + migrationSummary = "skipped (existing schema, no migration journal)"; + } else { + migrationSummary = "already applied"; + } + + db = createDb(embeddedConnectionString); + logger.info("Embedded PostgreSQL ready"); + startupDbInfo = { mode: "embedded-postgres", dataDir, port }; } -const app = createApp(db as any, { serveUi: config.serveUi }); +const uiMode = config.uiDevMiddleware ? "vite-dev" : config.serveUi ? "static" : "none"; +const app = await createApp(db as any, { uiMode }); const server = createServer(app); +const listenPort = await detectPort(config.port); + +if (listenPort !== config.port) { + logger.warn({ requestedPort: config.port, selectedPort: listenPort }, "Requested port is busy; using next free port"); +} setupLiveEventsWebSocketServer(server, db as any); @@ -40,6 +177,35 @@ if (config.heartbeatSchedulerEnabled) { }, config.heartbeatSchedulerIntervalMs); } -server.listen(config.port, () => { - logger.info(`Server listening on :${config.port}`); +server.listen(listenPort, () => { + logger.info(`Server listening on :${listenPort}`); + printStartupBanner({ + requestedPort: config.port, + listenPort, + uiMode, + db: startupDbInfo, + migrationSummary, + heartbeatSchedulerEnabled: config.heartbeatSchedulerEnabled, + heartbeatSchedulerIntervalMs: config.heartbeatSchedulerIntervalMs, + }); }); + +if (embeddedPostgres && embeddedPostgresStartedByThisProcess) { + const shutdown = async (signal: "SIGINT" | "SIGTERM") => { + logger.info({ signal }, "Stopping embedded PostgreSQL"); + try { + await embeddedPostgres?.stop(); + } catch (err) { + logger.error({ err }, "Failed to stop embedded PostgreSQL cleanly"); + } finally { + process.exit(0); + } + }; + + process.once("SIGINT", () => { + void shutdown("SIGINT"); + }); + process.once("SIGTERM", () => { + void shutdown("SIGTERM"); + }); +} diff --git a/server/src/startup-banner.ts b/server/src/startup-banner.ts new file mode 100644 index 00000000..912f24b5 --- /dev/null +++ b/server/src/startup-banner.ts @@ -0,0 +1,115 @@ +import { resolve } from "node:path"; + +type UiMode = "none" | "static" | "vite-dev"; + +type ExternalPostgresInfo = { + mode: "external-postgres"; + connectionString: string; +}; + +type EmbeddedPostgresInfo = { + mode: "embedded-postgres"; + dataDir: string; + port: number; +}; + +type StartupBannerOptions = { + requestedPort: number; + listenPort: number; + uiMode: UiMode; + db: ExternalPostgresInfo | EmbeddedPostgresInfo; + migrationSummary: string; + heartbeatSchedulerEnabled: boolean; + heartbeatSchedulerIntervalMs: number; +}; + +const ansi = { + reset: "\x1b[0m", + bold: "\x1b[1m", + dim: "\x1b[2m", + cyan: "\x1b[36m", + green: "\x1b[32m", + yellow: "\x1b[33m", + magenta: "\x1b[35m", + blue: "\x1b[34m", +}; + +function color(text: string, c: keyof typeof ansi): string { + return `${ansi[c]}${text}${ansi.reset}`; +} + +function row(label: string, value: string): string { + return `${color(label.padEnd(16), "dim")} ${value}`; +} + +function redactConnectionString(raw: string): string { + try { + const u = new URL(raw); + const user = u.username || "user"; + const auth = `${user}:***@`; + return `${u.protocol}//${auth}${u.host}${u.pathname}`; + } catch { + return ""; + } +} + +export function printStartupBanner(opts: StartupBannerOptions): void { + const baseUrl = `http://localhost:${opts.listenPort}`; + const apiUrl = `${baseUrl}/api`; + const uiUrl = opts.uiMode === "none" ? "disabled" : baseUrl; + const configPath = process.env.PAPERCLIP_CONFIG + ? resolve(process.env.PAPERCLIP_CONFIG) + : resolve(process.cwd(), ".paperclip/config.json"); + + const dbMode = + opts.db.mode === "embedded-postgres" + ? color("embedded-postgres", "green") + : color("external-postgres", "yellow"); + const uiMode = + opts.uiMode === "vite-dev" + ? color("vite-dev-middleware", "cyan") + : opts.uiMode === "static" + ? color("static-ui", "magenta") + : color("headless-api", "yellow"); + + const portValue = + opts.requestedPort === opts.listenPort + ? `${opts.listenPort}` + : `${opts.listenPort} ${color(`(requested ${opts.requestedPort})`, "dim")}`; + + const dbDetails = + opts.db.mode === "embedded-postgres" + ? `${opts.db.dataDir} ${color(`(pg:${opts.db.port})`, "dim")}` + : redactConnectionString(opts.db.connectionString); + + const heartbeat = opts.heartbeatSchedulerEnabled + ? `enabled ${color(`(${opts.heartbeatSchedulerIntervalMs}ms)`, "dim")}` + : color("disabled", "yellow"); + + const art = [ + color("██████╗ █████╗ ██████╗ ███████╗██████╗ ██████╗██╗ ██╗██████╗ ", "cyan"), + color("██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔══██╗██╔════╝██║ ██║██╔══██╗", "cyan"), + color("██████╔╝███████║██████╔╝█████╗ ██████╔╝██║ ██║ ██║██████╔╝", "cyan"), + color("██╔═══╝ ██╔══██║██╔═══╝ ██╔══╝ ██╔══██╗██║ ██║ ██║██╔═══╝ ", "cyan"), + color("██║ ██║ ██║██║ ███████╗██║ ██║╚██████╗███████╗██║██║ ", "cyan"), + color("╚═╝ ╚═╝ ╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝╚══════╝╚═╝╚═╝ ", "cyan"), + ]; + + const lines = [ + "", + ...art, + color(" ───────────────────────────────────────────────────────", "blue"), + row("Mode", `${dbMode} | ${uiMode}`), + row("Server", portValue), + row("API", `${apiUrl} ${color(`(health: ${apiUrl}/health)`, "dim")}`), + row("UI", uiUrl), + row("Database", dbDetails), + row("Migrations", opts.migrationSummary), + row("Heartbeat", heartbeat), + row("Config", configPath), + color(" ───────────────────────────────────────────────────────", "blue"), + "", + ]; + + console.log(lines.join("\n")); +} diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index aa54f0a4..4ab5f953 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -60,8 +60,8 @@ export function OnboardingWizard() { const [url, setUrl] = useState(""); // Step 3 - const [taskTitle, setTaskTitle] = useState(""); - const [taskDescription, setTaskDescription] = useState(""); + const [taskTitle, setTaskTitle] = useState("Create your CEO HEARTBEAT.md"); + const [taskDescription, setTaskDescription] = useState("You're the CEO of the company, make sure you have a file agents/ceo/HEARTBEAT.md that tells you your core loop. You MUST use the Paperclip SKILL."); // Created entity IDs const [createdCompanyId, setCreatedCompanyId] = useState(null); @@ -88,8 +88,8 @@ export function OnboardingWizard() { setCommand(""); setArgs(""); setUrl(""); - setTaskTitle(""); - setTaskDescription(""); + setTaskTitle("Create your CEO HEARTBEAT.md"); + setTaskDescription("You're the CEO of the company, make sure you have a file agents/ceo/HEARTBEAT.md that tells you your core loop. You MUST use the Paperclip SKILL."); setCreatedCompanyId(null); setCreatedAgentId(null); }