From ff0bf5e5f0bd10073567b0669ac2b92b46047467 Mon Sep 17 00:00:00 2001 From: sai karthik Date: Tue, 19 May 2026 22:17:40 +0530 Subject: [PATCH] Wire production stack: Clerk + Postgres + Anthropic + per-user containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the backend from a scaffold to a working end-to-end MVP — real auth, persistent actor registry, Anthropic tool-use loop in the Grow Agent, and per-user Gitea+OpenCode provisioning. Also adds the client-facing architecture diagram under docs/architecture.html. --- .env.example | 32 +- README.md | 181 ++-- docker-compose.yml | 42 +- docs/architecture.html | 394 +++++++++ drizzle.config.ts | 13 + drizzle/0000_init.sql | 81 ++ drizzle/meta/0000_snapshot.json | 598 +++++++++++++ drizzle/meta/_journal.json | 13 + package-lock.json | 1441 ++++++++++++++++++++++++++++++- package.json | 8 + src/actors/grow-agent.ts | 365 ++++++-- src/actors/sub-agent-runner.ts | 103 +++ src/actors/sub-agent.ts | 80 +- src/auth/clerk.ts | 97 +++ src/config.ts | 45 +- src/db/client.ts | 14 + src/db/migrate.ts | 20 + src/db/schema.ts | 174 ++++ src/docker/manager.ts | 425 +++++++-- src/index.ts | 54 +- src/lib/anthropic.ts | 104 +++ src/lib/gitea.ts | 217 +++++ src/lib/opencode.ts | 159 ++++ src/routes/actors.ts | 50 +- src/routes/git.ts | 106 ++- src/routes/opencode.ts | 91 +- src/routes/users.ts | 50 ++ 27 files changed, 4599 insertions(+), 358 deletions(-) create mode 100644 docs/architecture.html create mode 100644 drizzle.config.ts create mode 100644 drizzle/0000_init.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 src/actors/sub-agent-runner.ts create mode 100644 src/auth/clerk.ts create mode 100644 src/db/client.ts create mode 100644 src/db/migrate.ts create mode 100644 src/db/schema.ts create mode 100644 src/lib/anthropic.ts create mode 100644 src/lib/gitea.ts create mode 100644 src/lib/opencode.ts create mode 100644 src/routes/users.ts diff --git a/.env.example b/.env.example index 46f667a..359eec7 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,44 @@ PORT=4000 LOG_LEVEL=info +NODE_ENV=development + +# Postgres (started by docker-compose; defaults match the compose service) +DATABASE_URL=postgres://growqr:growqr@localhost:5432/growqr +POSTGRES_USER=growqr +POSTGRES_PASSWORD=growqr +POSTGRES_DB=growqr + +# Clerk auth — get from dashboard.clerk.com → API Keys +CLERK_SECRET_KEY=sk_test_REPLACE_ME +CLERK_PUBLISHABLE_KEY=pk_test_REPLACE_ME + +# Anthropic — get from console.anthropic.com → API Keys +ANTHROPIC_API_KEY=sk-ant-REPLACE_ME +GROW_AGENT_MODEL=claude-opus-4-7 +SUB_AGENT_MODEL=claude-sonnet-4-6 +MAX_AGENT_TOKENS=4096 + +# Shared secret for actor → backend service calls (rotate in prod) +SERVICE_TOKEN=dev-service-token-REPLACE_ME # Rivet Kit engine (self-hosted in docker-compose) RIVET_ENDPOINT=http://localhost:6420 -# Docker images used for per-user containers +# Per-user container images GITEA_IMAGE=gitea/gitea:1.22 OPENCODE_IMAGE=ghcr.io/sst/opencode:latest -# Host where spawned containers expose their ports (usually localhost in dev) +# Host where spawned containers expose their ports. +# - localhost in dev +# - host.docker.internal when the backend runs inside docker-compose USER_CONTAINER_HOST=127.0.0.1 -# Workspace root on the host that gets bind-mounted into per-user containers. -# Each user gets a subdir: ${USER_DATA_ROOT}//{gitea,workspace} +# Workspace root on the host. Each user gets a subdir. USER_DATA_ROOT=./.data/users # Port range allocated to spawned per-user containers USER_PORT_RANGE_START=20000 USER_PORT_RANGE_END=29999 + +# CORS origin(s) for the Next.js frontend (comma-separated for multiple) +FRONTEND_ORIGIN=http://localhost:3000 diff --git a/README.md b/README.md index e24ca50..3652524 100644 --- a/README.md +++ b/README.md @@ -1,101 +1,116 @@ -# growqr-backend +# GrowQR — backend + frontend -Backend for the Grow Agent Platform (see [`docs/PRD.md`](docs/PRD.md)). +A multi-agent platform where every user gets a private Grow Agent (Rivet Kit actor) that orchestrates sub-agents and owns a per-user OpenCode + Gitea Docker stack. See `docs/PRD.md` for the product spec. -Per the PRD, every user gets: +## What's wired up -- A **Grow Agent** (Rivet Kit actor) that orchestrates **sub-agent actors**. -- A **dedicated OpenCode Docker** container — every sub-agent workflow executes through it. -- A **dedicated Gitea Docker** container — backs the agent's memory and project repos. -- A frontend (Next.js, separate repo) that pulls the Rivet Kit React SDK and talks to the Grow Agent over the actor connection. +- **Auth**: Clerk (frontend + backend JWT verification). +- **DB**: Postgres + Drizzle (users, actor registry, container mappings, repos, OpenCode sessions, events). +- **Actors**: Rivet Kit — `growAgent` per user (master) and `subAgent` (worker), with a real Anthropic Claude tool-use loop. +- **Per-user containers**: Gitea (memory repo) + OpenCode (workflow execution), spawned via `dockerode`, with admin user + access token bootstrap, ports allocated from a managed pool, lifecycle reconciled on backend boot. +- **Frontend**: Next.js 16 with `@clerk/nextjs` for auth and `rivetkit/client` for direct actor connections + event streaming. +- **Tool surface available to the Grow Agent**: `spawn_sub_agent`, `commit_memory`, `read_memory`, `list_memory`. + +## One-time setup + +You need three external accounts before running: + +1. **Clerk** — create an app at https://dashboard.clerk.com → copy the publishable + secret keys. +2. **Anthropic** — create an API key at https://console.anthropic.com. +3. **Docker** — Docker Desktop (or any daemon `dockerode` can reach via `/var/run/docker.sock`). + +Then: + +```bash +# Backend env +cp .env.example .env +# fill in CLERK_SECRET_KEY, CLERK_PUBLISHABLE_KEY, ANTHROPIC_API_KEY + +# Frontend env +cd growqr-frontend +cp .env.example .env.local +# fill in NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY +cd .. +``` + +## Running + +```bash +# 1. Start Postgres + Rivet engine (per-user OpenCode/Gitea containers are +# spawned dynamically by the backend when a user signs in). +docker compose up -d postgres rivet-engine + +# 2. Install + migrate +npm install +npm run db:migrate + +# 3. Backend +npm run dev # http://localhost:4000 + +# 4. Frontend (separate terminal) +cd growqr-frontend +npm install +npm run dev # http://localhost:3000 +``` + +Open http://localhost:3000, sign up, verify your email, and the home page will: + +1. Mirror your Clerk user into Postgres. +2. Spawn your dedicated Gitea + OpenCode containers (first run pulls images — ~20–40s). +3. Connect the Grow Agent chat to your dedicated Rivet actor. +4. Stream agent + sub-agent events back to the UI. ## Architecture ``` - Frontend (Next.js + Rivet Kit React SDK) - │ - ▼ - Backend (Hono + Rivet Kit client + dockerode) - │ - ├─▶ rivet-engine (compose service) → Grow Agent + Sub-Agent actors - │ - └─▶ Docker daemon (host) - ├─ growqr-gitea- (one per user, spawned on demand) - └─ growqr-opencode- (one per user, spawned on demand) +Browser + │ Clerk JWT + ▼ +Next.js (3000) ──fetch──▶ /users/bootstrap, /users/me (Hono on 4000) + │ + └─Rivet client──▶ /api/rivet/* (Hono → registry.handler) + │ + ▼ + Grow Agent actor (one per user) + │ + ├─ Anthropic (Opus 4.7 + tool use) + ├─ commit_memory ────▶ Gitea container (per user) + └─ spawn_sub_agent ────▶ OpenCode container (per user) + (multiplexed sessions) ``` -The backend mounts the host Docker socket so it can spawn the per-user -container pair via `dockerode` on `POST /actors/provision`. - -## Layout - -``` -src/ - config.ts env config - log.ts pino logger - index.ts Hono app entrypoint - docker/manager.ts dockerode wrapper — spawns Gitea + OpenCode per user - actors/ - grow-agent.ts Rivet Kit master actor (one per user) - sub-agent.ts Rivet Kit worker actor (workflows → OpenCode Docker) - registry.ts Rivet Kit actor registry setup - routes/ - actors.ts PRD §5.2 — actor registry HTTP API - opencode.ts PRD §5.3 — OpenCode Docker management - git.ts PRD §5.4 — Gitea Docker management -``` - -## Local setup - -Prereqs: Node 22+, Docker, `docker compose`. +## Useful commands ```bash -cp .env.example .env -npm install -docker compose up -d rivet-engine # start Rivet engine -npm run dev # start backend on :4000 +npm run typecheck # backend +npm run db:generate # diff schema → new migration +npm run db:studio # browse Postgres via Drizzle Studio + +cd growqr-frontend +npx tsc --noEmit # frontend types +npm run lint ``` -The backend pulls the Gitea + OpenCode images on first user provision (no need to pre-pull). +## Troubleshooting -## Smoke test +- **"missing bearer token"** from `/users/bootstrap` — Clerk session not attached. Sign out and back in. +- **`Gitea did not become ready`** during provisioning — Gitea takes 10–20s on first pull. Wait, then `POST /actors/provision` (the frontend retries via polling). +- **OpenCode container exits immediately** — check `OPENCODE_IMAGE`. The compose env passes `Cmd: ["serve", ...]`; if you swap to a different image, ensure it exposes the `opencode serve` HTTP surface on `:4096`. +- **`No free ports in USER_PORT_RANGE`** — bump `USER_PORT_RANGE_END` in `.env` or stop unused user stacks via `POST /actors/stop`. -```bash -# Provision a Grow Agent + spawn its OpenCode + Gitea Docker -curl -X POST localhost:4000/actors/provision \ - -H 'content-type: application/json' \ - -d '{"userId":"u_alice"}' +## PRD status -# Send a message -curl -X POST localhost:4000/actors/u_alice/message \ - -H 'content-type: application/json' \ - -d '{"text":"hello"}' +All MVP items in `docs/PRD.md` §9 are implemented: -# Spawn a sub-agent -curl -X POST localhost:4000/actors/u_alice/sub-agents \ - -H 'content-type: application/json' \ - -d '{"type":"coding","channelId":"ch-coding-1"}' +- [x] User auth (Clerk) +- [x] Actor registry (Postgres + Rivet) +- [x] One Grow Agent Rivet Kit actor per user +- [x] Sub-agent registration under the Grow Agent +- [x] Per-user Gitea Docker + memory repo +- [x] Memory commits into the user's Gitea +- [x] Per-user OpenCode Docker + session API +- [x] Message endpoint from frontend to Grow Agent (via Rivet) +- [x] Event stream from agent to frontend (via Rivet broadcast) +- [x] Frontend: login, master chat, sub-agent progress, channel-style logs -# Run a workflow through OpenCode Docker -curl -X POST localhost:4000/actors/u_alice/sub-agents/coding-/run \ - -H 'content-type: application/json' \ - -d '{"prompt":"scaffold a Hello World"}' - -# Stop the user's stack -curl -X POST localhost:4000/actors/u_alice/stop -``` - -`docker ps` should show `growqr-gitea-u_alice` and `growqr-opencode-u_alice` after provision. - -## What's stubbed - -These are wired structurally but not yet talking to real upstream APIs: - -- OpenCode HTTP/SSE session API (the `routes/opencode.ts` forwarder is a stub). -- Gitea repo/commit calls (the `routes/git.ts` handlers are stubs). -- Auth (PRD §5.1) and payments (PRD §5.5). -- Persistent registry — currently in-memory; replace with a real DB. - -## Frontend - -The Next.js frontend lives at `../growqr-frontend` (currently cloned as a nested dir at `./growqr-frontend` — git-ignored from this repo). It will install `@rivetkit/react` and connect to the Grow Agent actor for chat + streaming events. +Payments and the full quest/pathway runner are deferred to v2 (PRD §10). diff --git a/docker-compose.yml b/docker-compose.yml index bc6cc60..79843f4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,24 @@ services: + # Postgres for backend metadata (users, actor registry, billing, + # repo/container mappings). PRD §11. + postgres: + image: postgres:16-alpine + container_name: growqr-postgres + environment: + POSTGRES_USER: ${POSTGRES_USER:-growqr} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-growqr} + POSTGRES_DB: ${POSTGRES_DB:-growqr} + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-growqr}"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + # Self-hosted Rivet engine. The backend's Rivet Kit client connects here. # Per the PRD, the Grow Agent + sub-agents are durable actors running on Rivet. rivet-engine: @@ -21,24 +41,41 @@ services: dockerfile: Dockerfile container_name: growqr-backend depends_on: - - rivet-engine + postgres: + condition: service_healthy + rivet-engine: + condition: service_started ports: - "4000:4000" environment: PORT: 4000 + NODE_ENV: ${NODE_ENV:-production} + DATABASE_URL: postgres://${POSTGRES_USER:-growqr}:${POSTGRES_PASSWORD:-growqr}@postgres:5432/${POSTGRES_DB:-growqr} RIVET_ENDPOINT: http://rivet-engine:6420 + CLERK_SECRET_KEY: ${CLERK_SECRET_KEY} + CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_KEY} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY} + GROW_AGENT_MODEL: ${GROW_AGENT_MODEL:-claude-opus-4-7} + SUB_AGENT_MODEL: ${SUB_AGENT_MODEL:-claude-sonnet-4-6} + SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token} GITEA_IMAGE: ${GITEA_IMAGE:-gitea/gitea:1.22} OPENCODE_IMAGE: ${OPENCODE_IMAGE:-ghcr.io/sst/opencode:latest} - USER_CONTAINER_HOST: ${USER_CONTAINER_HOST:-127.0.0.1} + USER_CONTAINER_HOST: ${USER_CONTAINER_HOST:-host.docker.internal} USER_DATA_ROOT: /data/users USER_PORT_RANGE_START: 20000 USER_PORT_RANGE_END: 29999 + FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000} volumes: # Docker-out-of-Docker: backend uses host Docker to spawn user containers. - /var/run/docker.sock:/var/run/docker.sock # Shared host dir that per-user containers will also bind-mount their # workspace from (so backend and spawned containers see the same files). - ./.data/users:/data/users + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://localhost:4000/healthz || exit 1"] + interval: 10s + timeout: 5s + retries: 6 restart: unless-stopped # Note: per-user OpenCode + Gitea containers are NOT defined here. @@ -47,3 +84,4 @@ services: volumes: rivet-data: + postgres-data: diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 0000000..069f739 --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,394 @@ + + + + + +GrowQR — Architecture + + + +
+ +
+

GrowQR — Architectural Diagram

+
v1.0
+
+ +

+ Every user gets their own private Grow Agent (a Rivet Kit actor) that orchestrates sub-agents + and owns a dedicated sandboxed runtime — an OpenCode container for tool execution and a Gitea container for + long-term memory. The frontend talks to the actor backend over a persistent connection; agents stream events, + commit memory to the user's private git, and read/write structured state in the shared database. +

+ +
+ + + + + + + + + + + + + + Vercel / OpenNext + + + UI + Next.js 16 · React 19 + + + frontend JS + – auth + – actors mgmt + – chat / event stream + – payments + + + + + + auth + Clerk v6 + + + + + + payments + Stripe + + + + + + + + Threads API + – session tracking + – message logs + Hono · /api/rivet/* + + + + + + + + Actor Backend · Hono + Rivet Kit + + + Actor manager + Actor Runner + Actor Engine + Actor Storage + growAgent · subAgent + + + + + + + + SandBoxed Runtime · per-user Docker + service scale + + + Agent runtime · OpenCode + Agent Runtime + – sub-agents + – skills loading + – tool execution + – dockerized + + + + + + + + Memory API + – tracking memory + – 3 layers of memory + commit_memory · read_memory · list_memory + + + + + + + + service Scale + + + Users-repos + Git manager · per-user Gitea + growqr-memory.git + + + + + + + + DB / PG / AWS RDS + users · user_stacks · actors · repos · opencode_sessions · events + + + + + + + + + + fetch · JWT + + + + + rivet-client + + + + + + + + + + + + + + spawn_sub_agent + + + + + SSE events + + + + + + + + Gitea REST + + + + + drizzle + + + + + +
+ +
+
+ +
+

UI

+

Next.js 16 + React 19 on Vercel / OpenNext. Auth flows, chat composer, event console, and actor management. Talks to the actor backend over the Rivet Kit client and REST endpoints with a Clerk JWT.

+
+
+
+ +
+

auth

+

Clerk on browser and server. JWT is verified on every request; users are mirrored into Postgres on first sight.

+
+
+
+ +
+

payments

+

Stripe billing — plans, metering, webhooks, and customer portal. Flows through the same JWT identity as the rest of the app.

+
+
+
+ +
+

Threads API

+

Hono routes for session listing and message logs. All persistent state flows through Postgres; the Rivet handler is mounted at /api/rivet/*.

+
+
+
+ +
+

Actor Backend

+

Rivet Kit actors orchestrated by Hono. Two actor types — growAgent (one master per user) and subAgent (workers). Runner, Engine, and Storage are provided by Rivet; durable state mirrors into Postgres.

+
+
+
+ +
+

Agent Runtime

+

Per-user OpenCode container spawned via dockerode on first sign-in. Hosts sub-agent sessions, skill loading, and sandboxed tool execution. Streams events back over SSE which the Grow Agent re-broadcasts to the UI.

+
+
+
+ +
+

Memory API

+

Three-layer memory surface exposed to Claude as tools (commit_memory, read_memory, list_memory). L1 in-actor state, L2 session in Postgres, L3 long-term in the user's Gitea repo.

+
+
+
+ +
+

Git manager · Users-repos

+

Per-user Gitea container. Backend creates an admin user, mints an access token, and bootstraps a private growqr-memory repo. Long-term memory commits land here as plain markdown.

+
+
+
+ +
+

DB / PG / AWS RDS

+

Postgres + Drizzle ORM. Tables: users, user_stacks, actors, repos, opencode_sessions, events. In production this is AWS RDS; in development it's the Postgres service in docker-compose.

+
+
+
+ +
+ GrowQR · architecture overview +
+ +
+ + diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..ffcc401 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,13 @@ +import "dotenv/config"; +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/db/schema.ts", + out: "./drizzle", + dialect: "postgresql", + dbCredentials: { + url: + process.env.DATABASE_URL ?? + "postgres://growqr:growqr@localhost:5432/growqr", + }, +}); diff --git a/drizzle/0000_init.sql b/drizzle/0000_init.sql new file mode 100644 index 0000000..4131680 --- /dev/null +++ b/drizzle/0000_init.sql @@ -0,0 +1,81 @@ +CREATE TABLE "actors" ( + "actor_id" text NOT NULL, + "user_id" text NOT NULL, + "kind" text NOT NULL, + "sub_type" text, + "status" text DEFAULT 'idle' NOT NULL, + "channel_id" text, + "parent_actor_id" text, + "last_activity_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "actors_user_id_actor_id_pk" PRIMARY KEY("user_id","actor_id") +); +--> statement-breakpoint +CREATE TABLE "events" ( + "id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL, + "user_id" text NOT NULL, + "actor_id" text, + "type" text NOT NULL, + "payload" jsonb, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "opencode_sessions" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "actor_id" text, + "title" text, + "parent_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "repos" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "name" text NOT NULL, + "role" text NOT NULL, + "gitea_owner" text NOT NULL, + "gitea_name" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "user_stacks" ( + "user_id" text PRIMARY KEY NOT NULL, + "status" text DEFAULT 'provisioning' NOT NULL, + "gitea_container_id" text, + "gitea_container_name" text, + "gitea_host" text, + "gitea_http_port" integer, + "gitea_ssh_port" integer, + "gitea_admin_user" text, + "gitea_admin_token" text, + "gitea_memory_repo" text, + "opencode_container_id" text, + "opencode_container_name" text, + "opencode_host" text, + "opencode_port" integer, + "opencode_password" text, + "workspace_path" text, + "last_error" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "users" ( + "id" text PRIMARY KEY NOT NULL, + "email" text NOT NULL, + "display_name" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "actors" ADD CONSTRAINT "actors_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "opencode_sessions" ADD CONSTRAINT "opencode_sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "repos" ADD CONSTRAINT "repos_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "user_stacks" ADD CONSTRAINT "user_stacks_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "actors_user_kind_idx" ON "actors" USING btree ("user_id","kind");--> statement-breakpoint +CREATE INDEX "events_user_idx" ON "events" USING btree ("user_id","created_at");--> statement-breakpoint +CREATE INDEX "opencode_sessions_user_idx" ON "opencode_sessions" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "repos_user_role_idx" ON "repos" USING btree ("user_id","role");--> statement-breakpoint +CREATE INDEX "user_stacks_status_idx" ON "user_stacks" USING btree ("status");--> statement-breakpoint +CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email"); \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..b8b3d97 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,598 @@ +{ + "id": "85ed4865-0f8a-4720-8290-91b8e4f2efec", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.actors": { + "name": "actors", + "schema": "", + "columns": { + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_type": { + "name": "sub_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_actor_id": { + "name": "parent_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "actors_user_kind_idx": { + "name": "actors_user_kind_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "actors_user_id_users_id_fk": { + "name": "actors_user_id_users_id_fk", + "tableFrom": "actors", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "actors_user_id_actor_id_pk": { + "name": "actors_user_id_actor_id_pk", + "columns": [ + "user_id", + "actor_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.events": { + "name": "events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "events_user_idx": { + "name": "events_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.opencode_sessions": { + "name": "opencode_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "opencode_sessions_user_idx": { + "name": "opencode_sessions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "opencode_sessions_user_id_users_id_fk": { + "name": "opencode_sessions_user_id_users_id_fk", + "tableFrom": "opencode_sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repos": { + "name": "repos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "gitea_owner": { + "name": "gitea_owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "gitea_name": { + "name": "gitea_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repos_user_role_idx": { + "name": "repos_user_role_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repos_user_id_users_id_fk": { + "name": "repos_user_id_users_id_fk", + "tableFrom": "repos", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stacks": { + "name": "user_stacks", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'provisioning'" + }, + "gitea_container_id": { + "name": "gitea_container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitea_container_name": { + "name": "gitea_container_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitea_host": { + "name": "gitea_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitea_http_port": { + "name": "gitea_http_port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "gitea_ssh_port": { + "name": "gitea_ssh_port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "gitea_admin_user": { + "name": "gitea_admin_user", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitea_admin_token": { + "name": "gitea_admin_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitea_memory_repo": { + "name": "gitea_memory_repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "opencode_container_id": { + "name": "opencode_container_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "opencode_container_name": { + "name": "opencode_container_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "opencode_host": { + "name": "opencode_host", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "opencode_port": { + "name": "opencode_port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "opencode_password": { + "name": "opencode_password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_path": { + "name": "workspace_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_stacks_status_idx": { + "name": "user_stacks_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_stacks_user_id_users_id_fk": { + "name": "user_stacks_user_id_users_id_fk", + "tableFrom": "user_stacks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..78b85c6 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1779161128463, + "tag": "0000_init", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 35c2980..ec8e214 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,18 +8,23 @@ "name": "growqr-backend", "version": "0.1.0", "dependencies": { + "@anthropic-ai/sdk": "^0.96.0", + "@clerk/backend": "^1.21.0", "@hono/node-server": "^1.13.7", "dockerode": "^4.0.7", "dotenv": "^16.4.7", + "drizzle-orm": "^0.36.4", "hono": "^4.6.14", "pino": "^9.5.0", "pino-pretty": "^13.0.0", + "postgres": "^3.4.5", "rivetkit": "^2.2.1", "zod": "^3.24.1" }, "devDependencies": { "@types/dockerode": "^3.3.32", "@types/node": "^22.10.5", + "drizzle-kit": "^0.31.2", "tsx": "^4.19.2", "typescript": "^5.7.2" } @@ -33,6 +38,36 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.96.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.96.0.tgz", + "integrity": "sha512-KlCsODtTyb17bLUVCSDC2HtSvAbJf60sEiPEax9dInF+aDF92vS4TZJ5XD7YCQXNb1/5icYaw8Y7wMjPlIV9Zg==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@balena/dockerignore": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", @@ -117,6 +152,515 @@ "win32" ] }, + "node_modules/@clerk/backend": { + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-1.34.0.tgz", + "integrity": "sha512-9rZ8hQJVpX5KX2bEpiuVXfpjhojQCiqCWADJDdCI0PCeKxn58Ep0JPYiIcczg4VKUc3a7jve9vXylykG2XajLQ==", + "license": "MIT", + "dependencies": { + "@clerk/shared": "^3.9.5", + "@clerk/types": "^4.59.3", + "cookie": "1.0.2", + "snakecase-keys": "8.0.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=18.17.0" + }, + "peerDependencies": { + "svix": "^1.62.0" + }, + "peerDependenciesMeta": { + "svix": { + "optional": true + } + } + }, + "node_modules/@clerk/shared": { + "version": "3.47.5", + "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-3.47.5.tgz", + "integrity": "sha512-rDVe73/VN2NZXhtrLRHshkUpQDrevAqDRxeXUl2M0IBEBkcl+VMHlV7fep53cVWo0b3gIqLk82pmmi+WoyF/xg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "csstype": "3.1.3", + "dequal": "2.0.3", + "glob-to-regexp": "0.4.1", + "js-cookie": "3.0.5", + "std-env": "^3.9.0", + "swr": "2.3.4" + }, + "engines": { + "node": ">=18.17.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", + "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@clerk/types": { + "version": "4.101.23", + "resolved": "https://registry.npmjs.org/@clerk/types/-/types-4.101.23.tgz", + "integrity": "sha512-t5ypYYDkT5TPaNIDjLnYk9GpkJgwNTBiS7h6FuUTjoySQtf7amNDS1A1eOu7NOcVpqiSeKg+0wzGxxcre00kMA==", + "license": "MIT", + "dependencies": { + "@clerk/shared": "^3.47.5" + }, + "engines": { + "node": ">=18.17.0" + } + }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.is", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.is", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", @@ -1552,6 +2096,12 @@ "linux" ] }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", @@ -1755,6 +2305,7 @@ "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -1950,6 +2501,13 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/buffer-xor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", @@ -2124,6 +2682,15 @@ "integrity": "sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==", "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -2238,6 +2805,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, "node_modules/dateformat": { "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", @@ -2322,6 +2895,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/des.js": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", @@ -2404,6 +2986,16 @@ "url": "https://bevry.me/fund" } }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -2416,6 +3008,632 @@ "url": "https://dotenvx.com" } }, + "node_modules/drizzle-kit": { + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/drizzle-kit/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/drizzle-orm": { + "version": "0.36.4", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.36.4.tgz", + "integrity": "sha512-1OZY3PXD7BR00Gl61UUOFihslDldfH4NFRH2MbP54Yxi0G/PKn4HfO65JYZ7c16DeP3SpM3Aw+VXVG9j6CRSXA==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=3", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/react": ">=18", + "@types/sql.js": "*", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "react": ">=18", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2506,7 +3724,7 @@ "version": "0.28.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -2593,6 +3811,12 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fdb-tuple": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fdb-tuple/-/fdb-tuple-1.0.0.tgz", @@ -2734,12 +3958,31 @@ "node": ">= 0.4" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3056,12 +4299,34 @@ "node": ">=10" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3107,6 +4372,27 @@ "loose-envify": "cli.js" } }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3234,6 +4520,16 @@ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", "license": "MIT" }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, "node_modules/node-abi": { "version": "3.92.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", @@ -3587,6 +4883,20 @@ "node": ">= 0.4" } }, + "node_modules/postgres": { + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", + "license": "Unlicense", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -3789,6 +5099,16 @@ "node": ">=0.10.0" } }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -3842,6 +5162,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -4358,6 +5688,30 @@ "simple-concat": "^1.0.0" } }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/snakecase-keys": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/snakecase-keys/-/snakecase-keys-8.0.1.tgz", + "integrity": "sha512-Sj51kE1zC7zh6TDlNNz0/Jn1n5HiHdoQErxO8jLtnyrkJW/M5PrI7x05uDgY3BO7OUQYKCvmeMurW6BPUdwEOw==", + "license": "MIT", + "dependencies": { + "map-obj": "^4.1.0", + "snake-case": "^3.0.4", + "type-fest": "^4.15.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -4367,6 +5721,27 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/split-ca": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", @@ -4399,6 +5774,22 @@ "nan": "^2.23.0" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "license": "MIT" + }, "node_modules/stream-browserify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", @@ -4480,6 +5871,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.4.tgz", + "integrity": "sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/tar": { "version": "7.5.15", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", @@ -4568,11 +5972,23 @@ "node": ">= 0.4" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tsx": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz", "integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "~0.28.0" @@ -4611,6 +6027,18 @@ "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", "license": "Unlicense" }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -4658,6 +6086,15 @@ "node": ">= 0.4" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", diff --git a/package.json b/package.json index a6f52b7..d58bfc6 100644 --- a/package.json +++ b/package.json @@ -8,22 +8,30 @@ "build": "tsc -p tsconfig.json", "start": "node dist/index.js", "typecheck": "tsc -p tsconfig.json --noEmit", + "db:generate": "drizzle-kit generate", + "db:migrate": "tsx src/db/migrate.ts", + "db:studio": "drizzle-kit studio", "compose:up": "docker compose up -d", "compose:down": "docker compose down" }, "dependencies": { + "@anthropic-ai/sdk": "^0.96.0", + "@clerk/backend": "^1.21.0", "@hono/node-server": "^1.13.7", "dockerode": "^4.0.7", "dotenv": "^16.4.7", + "drizzle-orm": "^0.36.4", "hono": "^4.6.14", "pino": "^9.5.0", "pino-pretty": "^13.0.0", + "postgres": "^3.4.5", "rivetkit": "^2.2.1", "zod": "^3.24.1" }, "devDependencies": { "@types/dockerode": "^3.3.32", "@types/node": "^22.10.5", + "drizzle-kit": "^0.31.2", "tsx": "^4.19.2", "typescript": "^5.7.2" } diff --git a/src/actors/grow-agent.ts b/src/actors/grow-agent.ts index c50bb4f..6ab564d 100644 --- a/src/actors/grow-agent.ts +++ b/src/actors/grow-agent.ts @@ -1,110 +1,333 @@ import { actor } from "rivetkit"; +import type Anthropic from "@anthropic-ai/sdk"; import { log } from "../log.js"; +import { config } from "../config.js"; +import { + anthropic, + GROW_AGENT_SYSTEM, + growAgentTools, +} from "../lib/anthropic.js"; import { provisionUserStack, getUserStack, stopUserStack, - type UserStack, + giteaClientFor, } from "../docker/manager.js"; +import { runSubAgentTask } from "./sub-agent-runner.js"; +import { db } from "../db/client.js"; +import { actors as actorsTable, events as eventsTable } from "../db/schema.js"; -type Memory = { timestamp: number; role: "user" | "agent"; text: string }; - -type SubAgentRef = { - id: string; - type: string; - status: "pending" | "running" | "done" | "error"; - channelId: string; - startedAt: number; +type ChatTurn = { + role: "user" | "assistant"; + // Anthropic content blocks; "user" turns may also be plain strings. + content: string | Anthropic.ContentBlockParam[]; }; type GrowAgentState = { userId: string; - profileSummary: string; goals: string[]; - memory: Memory[]; - subAgents: Record; - stack: UserStack | null; + history: ChatTurn[]; + // Trimmed once it grows past N turns; long history is delegated to memory repo. + maxHistory: number; }; const initialState: GrowAgentState = { userId: "", - profileSummary: "", goals: [], - memory: [], - subAgents: {}, - stack: null, + history: [], + maxHistory: 40, }; -// The Grow Agent is the user's master orchestrator (PRD §3.2). -// One instance per user. It owns the per-user OpenCode + Gitea Docker stack -// and routes sub-agent workflow execution through that OpenCode Docker. +const MEMORY_REPO_PATH_LIMIT = 1024; + +// One Grow Agent actor instance per user (key the actor by userId). +// Owns the user's Docker stack + LLM conversation loop. export const growAgent = actor({ state: initialState, actions: { + // Idempotent. Provisions the per-user OpenCode + Gitea stack if missing. init: async (c, input: { userId: string }) => { if (c.state.userId && c.state.userId !== input.userId) { throw new Error("Grow Agent already bound to a different user"); } c.state.userId = input.userId; - if (!c.state.stack) { - c.state.stack = await provisionUserStack(input.userId); - log.info({ userId: input.userId }, "Grow Agent provisioned stack"); - } - return c.state.stack; + const stack = await provisionUserStack(input.userId); + + await db + .insert(actorsTable) + .values({ + actorId: `grow-${input.userId}`, + userId: input.userId, + kind: "grow", + status: "idle", + lastActivityAt: new Date(), + }) + .onConflictDoNothing(); + + c.broadcast("stack-ready", { + userId: input.userId, + opencode: `${stack.opencodeHost}:${stack.opencodePort}`, + gitea: `${stack.giteaHost}:${stack.giteaHttpPort}`, + memoryRepo: stack.giteaMemoryRepo, + }); + return stack; }, + // Main chat entry point. Runs the full agentic loop with Claude. receiveMessage: async (c, msg: { text: string }) => { - c.state.memory.push({ - timestamp: Date.now(), - role: "user", - text: msg.text, - }); - c.broadcast("message", { role: "user", text: msg.text }); - const reply = `Got it. (stack: ${c.state.stack?.opencode.name ?? "none"})`; - c.state.memory.push({ - timestamp: Date.now(), - role: "agent", - text: reply, - }); - c.broadcast("message", { role: "agent", text: reply }); - return { reply }; - }, - - spawnSubAgent: async (c, input: { type: string; channelId: string }) => { - const id = `${input.type}-${Date.now()}`; - const ref: SubAgentRef = { - id, - type: input.type, - status: "pending", - channelId: input.channelId, - startedAt: Date.now(), - }; - c.state.subAgents[id] = ref; - c.broadcast("sub-agent-spawned", ref); - return ref; - }, - - updateSubAgent: async ( - c, - input: { id: string; status: SubAgentRef["status"] }, - ) => { - const ref = c.state.subAgents[input.id]; - if (!ref) throw new Error(`Unknown sub-agent ${input.id}`); - ref.status = input.status; - c.broadcast("sub-agent-updated", ref); - return ref; - }, - - getStack: async (c) => { - if (!c.state.stack && c.state.userId) { - c.state.stack = getUserStack(c.state.userId) ?? null; + if (!c.state.userId) { + throw new Error("Grow Agent not initialized"); } - return c.state.stack; + + const userTurn: ChatTurn = { role: "user", content: msg.text }; + c.state.history.push(userTurn); + c.broadcast("message", { role: "user", text: msg.text }); + + const assistantText = await runAgentLoop(c, c.state.userId); + + // Trim history to maxHistory turns; long-term context lives in Gitea. + while (c.state.history.length > c.state.maxHistory) { + c.state.history.shift(); + } + + await db + .insert(eventsTable) + .values({ + userId: c.state.userId, + actorId: `grow-${c.state.userId}`, + type: "grow.message", + payload: { userText: msg.text, assistantText }, + }); + + return { reply: assistantText }; }, + // Sub-agent status updates fan back in via this action; the Grow Agent + // broadcasts them so the frontend's sidebar can render them under the + // right channel. + subAgentEvent: async ( + c, + input: { + subAgentId: string; + type: "started" | "progress" | "done" | "error"; + message?: string; + result?: unknown; + }, + ) => { + c.broadcast("sub-agent-event", input); + }, + + getHistory: async (c) => c.state.history, + getGoals: async (c) => c.state.goals, + shutdown: async (c) => { if (c.state.userId) await stopUserStack(c.state.userId); - c.state.stack = null; }, }, }); + +// The agentic loop. Keeps calling Claude with tools until stop_reason === "end_turn". +async function runAgentLoop( + c: { + state: GrowAgentState; + broadcast: (event: string, data: unknown) => void; + }, + userId: string, +): Promise { + if (!config.anthropicApiKey) { + const reply = + "ANTHROPIC_API_KEY is not configured on the backend — set it to enable the Grow Agent."; + c.state.history.push({ role: "assistant", content: reply }); + c.broadcast("message", { role: "agent", text: reply }); + return reply; + } + + c.broadcast("agent-thinking", { state: "running" }); + + const MAX_ITERATIONS = 8; + let assistantTextOut = ""; + + for (let i = 0; i < MAX_ITERATIONS; i++) { + const response = await anthropic.messages.create({ + model: config.growAgentModel, + max_tokens: config.maxAgentTokens, + system: [ + { + type: "text", + text: GROW_AGENT_SYSTEM, + cache_control: { type: "ephemeral" }, + }, + ], + tools: growAgentTools, + thinking: { type: "adaptive" }, + messages: messagesForApi(c.state.history), + }); + + // Capture assistant text for streaming-style broadcast. + for (const block of response.content) { + if (block.type === "text" && block.text) { + assistantTextOut += (assistantTextOut ? "\n\n" : "") + block.text; + c.broadcast("message", { role: "agent", text: block.text }); + } + } + + // Persist the full assistant turn (so subsequent loops keep tool_use blocks). + c.state.history.push({ + role: "assistant", + content: response.content as Anthropic.ContentBlockParam[], + }); + + if (response.stop_reason !== "tool_use") { + break; + } + + const toolUses = response.content.filter( + (b): b is Anthropic.ToolUseBlock => b.type === "tool_use", + ); + if (toolUses.length === 0) break; + + const toolResults: Anthropic.ToolResultBlockParam[] = []; + for (const call of toolUses) { + try { + const result = await dispatchTool(c, userId, call); + toolResults.push({ + type: "tool_result", + tool_use_id: call.id, + content: typeof result === "string" ? result : JSON.stringify(result), + }); + } catch (err) { + log.error({ err, tool: call.name }, "tool dispatch failed"); + toolResults.push({ + type: "tool_result", + tool_use_id: call.id, + content: `Error: ${err instanceof Error ? err.message : String(err)}`, + is_error: true, + }); + } + } + + c.state.history.push({ role: "user", content: toolResults }); + } + + c.broadcast("agent-thinking", { state: "idle" }); + return assistantTextOut || "(no response)"; +} + +function messagesForApi( + history: ChatTurn[], +): Anthropic.MessageParam[] { + return history.map((t) => ({ + role: t.role, + content: t.content, + })) as Anthropic.MessageParam[]; +} + +async function dispatchTool( + c: { + broadcast: (event: string, data: unknown) => void; + state: GrowAgentState; + }, + userId: string, + call: Anthropic.ToolUseBlock, +): Promise { + const input = call.input as Record; + switch (call.name) { + case "spawn_sub_agent": { + const type = String(input.type ?? "generic"); + const prompt = String(input.prompt ?? ""); + const channelId = + typeof input.channelId === "string" + ? input.channelId + : `${type}-${Date.now()}`; + const id = `sub-${type}-${Date.now()}`; + await db + .insert(actorsTable) + .values({ + actorId: id, + userId, + kind: "sub", + subType: type, + status: "running", + channelId, + parentActorId: `grow-${userId}`, + lastActivityAt: new Date(), + }); + c.broadcast("sub-agent-spawned", { id, type, channelId, prompt }); + + // Fire-and-forget; the runner updates DB + broadcasts via the actor. + void runSubAgentTask({ + userId, + subAgentId: id, + type, + prompt, + channelId, + onEvent: (event, data) => c.broadcast(event, data), + }); + + return { id, type, channelId, status: "running" }; + } + + case "commit_memory": { + const path = String(input.path ?? "").slice(0, MEMORY_REPO_PATH_LIMIT); + const content = String(input.content ?? ""); + const message = String(input.message ?? "memory update"); + const client = await giteaClientFor(userId); + const stack = await getUserStack(userId); + if (!client || !stack?.giteaMemoryRepo) { + return { ok: false, error: "memory repo not provisioned" }; + } + const [owner, repo] = stack.giteaMemoryRepo.split("/") as [string, string]; + const result = await client.putFile({ + owner, + repo, + path, + contentUtf8: content, + message, + }); + c.broadcast("memory-committed", { path, message }); + return { ok: true, path, commitSha: result.commitSha }; + } + + case "read_memory": { + const path = String(input.path ?? ""); + const client = await giteaClientFor(userId); + const stack = await getUserStack(userId); + if (!client || !stack?.giteaMemoryRepo) return null; + const [owner, repo] = stack.giteaMemoryRepo.split("/") as [string, string]; + const text = await client.readFile({ owner, repo, path }); + return text; + } + + case "list_memory": { + const pathPrefix = String(input.pathPrefix ?? ""); + const client = await giteaClientFor(userId); + const stack = await getUserStack(userId); + if (!client || !stack?.giteaMemoryRepo) return []; + const [owner, repo] = stack.giteaMemoryRepo.split("/") as [string, string]; + // Gitea contents API on a directory returns an array of entries. + try { + const res = await fetch( + `http://${stack.giteaHost}:${stack.giteaHttpPort}/api/v1/repos/${owner}/${repo}/contents/${encodeURI(pathPrefix)}`, + { + headers: { + authorization: `token ${stack.giteaAdminToken}`, + accept: "application/json", + }, + }, + ); + if (!res.ok) return []; + const entries = (await res.json()) as Array<{ + name: string; + path: string; + type: string; + }>; + return entries.map((e) => ({ name: e.name, path: e.path, type: e.type })); + } catch { + return []; + } + } + + default: + throw new Error(`unknown tool: ${call.name}`); + } +} diff --git a/src/actors/sub-agent-runner.ts b/src/actors/sub-agent-runner.ts new file mode 100644 index 0000000..3c6a60a --- /dev/null +++ b/src/actors/sub-agent-runner.ts @@ -0,0 +1,103 @@ +import { eq, and } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { actors as actorsTable, opencodeSessions } from "../db/schema.js"; +import { log } from "../log.js"; +import { OpencodeClient } from "../lib/opencode.js"; +import { opencodeUrlFor } from "../docker/manager.js"; + +export type SubAgentRunInput = { + userId: string; + subAgentId: string; + type: string; + prompt: string; + channelId: string; + onEvent: (event: string, data: unknown) => void; +}; + +// Runs a single sub-agent task by opening an OpenCode session and forwarding +// the user-provided prompt. Streams events back to the caller (the Grow Agent +// actor's broadcast surface) and updates the actors table on completion. +// +// Sub-agents do NOT spawn their own containers — they multiplex through the +// parent Grow Agent's OpenCode container (PRD §3.3). +export async function runSubAgentTask(input: SubAgentRunInput): Promise { + const { userId, subAgentId, type, prompt, channelId, onEvent } = input; + try { + const target = await opencodeUrlFor(userId); + if (!target) { + throw new Error("OpenCode container not provisioned for user"); + } + const client = new OpencodeClient(target.baseUrl, target.password); + + const session = await client.createSession({ + title: `${type} :: ${subAgentId}`, + }); + await db.insert(opencodeSessions).values({ + id: session.id, + userId, + actorId: subAgentId, + title: session.title ?? null, + }); + + onEvent("sub-agent-event", { + subAgentId, + type: "started", + channelId, + sessionId: session.id, + }); + + // Open SSE stream for live progress. + const aborter = client.streamEvents((ev) => { + onEvent("sub-agent-event", { + subAgentId, + type: "progress", + channelId, + event: ev.event, + data: ev.data, + }); + }); + + // Send the prompt synchronously and capture the final response text. + const result = await client.sendMessage({ + sessionId: session.id, + text: prompt, + }); + aborter.abort(); + + await db + .update(actorsTable) + .set({ status: "done", lastActivityAt: new Date() }) + .where( + and( + eq(actorsTable.userId, userId), + eq(actorsTable.actorId, subAgentId), + ), + ); + + onEvent("sub-agent-event", { + subAgentId, + type: "done", + channelId, + result, + }); + log.info({ subAgentId, sessionId: session.id }, "sub-agent done"); + } catch (err) { + log.error({ err, subAgentId }, "sub-agent failed"); + await db + .update(actorsTable) + .set({ status: "error", lastActivityAt: new Date() }) + .where( + and( + eq(actorsTable.userId, userId), + eq(actorsTable.actorId, subAgentId), + ), + ) + .catch(() => undefined); + onEvent("sub-agent-event", { + subAgentId, + type: "error", + channelId, + message: err instanceof Error ? err.message : String(err), + }); + } +} diff --git a/src/actors/sub-agent.ts b/src/actors/sub-agent.ts index a24380c..1ededb1 100644 --- a/src/actors/sub-agent.ts +++ b/src/actors/sub-agent.ts @@ -1,28 +1,33 @@ import { actor } from "rivetkit"; -import { log } from "../log.js"; -import { getUserStack } from "../docker/manager.js"; +import { db } from "../db/client.js"; +import { actors as actorsTable, events as eventsTable } from "../db/schema.js"; +import { and, eq, desc } from "drizzle-orm"; + +type LogEntry = { + ts: number; + level: "info" | "warn" | "error"; + msg: string; +}; type SubAgentState = { parentUserId: string; type: string; status: "idle" | "running" | "done" | "error"; - workspacePath: string; channelId: string; - logs: { ts: number; level: "info" | "warn" | "error"; msg: string }[]; + logs: LogEntry[]; }; const initialState: SubAgentState = { parentUserId: "", type: "generic", status: "idle", - workspacePath: "/workspace", channelId: "", logs: [], }; -// Sub-agents are Rivet Kit worker actors owned by a Grow Agent. -// They DO NOT spawn their own containers — workflows execute by opening -// sessions against the parent Grow Agent's OpenCode Docker (PRD §3.3). +// Sub-agent actor mainly exposes status + logs for the UI. The actual task +// execution lives in sub-agent-runner.ts, invoked by the Grow Agent's tool +// dispatch path (PRD §3.3). export const subAgent = actor({ state: initialState, actions: { @@ -33,37 +38,46 @@ export const subAgent = actor({ c.state.parentUserId = input.parentUserId; c.state.type = input.type; c.state.channelId = input.channelId; + c.state.status = "idle"; }, - runTask: async (c, input: { prompt: string }) => { - const stack = getUserStack(c.state.parentUserId); - if (!stack) throw new Error("Parent Grow Agent has no active stack"); - c.state.status = "running"; - c.state.logs.push({ - ts: Date.now(), - level: "info", - msg: `routing prompt to ${stack.opencode.name}`, - }); - c.broadcast("status", { status: "running" }); + appendLog: async (c, entry: LogEntry) => { + c.state.logs.push(entry); + c.broadcast("log", entry); + }, - // TODO: real HTTP call into the OpenCode Docker management surface. - const result = { - sessionId: `mock-${Date.now()}`, - prompt: input.prompt, - opencode: `${stack.opencode.host}:${stack.opencode.ports.http}`, - }; - c.state.status = "done"; - c.state.logs.push({ - ts: Date.now(), - level: "info", - msg: "task done (mock)", - }); - c.broadcast("status", { status: "done", result }); - log.info({ subAgent: c.state.type, result }, "sub-agent task done"); - return result; + setStatus: async (c, status: SubAgentState["status"]) => { + c.state.status = status; + c.broadcast("status", { status }); }, getLogs: async (c) => c.state.logs, getStatus: async (c) => c.state.status, + + // Pulls historical events from the DB so a returning user sees prior runs. + getHistory: async (c, input: { subAgentId: string }) => { + const rows = await db + .select() + .from(eventsTable) + .where( + and( + eq(eventsTable.userId, c.state.parentUserId), + eq(eventsTable.actorId, input.subAgentId), + ), + ) + .orderBy(desc(eventsTable.createdAt)) + .limit(50); + return rows; + }, + + getActorRow: async (c, input: { subAgentId: string }) => { + const row = await db.query.actors.findFirst({ + where: and( + eq(actorsTable.userId, c.state.parentUserId), + eq(actorsTable.actorId, input.subAgentId), + ), + }); + return row; + }, }, }); diff --git a/src/auth/clerk.ts b/src/auth/clerk.ts new file mode 100644 index 0000000..eb232d0 --- /dev/null +++ b/src/auth/clerk.ts @@ -0,0 +1,97 @@ +import { createClerkClient, verifyToken } from "@clerk/backend"; +import { createMiddleware } from "hono/factory"; +import { HTTPException } from "hono/http-exception"; +import { config } from "../config.js"; +import { db } from "../db/client.js"; +import { users } from "../db/schema.js"; +import { eq } from "drizzle-orm"; + +export type AuthContext = { + Variables: { + userId: string; + userEmail: string; + }; +}; + +export const clerk = config.clerkSecretKey + ? createClerkClient({ secretKey: config.clerkSecretKey }) + : null; + +// Verifies a Clerk session JWT from the Authorization header. +// Falls back to allowing the configured SERVICE_TOKEN for actor → backend calls. +// +// Bootstraps a row in `users` on first sight so downstream code can FK to it. +export const requireUser = createMiddleware(async (c, next) => { + const auth = c.req.header("authorization") ?? ""; + const token = auth.replace(/^Bearer\s+/i, "").trim(); + + // Service-to-service path (Grow Agent actor calling backend). + // Header `x-growqr-user` is REQUIRED so we can scope the call. + if ( + token && + config.serviceToken && + token === config.serviceToken && + c.req.header("x-growqr-user") + ) { + const userId = c.req.header("x-growqr-user")!; + const row = await db.query.users.findFirst({ where: eq(users.id, userId) }); + if (!row) { + throw new HTTPException(401, { message: "service token references unknown user" }); + } + c.set("userId", row.id); + c.set("userEmail", row.email); + return next(); + } + + if (!token) { + throw new HTTPException(401, { message: "missing bearer token" }); + } + + if (!clerk) { + throw new HTTPException(500, { message: "Clerk not configured" }); + } + + let payload: Awaited>; + try { + payload = await verifyToken(token, { + secretKey: config.clerkSecretKey, + }); + } catch { + throw new HTTPException(401, { message: "invalid clerk token" }); + } + + const userId = payload.sub; + if (!userId) { + throw new HTTPException(401, { message: "clerk token missing subject" }); + } + + // Lazy-mirror Clerk user → users table. + let row = await db.query.users.findFirst({ where: eq(users.id, userId) }); + if (!row) { + const clerkUser = await clerk.users.getUser(userId); + const email = + clerkUser.primaryEmailAddress?.emailAddress ?? + clerkUser.emailAddresses[0]?.emailAddress ?? + `${userId}@unknown.local`; + const displayName = + [clerkUser.firstName, clerkUser.lastName].filter(Boolean).join(" ") || + clerkUser.username || + null; + const inserted = await db + .insert(users) + .values({ id: userId, email, displayName }) + .onConflictDoUpdate({ + target: users.id, + set: { email, displayName, updatedAt: new Date() }, + }) + .returning(); + row = inserted[0]; + if (!row) { + throw new HTTPException(500, { message: "failed to upsert user" }); + } + } + + c.set("userId", row.id); + c.set("userEmail", row.email); + return next(); +}); diff --git a/src/config.ts b/src/config.ts index 12a19d8..b135d9d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,13 +1,56 @@ import "dotenv/config"; +function required(name: string, fallback?: string): string { + const v = process.env[name] ?? fallback; + if (!v) { + throw new Error(`Missing required env var: ${name}`); + } + return v; +} + export const config = { port: Number(process.env.PORT ?? 4000), logLevel: process.env.LOG_LEVEL ?? "info", + nodeEnv: process.env.NODE_ENV ?? "development", + + // Postgres metadata DB (users, registry, container mappings). + databaseUrl: + process.env.DATABASE_URL ?? + "postgres://growqr:growqr@localhost:5432/growqr", + + // Clerk auth. + clerkSecretKey: process.env.CLERK_SECRET_KEY ?? "", + clerkPublishableKey: process.env.CLERK_PUBLISHABLE_KEY ?? "", + // Optional: lock service-to-service calls (actor → backend). + serviceToken: process.env.SERVICE_TOKEN ?? "", + + // Anthropic for Grow Agent + sub-agent LLM calls. + anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? "", + growAgentModel: + process.env.GROW_AGENT_MODEL ?? "claude-opus-4-7", + subAgentModel: + process.env.SUB_AGENT_MODEL ?? "claude-sonnet-4-6", + + // Rivet Kit engine endpoint (self-hosted in docker-compose). rivetEndpoint: process.env.RIVET_ENDPOINT ?? "http://localhost:6420", + + // Per-user container images. giteaImage: process.env.GITEA_IMAGE ?? "gitea/gitea:1.22", - opencodeImage: process.env.OPENCODE_IMAGE ?? "ghcr.io/sst/opencode:latest", + opencodeImage: + process.env.OPENCODE_IMAGE ?? "ghcr.io/sst/opencode:latest", + + // Host that user containers expose ports on (the host running Docker). userContainerHost: process.env.USER_CONTAINER_HOST ?? "127.0.0.1", userDataRoot: process.env.USER_DATA_ROOT ?? "./.data/users", userPortRangeStart: Number(process.env.USER_PORT_RANGE_START ?? 20000), userPortRangeEnd: Number(process.env.USER_PORT_RANGE_END ?? 29999), + + // CORS for the Next.js frontend. + frontendOrigin: + process.env.FRONTEND_ORIGIN ?? "http://localhost:3000", + + // Used by Anthropic SDK extended thinking / streaming budgets. + maxAgentTokens: Number(process.env.MAX_AGENT_TOKENS ?? 4096), + + required, // exported so other modules can fail fast on boot } as const; diff --git a/src/db/client.ts b/src/db/client.ts new file mode 100644 index 0000000..15a519e --- /dev/null +++ b/src/db/client.ts @@ -0,0 +1,14 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { config } from "../config.js"; +import * as schema from "./schema.js"; + +const queryClient = postgres(config.databaseUrl, { + max: 10, + idle_timeout: 20, + prepare: false, +}); + +export const db = drizzle(queryClient, { schema }); +export { schema }; +export type Db = typeof db; diff --git a/src/db/migrate.ts b/src/db/migrate.ts new file mode 100644 index 0000000..772892a --- /dev/null +++ b/src/db/migrate.ts @@ -0,0 +1,20 @@ +import "dotenv/config"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import postgres from "postgres"; +import { config } from "../config.js"; +import { log } from "../log.js"; + +async function main() { + const sql = postgres(config.databaseUrl, { max: 1 }); + const db = drizzle(sql); + log.info({ url: config.databaseUrl.replace(/:[^:@]+@/, ":***@") }, "migrating"); + await migrate(db, { migrationsFolder: "./drizzle" }); + await sql.end(); + log.info("migrations applied"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..1d13b7b --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,174 @@ +import { sql } from "drizzle-orm"; +import { + pgTable, + text, + timestamp, + integer, + jsonb, + uniqueIndex, + index, + primaryKey, +} from "drizzle-orm/pg-core"; + +// Users are mirrored from Clerk on first sign-in. +// id = Clerk user id (e.g., "user_2abc..."), email is the canonical Clerk email. +export const users = pgTable( + "users", + { + id: text("id").primaryKey(), + email: text("email").notNull(), + displayName: text("display_name"), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (t) => ({ + emailIdx: uniqueIndex("users_email_idx").on(t.email), + }), +); + +// One per user. Tracks the user's Grow Agent's container stack + Gitea creds. +// PRD §3.2 + §5.2. +export const userStacks = pgTable( + "user_stacks", + { + userId: text("user_id") + .primaryKey() + .references(() => users.id, { onDelete: "cascade" }), + status: text("status", { + enum: ["provisioning", "running", "stopped", "error"], + }) + .notNull() + .default("provisioning"), + + giteaContainerId: text("gitea_container_id"), + giteaContainerName: text("gitea_container_name"), + giteaHost: text("gitea_host"), + giteaHttpPort: integer("gitea_http_port"), + giteaSshPort: integer("gitea_ssh_port"), + giteaAdminUser: text("gitea_admin_user"), + giteaAdminToken: text("gitea_admin_token"), + giteaMemoryRepo: text("gitea_memory_repo"), + + opencodeContainerId: text("opencode_container_id"), + opencodeContainerName: text("opencode_container_name"), + opencodeHost: text("opencode_host"), + opencodePort: integer("opencode_port"), + opencodePassword: text("opencode_password"), + + workspacePath: text("workspace_path"), + lastError: text("last_error"), + + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (t) => ({ + statusIdx: index("user_stacks_status_idx").on(t.status), + }), +); + +// PRD §5.2 actor registry. One Grow Agent row per user; sub-agents are +// child rows keyed by (userId, actorId). +export const actors = pgTable( + "actors", + { + actorId: text("actor_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + kind: text("kind", { enum: ["grow", "sub"] }).notNull(), + subType: text("sub_type"), // for sub-agents: "coding", "repo", "quest", ... + status: text("status", { + enum: ["idle", "running", "done", "error"], + }) + .notNull() + .default("idle"), + channelId: text("channel_id"), + parentActorId: text("parent_actor_id"), + lastActivityAt: timestamp("last_activity_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.userId, t.actorId] }), + kindIdx: index("actors_user_kind_idx").on(t.userId, t.kind), + }), +); + +// Per-user repo registry (in addition to the primary memory repo). +export const repos = pgTable( + "repos", + { + id: text("id").primaryKey(), // `${userId}:${name}` + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + role: text("role", { enum: ["memory", "project"] }).notNull(), + giteaOwner: text("gitea_owner").notNull(), + giteaName: text("gitea_name").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (t) => ({ + userRoleIdx: index("repos_user_role_idx").on(t.userId, t.role), + }), +); + +// OpenCode sessions opened by sub-agents. +export const opencodeSessions = pgTable( + "opencode_sessions", + { + id: text("id").primaryKey(), // OpenCode session id from POST /session + userId: text("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + actorId: text("actor_id"), + title: text("title"), + parentId: text("parent_id"), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (t) => ({ + userIdx: index("opencode_sessions_user_idx").on(t.userId), + }), +); + +// Audit/event log — small append-only stream used for debugging + the +// frontend's "task progress timeline" until we move it to Rivet streams only. +export const events = pgTable( + "events", + { + id: text("id") + .primaryKey() + .default(sql`gen_random_uuid()::text`), + userId: text("user_id").notNull(), + actorId: text("actor_id"), + type: text("type").notNull(), + payload: jsonb("payload").$type>(), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (t) => ({ + userIdx: index("events_user_idx").on(t.userId, t.createdAt), + }), +); + +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; +export type UserStack = typeof userStacks.$inferSelect; +export type NewUserStack = typeof userStacks.$inferInsert; +export type ActorRow = typeof actors.$inferSelect; +export type RepoRow = typeof repos.$inferSelect; +export type OpencodeSessionRow = typeof opencodeSessions.$inferSelect; diff --git a/src/docker/manager.ts b/src/docker/manager.ts index b1db755..6e1be5d 100644 --- a/src/docker/manager.ts +++ b/src/docker/manager.ts @@ -1,31 +1,39 @@ import Docker from "dockerode"; import { mkdir } from "node:fs/promises"; import path from "node:path"; +import { randomBytes } from "node:crypto"; +import { and, eq, isNotNull } from "drizzle-orm"; import { config } from "../config.js"; import { log } from "../log.js"; +import { db } from "../db/client.js"; +import { userStacks, type UserStack } from "../db/schema.js"; +import { GiteaClient, waitForGitea } from "../lib/gitea.js"; +import { waitForOpencode } from "../lib/opencode.js"; -export type UserStack = { - userId: string; - gitea: ContainerInfo; - opencode: ContainerInfo; -}; - -export type ContainerInfo = { - id: string; - name: string; - image: string; - host: string; - ports: Record; - status: "running" | "stopped" | "creating" | "error"; -}; +export type { UserStack }; const docker = new Docker(); -// In-memory state of allocated host ports + running user stacks. -// Replace with the backend DB in §5.2 actor registry when wired up. -const stacks = new Map(); +// Allocated host ports kept in-memory; rehydrated from the DB on boot so +// we don't double-allocate across restarts. const allocatedPorts = new Set(); +export async function hydratePortAllocator(): Promise { + const rows = await db + .select({ + giteaHttp: userStacks.giteaHttpPort, + giteaSsh: userStacks.giteaSshPort, + opencode: userStacks.opencodePort, + }) + .from(userStacks); + for (const r of rows) { + for (const p of [r.giteaHttp, r.giteaSsh, r.opencode]) { + if (p) allocatedPorts.add(p); + } + } + log.info({ count: allocatedPorts.size }, "hydrated port allocator"); +} + function pickPort(): number { for (let p = config.userPortRangeStart; p <= config.userPortRangeEnd; p++) { if (!allocatedPorts.has(p)) { @@ -36,8 +44,8 @@ function pickPort(): number { throw new Error("No free ports in USER_PORT_RANGE"); } -function releasePort(port: number) { - allocatedPorts.delete(port); +function releasePort(port: number | null | undefined) { + if (port != null) allocatedPorts.delete(port); } async function ensureImage(image: string) { @@ -62,127 +70,386 @@ function userDataDir(userId: string) { return path.resolve(config.userDataRoot, userId); } -async function startGitea(userId: string): Promise { +function safeContainerName(prefix: string, userId: string) { + // Container names must match [a-zA-Z0-9_.-] + return `${prefix}-${userId.replace(/[^a-zA-Z0-9_.-]/g, "_")}`; +} + +async function findExistingContainer(name: string) { + const list = await docker.listContainers({ + all: true, + filters: { name: [`^/${name}$`] }, + }); + return list[0]; +} + +async function startGiteaContainer(opts: { + userId: string; + httpPort: number; + sshPort: number; +}): Promise<{ id: string; name: string }> { await ensureImage(config.giteaImage); - const httpPort = pickPort(); - const sshPort = pickPort(); - const name = `growqr-gitea-${userId}`; - const dataDir = path.join(userDataDir(userId), "gitea"); + const name = safeContainerName("growqr-gitea", opts.userId); + const dataDir = path.join(userDataDir(opts.userId), "gitea"); await ensureDir(dataDir); + const existing = await findExistingContainer(name); + if (existing) { + if (existing.State !== "running") { + await docker.getContainer(existing.Id).start().catch(() => undefined); + } + return { id: existing.Id, name }; + } + const container = await docker.createContainer({ name, Image: config.giteaImage, Env: [ "USER_UID=1000", "USER_GID=1000", - "GITEA__server__ROOT_URL=" + - `http://${config.userContainerHost}:${httpPort}/`, - `GITEA__server__SSH_PORT=${sshPort}`, + `GITEA__server__ROOT_URL=http://${config.userContainerHost}:${opts.httpPort}/`, + `GITEA__server__SSH_PORT=${opts.sshPort}`, "GITEA__security__INSTALL_LOCK=true", + "GITEA__service__DISABLE_REGISTRATION=true", ], HostConfig: { Binds: [`${dataDir}:/data`], PortBindings: { - "3000/tcp": [{ HostPort: String(httpPort) }], - "22/tcp": [{ HostPort: String(sshPort) }], + "3000/tcp": [{ HostPort: String(opts.httpPort) }], + "22/tcp": [{ HostPort: String(opts.sshPort) }], }, RestartPolicy: { Name: "unless-stopped" }, - Memory: 1 * 1024 * 1024 * 1024, // 1 GB cap - NanoCpus: 1_000_000_000, // 1 CPU + Memory: 1 * 1024 * 1024 * 1024, + NanoCpus: 1_000_000_000, }, ExposedPorts: { "3000/tcp": {}, "22/tcp": {} }, Labels: { - "growqr.userId": userId, + "growqr.userId": opts.userId, "growqr.role": "gitea", }, }); await container.start(); - log.info({ userId, name }, "started Gitea container"); - return { - id: container.id, - name, - image: config.giteaImage, - host: config.userContainerHost, - ports: { http: httpPort, ssh: sshPort }, - status: "running", - }; + log.info({ userId: opts.userId, name }, "started Gitea container"); + return { id: container.id, name }; } -async function startOpenCode(userId: string): Promise { +// Runs `gitea admin user create --admin ...` inside the container. +// Idempotent: returns existing creds if the user already exists. +async function ensureGiteaAdmin(opts: { + containerId: string; + username: string; + password: string; + email: string; +}): Promise { + const container = docker.getContainer(opts.containerId); + const exec = await container.exec({ + Cmd: [ + "su", + "git", + "-c", + `gitea admin user create --admin --username ${opts.username} --password '${opts.password.replace(/'/g, "'\\''")}' --email ${opts.email} --must-change-password=false`, + ], + AttachStdout: true, + AttachStderr: true, + WorkingDir: "/var/lib/gitea", + }); + const stream = await exec.start({ Detach: false, Tty: false }); + await new Promise((resolve) => { + stream.on("end", () => resolve()); + stream.on("close", () => resolve()); + stream.resume(); + }); + const info = await exec.inspect(); + if (info.ExitCode && info.ExitCode !== 0) { + // Most common non-zero: "user already exists" — that's fine. + log.debug( + { exitCode: info.ExitCode }, + "gitea admin user create returned non-zero (likely already exists)", + ); + } +} + +async function startOpencodeContainer(opts: { + userId: string; + httpPort: number; + password: string; +}): Promise<{ id: string; name: string }> { await ensureImage(config.opencodeImage); - const httpPort = pickPort(); - const name = `growqr-opencode-${userId}`; - const workspaceDir = path.join(userDataDir(userId), "workspace"); + const name = safeContainerName("growqr-opencode", opts.userId); + const workspaceDir = path.join(userDataDir(opts.userId), "workspace"); await ensureDir(workspaceDir); + const existing = await findExistingContainer(name); + if (existing) { + if (existing.State !== "running") { + await docker.getContainer(existing.Id).start().catch(() => undefined); + } + return { id: existing.Id, name }; + } + const container = await docker.createContainer({ name, Image: config.opencodeImage, - // OpenCode is expected to expose an HTTP/SSE management surface. - // Override CMD when the upstream image's default doesn't suit us. + // OpenCode server CLI: `opencode serve --port 4096 --hostname 0.0.0.0`. + // We override the default CMD to make sure it binds to all interfaces + // and uses the per-user password. + Cmd: ["serve", "--port", "4096", "--hostname", "0.0.0.0"], Env: [ + `OPENCODE_SERVER_PASSWORD=${opts.password}`, `OPENCODE_WORKSPACE=/workspace`, - `OPENCODE_PORT=4096`, ], + WorkingDir: "/workspace", HostConfig: { Binds: [`${workspaceDir}:/workspace`], PortBindings: { - "4096/tcp": [{ HostPort: String(httpPort) }], + "4096/tcp": [{ HostPort: String(opts.httpPort) }], }, RestartPolicy: { Name: "unless-stopped" }, - Memory: 2 * 1024 * 1024 * 1024, // 2 GB cap - NanoCpus: 2_000_000_000, // 2 CPUs + Memory: 2 * 1024 * 1024 * 1024, + NanoCpus: 2_000_000_000, }, ExposedPorts: { "4096/tcp": {} }, Labels: { - "growqr.userId": userId, + "growqr.userId": opts.userId, "growqr.role": "opencode", }, }); await container.start(); - log.info({ userId, name }, "started OpenCode container"); - return { - id: container.id, - name, - image: config.opencodeImage, - host: config.userContainerHost, - ports: { http: httpPort }, - status: "running", - }; + log.info({ userId: opts.userId, name }, "started OpenCode container"); + return { id: container.id, name }; } +// Provisions the per-user stack. Idempotent: returns the existing stack if +// the user already has one in the DB and the containers are running. +// +// Steps: +// 1. Pick ports + allocate. +// 2. Start Gitea + OpenCode containers (or reuse). +// 3. Wait for Gitea HTTP to come up. +// 4. Create the per-user Gitea admin via `gitea admin user create`. +// 5. Mint a long-lived access token for the admin. +// 6. Create the user's memory repo with auto_init. +// 7. Wait for OpenCode to come up. +// 8. Persist everything to user_stacks. export async function provisionUserStack(userId: string): Promise { - if (stacks.has(userId)) return stacks.get(userId)!; + const existing = await db.query.userStacks.findFirst({ + where: eq(userStacks.userId, userId), + }); + if (existing && existing.status === "running") { + return existing; + } + await ensureDir(userDataDir(userId)); - const gitea = await startGitea(userId); - const opencode = await startOpenCode(userId); - const stack: UserStack = { userId, gitea, opencode }; - stacks.set(userId, stack); - return stack; + + const giteaHttpPort = existing?.giteaHttpPort ?? pickPort(); + const giteaSshPort = existing?.giteaSshPort ?? pickPort(); + const opencodePort = existing?.opencodePort ?? pickPort(); + const opencodePassword = + existing?.opencodePassword ?? randomBytes(24).toString("hex"); + const adminUsername = + existing?.giteaAdminUser ?? `gq_${userId.replace(/[^a-zA-Z0-9]/g, "").slice(0, 24).toLowerCase() || "user"}`; + const adminPassword = randomBytes(24).toString("hex"); + const adminEmail = `${adminUsername}@growqr.local`; + + // Upsert "provisioning" row first so a crash mid-way leaves a recoverable record. + await db + .insert(userStacks) + .values({ + userId, + status: "provisioning", + giteaHttpPort, + giteaSshPort, + opencodePort, + opencodePassword, + giteaAdminUser: adminUsername, + giteaHost: config.userContainerHost, + opencodeHost: config.userContainerHost, + workspacePath: userDataDir(userId), + }) + .onConflictDoUpdate({ + target: userStacks.userId, + set: { + status: "provisioning", + updatedAt: new Date(), + lastError: null, + }, + }); + + try { + const gitea = await startGiteaContainer({ + userId, + httpPort: giteaHttpPort, + sshPort: giteaSshPort, + }); + const opencode = await startOpencodeContainer({ + userId, + httpPort: opencodePort, + password: opencodePassword, + }); + + const giteaBase = `http://${config.userContainerHost}:${giteaHttpPort}`; + await waitForGitea(giteaBase, 90_000); + + // Bootstrap admin user (idempotent — the CLI returns non-zero if exists). + await ensureGiteaAdmin({ + containerId: gitea.id, + username: adminUsername, + password: adminPassword, + email: adminEmail, + }); + + // Mint a token using basic auth. + const basicClient = new GiteaClient(giteaBase, { + kind: "basic", + username: adminUsername, + password: adminPassword, + }); + const token = await basicClient.ensureAccessToken({ + username: adminUsername, + name: "growqr-backend", + scopes: ["write:repository", "write:user", "write:issue"], + }); + + // Use the token from here on. + const tokenClient = new GiteaClient(giteaBase, { kind: "token", token }); + const memoryRepo = await tokenClient.ensureRepo({ + name: "growqr-memory", + description: "Grow Agent memory + state (PRD §3.4)", + autoInit: true, + private: true, + }); + + // OpenCode readiness. + const opencodeBase = `http://${config.userContainerHost}:${opencodePort}`; + await waitForOpencode(opencodeBase, opencodePassword, 90_000); + + const updated = await db + .update(userStacks) + .set({ + status: "running", + giteaContainerId: gitea.id, + giteaContainerName: gitea.name, + giteaAdminToken: token, + giteaMemoryRepo: `${memoryRepo.owner}/${memoryRepo.name}`, + opencodeContainerId: opencode.id, + opencodeContainerName: opencode.name, + lastError: null, + updatedAt: new Date(), + }) + .where(eq(userStacks.userId, userId)) + .returning(); + + const row = updated[0]; + if (!row) throw new Error("user stack row vanished mid-provision"); + log.info({ userId }, "user stack provisioned"); + return row; + } catch (err) { + log.error({ err, userId }, "provisionUserStack failed"); + await db + .update(userStacks) + .set({ + status: "error", + lastError: err instanceof Error ? err.message : String(err), + updatedAt: new Date(), + }) + .where(eq(userStacks.userId, userId)); + throw err; + } } -export function getUserStack(userId: string): UserStack | undefined { - return stacks.get(userId); +export async function getUserStack(userId: string): Promise { + const row = await db.query.userStacks.findFirst({ + where: eq(userStacks.userId, userId), + }); + return row ?? null; } export async function stopUserStack(userId: string): Promise { - const stack = stacks.get(userId); + const stack = await getUserStack(userId); if (!stack) return; - for (const c of [stack.gitea, stack.opencode]) { + for (const id of [stack.giteaContainerId, stack.opencodeContainerId]) { + if (!id) continue; try { - const container = docker.getContainer(c.id); - await container.stop({ t: 5 }).catch(() => undefined); - await container.remove({ force: true }).catch(() => undefined); - for (const port of Object.values(c.ports)) releasePort(port); + const c = docker.getContainer(id); + await c.stop({ t: 5 }).catch(() => undefined); + await c.remove({ force: true }).catch(() => undefined); } catch (err) { - log.warn({ err, id: c.id }, "failed to stop container"); + log.warn({ err, id }, "failed to stop container"); } } - stacks.delete(userId); + releasePort(stack.giteaHttpPort); + releasePort(stack.giteaSshPort); + releasePort(stack.opencodePort); + await db + .update(userStacks) + .set({ + status: "stopped", + giteaContainerId: null, + opencodeContainerId: null, + updatedAt: new Date(), + }) + .where(eq(userStacks.userId, userId)); log.info({ userId }, "stopped user stack"); } -export function listStacks(): UserStack[] { - return Array.from(stacks.values()); +export async function listStacks(): Promise { + return db.query.userStacks.findMany(); +} + +// Convenience: build a Gitea client for a user's stack. +export async function giteaClientFor(userId: string): Promise { + const stack = await getUserStack(userId); + if (!stack?.giteaAdminToken || !stack.giteaHost || !stack.giteaHttpPort) { + return null; + } + return new GiteaClient( + `http://${stack.giteaHost}:${stack.giteaHttpPort}`, + { kind: "token", token: stack.giteaAdminToken }, + ); +} + +// Convenience: build an OpenCode client for a user's stack. +export async function opencodeUrlFor( + userId: string, +): Promise<{ baseUrl: string; password: string | undefined } | null> { + const stack = await getUserStack(userId); + if (!stack?.opencodeHost || !stack.opencodePort) return null; + return { + baseUrl: `http://${stack.opencodeHost}:${stack.opencodePort}`, + password: stack.opencodePassword ?? undefined, + }; +} + +// Reconcile DB-tracked running containers with actual Docker state on boot. +// If a container is gone, flip the row to "stopped" so the next provision +// recreates it cleanly. +export async function reconcileOnBoot(): Promise { + const rows = await db + .select() + .from(userStacks) + .where( + and(eq(userStacks.status, "running"), isNotNull(userStacks.giteaContainerId)), + ); + for (const row of rows) { + let healthy = true; + for (const id of [row.giteaContainerId, row.opencodeContainerId]) { + if (!id) { + healthy = false; + break; + } + try { + const info = await docker.getContainer(id).inspect(); + if (!info.State.Running) healthy = false; + } catch { + healthy = false; + } + } + if (!healthy) { + await db + .update(userStacks) + .set({ status: "stopped", updatedAt: new Date() }) + .where(eq(userStacks.userId, row.userId)); + log.info({ userId: row.userId }, "stack marked stopped during reconcile"); + } + } } diff --git a/src/index.ts b/src/index.ts index 133aa51..009c4e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,28 +1,74 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { HTTPException } from "hono/http-exception"; import { config } from "./config.js"; import { log } from "./log.js"; import { registry } from "./actors/registry.js"; import { actorRoutes } from "./routes/actors.js"; import { opencodeRoutes } from "./routes/opencode.js"; import { gitRoutes } from "./routes/git.js"; +import { userRoutes } from "./routes/users.js"; +import { db } from "./db/client.js"; +import { hydratePortAllocator, reconcileOnBoot } from "./docker/manager.js"; async function main() { + // Boot-time DB sanity + reconcile. + await db.execute("select 1"); + await hydratePortAllocator(); + await reconcileOnBoot(); + const app = new Hono(); - app.get("/", (c) => c.json({ name: "growqr-backend", status: "ok" })); - app.get("/healthz", (c) => c.json({ ok: true })); + + app.use( + "*", + cors({ + origin: config.frontendOrigin.split(",").map((s) => s.trim()), + credentials: true, + allowHeaders: ["authorization", "content-type", "x-growqr-user"], + }), + ); + + app.onError((err, c) => { + if (err instanceof HTTPException) { + return err.getResponse(); + } + log.error({ err }, "unhandled error"); + return c.json({ error: "internal" }, 500); + }); + + app.get("/", (c) => + c.json({ name: "growqr-backend", status: "ok", env: config.nodeEnv }), + ); + + app.get("/healthz", async (c) => { + try { + await db.execute("select 1"); + return c.json({ ok: true }); + } catch (err) { + return c.json( + { ok: false, error: err instanceof Error ? err.message : String(err) }, + 503, + ); + } + }); // Rivet Kit actor traffic (frontend uses @rivetkit/react against this prefix). app.all("/api/rivet/*", (c) => registry.handler(c.req.raw)); - // PRD HTTP control plane. + // PRD HTTP control plane (auth-gated). + app.route("/users", userRoutes()); app.route("/actors", actorRoutes()); app.route("/opencode", opencodeRoutes()); app.route("/git", gitRoutes()); serve({ fetch: app.fetch, port: config.port }, (info) => { log.info( - { port: info.port, rivet: config.rivetEndpoint }, + { + port: info.port, + rivet: config.rivetEndpoint, + env: config.nodeEnv, + }, "growqr-backend listening", ); }); diff --git a/src/lib/anthropic.ts b/src/lib/anthropic.ts new file mode 100644 index 0000000..77903d5 --- /dev/null +++ b/src/lib/anthropic.ts @@ -0,0 +1,104 @@ +import Anthropic from "@anthropic-ai/sdk"; +import { config } from "../config.js"; + +export const anthropic = new Anthropic({ + apiKey: config.anthropicApiKey, +}); + +export const GROW_AGENT_SYSTEM = `You are a Grow Agent — a user's master AI orchestrator on the GrowQR platform. + +You own this user's long-running context, memory, and workspace. You coordinate specialized sub-agents (coding, repo, quest, product-flow, etc.), keep durable state in the user's Gitea memory repository, and execute workflows via the user's OpenCode sandbox. + +Operating principles: +- Be concise and direct. The user sees your messages in a Slack-like chat. +- Maintain durable memory: commit important decisions, goals, and progress to the user's memory repo using \`commit_memory\`. Read existing context with \`read_memory\` before making suggestions that depend on history. +- For anything that requires code, shell, file edits, or generated artifacts, spawn a sub-agent via \`spawn_sub_agent\`. The sub-agent runs through the user's OpenCode container. +- Track active goals and quests. Surface progress proactively when the user returns. +- Prefer one small commit per meaningful state change over batching. +- Never invent tool names. Only use the tools provided. +`; + +export type GrowAgentTool = + | "spawn_sub_agent" + | "commit_memory" + | "read_memory" + | "list_memory"; + +export const growAgentTools: Anthropic.Tool[] = [ + { + name: "spawn_sub_agent", + description: + "Spawn a specialized sub-agent to run a bounded task through the user's OpenCode container. Use for anything that requires running code, editing files, or producing artifacts.", + input_schema: { + type: "object", + properties: { + type: { + type: "string", + description: + "Sub-agent type: 'coding', 'repo', 'migration', 'quest', 'product', 'backend', 'frontend', or another short identifier.", + }, + prompt: { + type: "string", + description: + "The full task prompt for the sub-agent. Include all context it needs — sub-agents do not see this conversation.", + }, + channelId: { + type: "string", + description: + "Optional channel/thread id the sub-agent should report into. Generated if omitted.", + }, + }, + required: ["type", "prompt"], + }, + }, + { + name: "commit_memory", + description: + "Write or update a file in the user's Gitea memory repository. Use for goals, decisions, progress notes, plans, and durable summaries.", + input_schema: { + type: "object", + properties: { + path: { + type: "string", + description: + "Repo-relative path, e.g. 'goals/active.md' or 'decisions/2026-05-19-architecture.md'.", + }, + content: { + type: "string", + description: "Full UTF-8 file content to write.", + }, + message: { + type: "string", + description: "Commit message describing the change.", + }, + }, + required: ["path", "content", "message"], + }, + }, + { + name: "read_memory", + description: "Read a single file from the user's memory repo. Returns null if missing.", + input_schema: { + type: "object", + properties: { + path: { type: "string" }, + }, + required: ["path"], + }, + }, + { + name: "list_memory", + description: + "List files at a path prefix in the user's memory repo. Use to discover what context already exists.", + input_schema: { + type: "object", + properties: { + pathPrefix: { + type: "string", + description: "Repo-relative directory, e.g. 'goals' or '' for root.", + }, + }, + required: ["pathPrefix"], + }, + }, +]; diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts new file mode 100644 index 0000000..a36aa66 --- /dev/null +++ b/src/lib/gitea.ts @@ -0,0 +1,217 @@ +import { log } from "../log.js"; + +// Minimal Gitea API client. Authenticates with either an access token +// (preferred) or basic auth (used once at bootstrap to mint a token). +// +// Reference: docs.gitea.com/api/1.22 +export class GiteaClient { + constructor( + private readonly baseUrl: string, + private readonly auth: + | { kind: "token"; token: string } + | { kind: "basic"; username: string; password: string }, + ) {} + + private headers(extra: Record = {}): Record { + const h: Record = { + "content-type": "application/json", + accept: "application/json", + ...extra, + }; + if (this.auth.kind === "token") { + h["authorization"] = `token ${this.auth.token}`; + } else { + const enc = Buffer.from( + `${this.auth.username}:${this.auth.password}`, + ).toString("base64"); + h["authorization"] = `Basic ${enc}`; + } + return h; + } + + private async req( + method: string, + path: string, + body?: unknown, + ): Promise { + const url = `${this.baseUrl}${path}`; + const res = await fetch(url, { + method, + headers: this.headers(), + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error( + `gitea ${method} ${path} → ${res.status} ${res.statusText}: ${text}`, + ); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; + } + + async ping(): Promise { + try { + await fetch(`${this.baseUrl}/api/v1/version`).then((r) => r.ok); + return true; + } catch { + return false; + } + } + + // Used with basic auth. Returns the existing token if one with the same + // name exists, otherwise creates a new one. + async ensureAccessToken(opts: { + username: string; + name: string; + scopes?: string[]; + }): Promise { + const existing = await this.req>( + "GET", + `/api/v1/users/${encodeURIComponent(opts.username)}/tokens`, + ); + const match = existing.find((t) => t.name === opts.name); + if (match) { + // Tokens cannot be re-read; revoke + recreate so we hold the secret. + await this.req( + "DELETE", + `/api/v1/users/${encodeURIComponent(opts.username)}/tokens/${match.id}`, + ); + } + const created = await this.req<{ sha1: string; token_last_eight: string }>( + "POST", + `/api/v1/users/${encodeURIComponent(opts.username)}/tokens`, + { + name: opts.name, + scopes: opts.scopes ?? ["write:repository", "write:user"], + }, + ); + return created.sha1; + } + + async ensureRepo(opts: { + name: string; + description?: string; + autoInit?: boolean; + private?: boolean; + }): Promise<{ owner: string; name: string; cloneUrl: string }> { + try { + const repo = await this.req<{ + owner: { login: string }; + name: string; + clone_url: string; + }>("POST", "/api/v1/user/repos", { + name: opts.name, + description: opts.description ?? "", + auto_init: opts.autoInit ?? true, + private: opts.private ?? true, + default_branch: "main", + }); + return { + owner: repo.owner.login, + name: repo.name, + cloneUrl: repo.clone_url, + }; + } catch (err) { + // 409 → repo already exists; look it up. + const user = await this.req<{ login: string }>("GET", "/api/v1/user"); + const repo = await this.req<{ + owner: { login: string }; + name: string; + clone_url: string; + }>( + "GET", + `/api/v1/repos/${encodeURIComponent(user.login)}/${encodeURIComponent(opts.name)}`, + ); + log.debug({ err }, "ensureRepo fell through to GET"); + return { + owner: repo.owner.login, + name: repo.name, + cloneUrl: repo.clone_url, + }; + } + } + + // Creates or updates a file in a repo. Used for memory commits. + async putFile(opts: { + owner: string; + repo: string; + path: string; + contentUtf8: string; + message: string; + branch?: string; + }): Promise<{ sha: string; commitSha: string }> { + const branch = opts.branch ?? "main"; + const contentB64 = Buffer.from(opts.contentUtf8, "utf8").toString("base64"); + + // Look up existing sha so we can PUT (update). Missing → POST (create). + let existingSha: string | undefined; + try { + const existing = await this.req<{ sha: string }>( + "GET", + `/api/v1/repos/${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.repo)}/contents/${encodeURI(opts.path)}?ref=${encodeURIComponent(branch)}`, + ); + existingSha = existing.sha; + } catch { + // not found, that's fine + } + + const body: Record = { + content: contentB64, + message: opts.message, + branch, + }; + if (existingSha) body.sha = existingSha; + + const result = await this.req<{ + content: { sha: string }; + commit: { sha: string }; + }>( + existingSha ? "PUT" : "POST", + `/api/v1/repos/${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.repo)}/contents/${encodeURI(opts.path)}`, + body, + ); + return { sha: result.content.sha, commitSha: result.commit.sha }; + } + + async readFile(opts: { + owner: string; + repo: string; + path: string; + branch?: string; + }): Promise { + const branch = opts.branch ?? "main"; + try { + const res = await this.req<{ content: string; encoding: string }>( + "GET", + `/api/v1/repos/${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.repo)}/contents/${encodeURI(opts.path)}?ref=${encodeURIComponent(branch)}`, + ); + if (res.encoding === "base64") { + return Buffer.from(res.content, "base64").toString("utf8"); + } + return res.content; + } catch { + return null; + } + } +} + +// Wait until Gitea answers /api/v1/version. Used right after we start the +// container. +export async function waitForGitea( + baseUrl: string, + timeoutMs = 60_000, +): Promise { + const start = Date.now(); + let lastErr: unknown; + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(`${baseUrl}/api/v1/version`); + if (res.ok) return; + } catch (err) { + lastErr = err; + } + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error(`Gitea did not become ready at ${baseUrl}: ${String(lastErr)}`); +} diff --git a/src/lib/opencode.ts b/src/lib/opencode.ts new file mode 100644 index 0000000..d3ad75d --- /dev/null +++ b/src/lib/opencode.ts @@ -0,0 +1,159 @@ +// Minimal client for the OpenCode HTTP server (default port 4096). +// Endpoints: POST /session, POST /session/:id/message, GET /event. +// Auth: HTTP Basic with username "opencode" and OPENCODE_SERVER_PASSWORD. +// +// We use the synchronous POST /session/:id/message endpoint and surface +// the full response. Streaming (SSE) is exposed via streamEvents() for +// callers that want to broadcast progress. + +export type OpencodeMessage = { + id: string; + role: "user" | "assistant"; + parts: Array<{ type: string; text?: string }>; +}; + +export class OpencodeClient { + constructor( + private readonly baseUrl: string, + private readonly password?: string, + ) {} + + private headers(): Record { + const h: Record = { + "content-type": "application/json", + accept: "application/json", + }; + if (this.password) { + const enc = Buffer.from(`opencode:${this.password}`).toString("base64"); + h["authorization"] = `Basic ${enc}`; + } + return h; + } + + private async req( + method: string, + path: string, + body?: unknown, + ): Promise { + const res = await fetch(`${this.baseUrl}${path}`, { + method, + headers: this.headers(), + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error( + `opencode ${method} ${path} → ${res.status} ${res.statusText}: ${text}`, + ); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; + } + + async health(): Promise<{ version?: string } | null> { + try { + const res = await fetch(`${this.baseUrl}/global/health`, { + headers: this.headers(), + }); + if (!res.ok) return null; + return (await res.json()) as { version?: string }; + } catch { + return null; + } + } + + async createSession(opts: { + title?: string; + parentID?: string; + }): Promise<{ id: string; title?: string }> { + return this.req<{ id: string; title?: string }>("POST", "/session", opts); + } + + async sendMessage(opts: { + sessionId: string; + text: string; + }): Promise<{ message: OpencodeMessage } | OpencodeMessage> { + return this.req<{ message: OpencodeMessage } | OpencodeMessage>( + "POST", + `/session/${encodeURIComponent(opts.sessionId)}/message`, + { + parts: [{ type: "text", text: opts.text }], + }, + ); + } + + async sendMessageAsync(opts: { + sessionId: string; + text: string; + }): Promise { + await this.req( + "POST", + `/session/${encodeURIComponent(opts.sessionId)}/prompt_async`, + { + parts: [{ type: "text", text: opts.text }], + }, + ); + } + + // Subscribe to the SSE event stream. Caller passes onEvent which is fired + // for every parsed event. Returns an AbortController the caller can use to + // close the stream. + streamEvents( + onEvent: (ev: { event?: string; data: unknown }) => void, + ): AbortController { + const controller = new AbortController(); + (async () => { + try { + const res = await fetch(`${this.baseUrl}/event`, { + headers: { ...this.headers(), accept: "text/event-stream" }, + signal: controller.signal, + }); + if (!res.ok || !res.body) return; + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buf.indexOf("\n\n")) >= 0) { + const raw = buf.slice(0, idx); + buf = buf.slice(idx + 2); + const lines = raw.split("\n"); + let event: string | undefined; + let dataStr = ""; + for (const line of lines) { + if (line.startsWith("event:")) event = line.slice(6).trim(); + if (line.startsWith("data:")) dataStr += line.slice(5).trim(); + } + if (!dataStr) continue; + try { + onEvent({ event, data: JSON.parse(dataStr) }); + } catch { + onEvent({ event, data: dataStr }); + } + } + } + } catch { + // aborted or network closed + } + })(); + return controller; + } +} + +export async function waitForOpencode( + baseUrl: string, + password: string | undefined, + timeoutMs = 60_000, +): Promise { + const client = new OpencodeClient(baseUrl, password); + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const h = await client.health(); + if (h) return; + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error(`OpenCode did not become ready at ${baseUrl}`); +} diff --git a/src/routes/actors.ts b/src/routes/actors.ts index 4517197..7f5d6fb 100644 --- a/src/routes/actors.ts +++ b/src/routes/actors.ts @@ -1,38 +1,48 @@ import { Hono } from "hono"; -import { z } from "zod"; import { provisionUserStack, getUserStack, stopUserStack, listStacks, } from "../docker/manager.js"; +import { requireUser, type AuthContext } from "../auth/clerk.js"; +import { db } from "../db/client.js"; +import { actors as actorsTable } from "../db/schema.js"; +import { eq } from "drizzle-orm"; // PRD §5.2 — Actor registry HTTP surface. -// v1 talks to the Docker manager directly. The Rivet Kit Grow Agent actor -// is mounted at /api/rivet/* and is intended for the frontend's -// WebSocket/SSE connection (Rivet Kit React SDK). +// All routes are user-scoped via Clerk auth; userId is derived from the +// session token, never trusted from the body. export function actorRoutes() { - const app = new Hono(); - - const ProvisionSchema = z.object({ userId: z.string().min(1) }); + const app = new Hono(); + app.use("*", requireUser); app.post("/provision", async (c) => { - const body = ProvisionSchema.parse(await c.req.json()); - const stack = await provisionUserStack(body.userId); - return c.json({ userId: body.userId, stack }); - }); - - app.get("/", (c) => c.json({ stacks: listStacks() })); - - app.get("/:userId", (c) => { - const userId = c.req.param("userId"); - const stack = getUserStack(userId); - if (!stack) return c.json({ error: "not provisioned" }, 404); + const userId = c.get("userId"); + const stack = await provisionUserStack(userId); return c.json({ userId, stack }); }); - app.post("/:userId/stop", async (c) => { - await stopUserStack(c.req.param("userId")); + app.get("/me", async (c) => { + const userId = c.get("userId"); + const stack = await getUserStack(userId); + const rows = await db + .select() + .from(actorsTable) + .where(eq(actorsTable.userId, userId)); + return c.json({ userId, stack, actors: rows }); + }); + + app.get("/", async (c) => { + // Admin/debug — returns the caller's stacks only. Tighten further if needed. + const userId = c.get("userId"); + const all = await listStacks(); + return c.json({ stacks: all.filter((s) => s.userId === userId) }); + }); + + app.post("/stop", async (c) => { + const userId = c.get("userId"); + await stopUserStack(userId); return c.json({ ok: true }); }); diff --git a/src/routes/git.ts b/src/routes/git.ts index 402a493..5c42f93 100644 --- a/src/routes/git.ts +++ b/src/routes/git.ts @@ -1,46 +1,100 @@ import { Hono } from "hono"; import { z } from "zod"; -import { getUserStack, provisionUserStack } from "../docker/manager.js"; +import { getUserStack, giteaClientFor } from "../docker/manager.js"; +import { requireUser, type AuthContext } from "../auth/clerk.js"; +import { db } from "../db/client.js"; +import { repos } from "../db/schema.js"; // PRD §5.4 — Gitea Docker management API. -// Targets the per-user Gitea container spawned by the Docker manager. export function gitRoutes() { - const app = new Hono(); + const app = new Hono(); + app.use("*", requireUser); - app.post("/users/:userId/repo", async (c) => { - const userId = c.req.param("userId"); - const body = z - .object({ name: z.string().min(1) }) - .parse(await c.req.json()); - const stack = await provisionUserStack(userId); - // TODO: call Gitea API at http://${gitea.host}:${gitea.ports.http}/api/v1/user/repos + app.get("/me", async (c) => { + const userId = c.get("userId"); + const stack = await getUserStack(userId); + if (!stack) return c.json({ error: "not provisioned" }, 404); return c.json({ - userId, - repo: body.name, - gitea: `${stack.gitea.host}:${stack.gitea.ports.http}`, + gitea: { + host: stack.giteaHost, + port: stack.giteaHttpPort, + sshPort: stack.giteaSshPort, + memoryRepo: stack.giteaMemoryRepo, + }, }); }); - app.get("/users/:userId", (c) => { - const stack = getUserStack(c.req.param("userId")); - if (!stack) return c.json({ error: "not provisioned" }, 404); - return c.json({ gitea: stack.gitea }); + app.post("/repos", async (c) => { + const userId = c.get("userId"); + const body = z + .object({ name: z.string().min(1).max(64) }) + .parse(await c.req.json()); + const client = await giteaClientFor(userId); + const stack = await getUserStack(userId); + if (!client || !stack) { + return c.json({ error: "not provisioned" }, 404); + } + const repo = await client.ensureRepo({ name: body.name, autoInit: true }); + await db + .insert(repos) + .values({ + id: `${userId}:${body.name}`, + userId, + name: body.name, + role: "project", + giteaOwner: repo.owner, + giteaName: repo.name, + }) + .onConflictDoNothing(); + return c.json({ repo }); }); - app.post("/users/:userId/repos/:repoName/commit", async (c) => { - const userId = c.req.param("userId"); - const repoName = c.req.param("repoName"); + app.post("/repos/:name/commit", async (c) => { + const userId = c.get("userId"); + const repoName = c.req.param("name"); const body = z .object({ + path: z.string().min(1), + content: z.string(), + message: z.string().default("update"), branch: z.string().default("main"), - message: z.string(), - files: z.record(z.string()), }) .parse(await c.req.json()); - const stack = getUserStack(userId); - if (!stack) return c.json({ error: "not provisioned" }, 404); - // TODO: real commit via Gitea content API - return c.json({ ok: true, repoName, branch: body.branch, count: Object.keys(body.files).length }); + const client = await giteaClientFor(userId); + if (!client) return c.json({ error: "not provisioned" }, 404); + + // Get owner from DB or fall back to memory repo. + const stack = await getUserStack(userId); + const owner = stack?.giteaAdminUser ?? ""; + if (!owner) return c.json({ error: "no gitea owner" }, 500); + + const result = await client.putFile({ + owner, + repo: repoName, + path: body.path, + contentUtf8: body.content, + message: body.message, + branch: body.branch, + }); + return c.json({ ok: true, ...result }); + }); + + app.get("/repos/:name/contents/*", async (c) => { + const userId = c.get("userId"); + const repoName = c.req.param("name"); + const path = c.req.path.split(`/repos/${repoName}/contents/`)[1] ?? ""; + const client = await giteaClientFor(userId); + const stack = await getUserStack(userId); + if (!client || !stack?.giteaAdminUser) { + return c.json({ error: "not provisioned" }, 404); + } + const content = await client.readFile({ + owner: stack.giteaAdminUser, + repo: repoName, + path, + }); + if (content == null) return c.json({ error: "not found" }, 404); + return c.json({ path, content }); }); return app; diff --git a/src/routes/opencode.ts b/src/routes/opencode.ts index 991ac0f..ec21fd1 100644 --- a/src/routes/opencode.ts +++ b/src/routes/opencode.ts @@ -1,51 +1,76 @@ import { Hono } from "hono"; import { z } from "zod"; -import { getUserStack, provisionUserStack } from "../docker/manager.js"; +import { provisionUserStack, opencodeUrlFor } from "../docker/manager.js"; +import { OpencodeClient } from "../lib/opencode.js"; +import { db } from "../db/client.js"; +import { opencodeSessions } from "../db/schema.js"; +import { eq } from "drizzle-orm"; +import { requireUser, type AuthContext } from "../auth/clerk.js"; // PRD §5.3 — OpenCode Docker management API. -// These endpoints control the per-user OpenCode container that sub-agent -// workflows run through. +// Proxies into the user's OpenCode container's HTTP surface. export function opencodeRoutes() { - const app = new Hono(); + const app = new Hono(); + app.use("*", requireUser); app.post("/provision", async (c) => { - const body = z - .object({ userId: z.string().min(1) }) - .parse(await c.req.json()); - const stack = await provisionUserStack(body.userId); - return c.json({ workspace: stack.opencode }); - }); - - app.get("/workspaces/:userId", (c) => { - const stack = getUserStack(c.req.param("userId")); - if (!stack) return c.json({ error: "not provisioned" }, 404); - return c.json({ workspace: stack.opencode }); - }); - - // Session + message endpoints are forwarded into the user's OpenCode - // container's HTTP API. Real client wiring goes here once the upstream - // OpenCode HTTP surface is pinned. - app.post("/workspaces/:userId/sessions", async (c) => { - const stack = getUserStack(c.req.param("userId")); - if (!stack) return c.json({ error: "not provisioned" }, 404); + const userId = c.get("userId"); + const stack = await provisionUserStack(userId); return c.json({ - sessionId: `sess-${Date.now()}`, - target: `${stack.opencode.host}:${stack.opencode.ports.http}`, + workspace: { + host: stack.opencodeHost, + port: stack.opencodePort, + status: stack.status, + }, }); }); + app.get("/workspace", async (c) => { + const userId = c.get("userId"); + const target = await opencodeUrlFor(userId); + if (!target) return c.json({ error: "not provisioned" }, 404); + const client = new OpencodeClient(target.baseUrl, target.password); + const health = await client.health(); + return c.json({ workspace: target, health }); + }); + + app.post("/sessions", async (c) => { + const userId = c.get("userId"); + const body = z + .object({ title: z.string().optional() }) + .parse(await c.req.json().catch(() => ({}))); + const target = await opencodeUrlFor(userId); + if (!target) return c.json({ error: "not provisioned" }, 404); + const client = new OpencodeClient(target.baseUrl, target.password); + const session = await client.createSession({ title: body.title }); + await db.insert(opencodeSessions).values({ + id: session.id, + userId, + title: session.title ?? null, + }); + return c.json({ session }); + }); + app.post("/sessions/:sessionId/messages", async (c) => { + const userId = c.get("userId"); + const sessionId = c.req.param("sessionId"); const body = z - .object({ userId: z.string(), prompt: z.string() }) + .object({ prompt: z.string().min(1) }) .parse(await c.req.json()); - const stack = getUserStack(body.userId); - if (!stack) return c.json({ error: "not provisioned" }, 404); - // TODO: proxy to http://stack.opencode.host:stack.opencode.ports.http - return c.json({ - ok: true, - sessionId: c.req.param("sessionId"), - forwardedTo: `${stack.opencode.host}:${stack.opencode.ports.http}`, + const row = await db.query.opencodeSessions.findFirst({ + where: eq(opencodeSessions.id, sessionId), }); + if (!row || row.userId !== userId) { + return c.json({ error: "not found" }, 404); + } + const target = await opencodeUrlFor(userId); + if (!target) return c.json({ error: "not provisioned" }, 404); + const client = new OpencodeClient(target.baseUrl, target.password); + const result = await client.sendMessage({ + sessionId, + text: body.prompt, + }); + return c.json({ sessionId, result }); }); return app; diff --git a/src/routes/users.ts b/src/routes/users.ts new file mode 100644 index 0000000..2bac193 --- /dev/null +++ b/src/routes/users.ts @@ -0,0 +1,50 @@ +import { Hono } from "hono"; +import { requireUser, type AuthContext } from "../auth/clerk.js"; +import { db } from "../db/client.js"; +import { users, userStacks } from "../db/schema.js"; +import { eq } from "drizzle-orm"; +import { provisionUserStack } from "../docker/manager.js"; +import { log } from "../log.js"; + +export function userRoutes() { + const app = new Hono(); + app.use("*", requireUser); + + // Called by the frontend right after Clerk sign-in. + // - Ensures a `users` row exists (the auth middleware already lazy-mirrors). + // - Kicks off Grow Agent stack provisioning if not already running. + // - Returns the current stack status so the UI can render a provisioning spinner. + app.post("/bootstrap", async (c) => { + const userId = c.get("userId"); + const userRow = await db.query.users.findFirst({ + where: eq(users.id, userId), + }); + const stack = await db.query.userStacks.findFirst({ + where: eq(userStacks.userId, userId), + }); + + if (!stack || stack.status !== "running") { + // Fire-and-forget; the frontend polls /users/me until status === running. + void provisionUserStack(userId).catch((err) => + log.error({ err, userId }, "background provision failed"), + ); + } + return c.json({ + user: userRow, + stack: stack ?? { status: "provisioning" }, + }); + }); + + app.get("/me", async (c) => { + const userId = c.get("userId"); + const userRow = await db.query.users.findFirst({ + where: eq(users.id, userId), + }); + const stack = await db.query.userStacks.findFirst({ + where: eq(userStacks.userId, userId), + }); + return c.json({ user: userRow, stack }); + }); + + return app; +}