refactor: purge Node.js, Rust is the one true backend

- Moved boosapi-rs/* to repo root
- Deleted all Node.js source (src/server/, src/web/, scripts/)
- Deleted node_modules, package.json, tsconfig, vite.config
- Updated .gitignore and AGENTS.md for Rust-only stack
- Fixed static file path for frontend dist/web/
- Dockerfile stays the same (already Rust-only)
- Kept dist/web/ for Rust static serving
This commit is contained in:
boos
2026-05-18 09:11:29 +08:00
parent 725f44699a
commit 5d29f03d50
1165 changed files with 271 additions and 323571 deletions
-33
View File
@@ -1,33 +0,0 @@
# Required in production: generate a unique 32+ byte random secret in your local .env, keep it out of git, and do not reuse AUTH_TOKEN.
ACCOUNT_CREDENTIAL_SECRET=REPLACE_WITH_STRONG_RANDOM_SECRET
AUTH_TOKEN=change-me-admin-token
CHECKIN_CRON=0 8 * * *
BALANCE_REFRESH_CRON=0 * * * *
# Optional OAuth overrides. Fill the matching credentials when an override is enabled; Claude requires a client secret with no fallback.
CLAUDE_CLIENT_ID=
CLAUDE_CLIENT_SECRET=
# Codex OAuth override only needs CODEX_CLIENT_ID.
CODEX_CLIENT_ID=
# Gemini CLI override requires both GEMINI_CLI_CLIENT_ID and GEMINI_CLI_CLIENT_SECRET together.
GEMINI_CLI_CLIENT_ID=
GEMINI_CLI_CLIENT_SECRET=
PROXY_TOKEN=change-me-proxy-sk-token
SYSTEM_PROXY_URL=
WEBHOOK_URL=
BARK_URL=
SERVERCHAN_KEY=
TELEGRAM_ENABLED=false
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
MODEL_AVAILABILITY_PROBE_CONCURRENCY=1
MODEL_AVAILABILITY_PROBE_ENABLED=false
MODEL_AVAILABILITY_PROBE_INTERVAL_MS=1800000
MODEL_AVAILABILITY_PROBE_TIMEOUT_MS=15000
PORT=4000
DATA_DIR=./data
NOTIFY_COOLDOWN_SEC=300
ADMIN_IP_ALLOWLIST=
TZ=Asia/Shanghai
# ComfyUI — 本地工作流引擎地址(默认 http://127.0.0.1:8188)
COMFYUI_BASE=http://127.0.0.1:8188
+7 -22
View File
@@ -1,35 +1,20 @@
/target
**/*.rs.bk
Cargo.lock
.env
.env.*
data/
node_modules/ node_modules/
dist/ dist/
release/ release/
data/*
!data/.gitkeep
.env
*.log *.log
*.tar.gz
logs/ logs/
tmp/* tmp/
!tmp/.gitkeep !tmp/.gitkeep
.worktrees/
# Local desktop/debug artifacts
/.tmp-*
/main.js
/NUL
/.playwright-cli/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
docker/data/ docker/data/
docker/.env docker/.env
# Local planning docs
docs/plan/
docs/plans/
.ace-tool/ .ace-tool/
# Local ComfyUI development install
.comfyui/ .comfyui/
# Rust binary
boosapi boosapi
boosapi-rs/target/
-1
View File
@@ -1 +0,0 @@
rebrand-video-agent-20260515-020021
-71
View File
@@ -1,71 +0,0 @@
# ComfyUI Integration for Metapi
## Repository
/root/metapi
## Mode
implement
## Files to Create
### 1. src/server/routes/api/comfyui.ts
Simple pass-through proxy to ComfyUI API at http://127.0.0.1:8188.
Follow `tasks.ts` pattern (simple route, no auth needed for now):
- `POST /api/comfyui/prompt` — forward JSON body to ComfyUI POST /prompt, return response
- `GET /api/comfyui/history` — forward to ComfyUI GET /history
- `GET /api/comfyui/history/:id` — forward to ComfyUI GET /history/{id}
- `GET /api/comfyui/queue` — forward to ComfyUI GET /queue
- `GET /api/comfyui/view` — forward to ComfyUI GET /view (image output access, preserve query params)
- `GET /api/comfyui/ws` — NOT needed (WebSocket is complex, skip for now)
Use undici `fetch` for all requests (already a dep). Handle errors gracefully.
Add CORS headers for cross-origin iframe embedding.
### 2. src/web/pages/ComfyUI.tsx
Simple iframe-based embedding page:
```tsx
import { useTheme } from '@/lib/ThemeContext';
export default function ComfyUI() {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
<iframe
src="/api/comfyui/proxy/"
style={{ width: '100%', height: '100%', border: 'none' }}
title="ComfyUI"
/>
</div>
);
}
```
Actually, since iframe needs same-origin, instead proxy the full ComfyUI HTML.
Use the API routes above and create a simple index page that embeds via /api/comfyui/proxy/.
Better approach: Add a GET /api/comfyui/ route that returns HTML page with iframe pointing to /api/comfyui/proxy/ui/, and add a proxy handler for /api/comfyui/proxy/* that forwards everything to ComfyUI.
## Files to Modify
### 3. src/server/index.ts
Add `import { comfyuiRoutes } from './routes/api/comfyui.js';`
Add `await app.register(comfyuiRoutes);` after other route registrations (line ~235 area)
### 4. src/web/App.tsx
- Add `const ComfyUI = lazy(() => import('./pages/ComfyUI.js'));` (replace ModelTester import or add alongside)
- Change route: `<Route path="/playground" element={<ComfyUI />} />` (replace ModelTester)
- Keep ModelTester import but rename or keep — simplest: just swap the component
## Constraints
- Follow existing patterns (tasks.ts for routes, App.tsx for routing)
- Don't add new npm dependencies
- Don't commit changes
- Run `npm run typecheck` at the end
## Acceptance
- npm run typecheck passes
- Server starts without errors
-44
View File
@@ -1,44 +0,0 @@
# HCW Dispatch Brief
## Goal
Implement three features in metapi: User Management (SaaS), TTS Proxy, and ComfyUI integration.
## Repository
/root/metapi
## Mode
implement
## Features
### Feature 1: User Management (SaaS)
- Add `users` table to `src/server/db/schema.ts` (id, username, email, passwordHash, role: admin|user, status, timestamps)
- Add `userId` column to `downstreamApiKeys` (nullable for backward compat)
- `src/server/services/userService.ts` — password hashing (crypto.scrypt), JWT (crypto.createHmac), user CRUD
- `src/server/contracts/userRoutePayloads.ts` — Zod schemas
- `src/server/routes/api/users.ts` — register, login, me, admin list/manage
- `src/server/routes/api/userApiKeys.ts` — user-scoped API key CRUD
- `src/server/middleware/auth.ts` — add userAuthMiddleware (JWT) + requireAdmin helper
- `src/server/services/downstreamApiKeyService.ts` — check user status for key
### Feature 2: TTS Proxy
- `src/server/routes/proxy/audio.ts` — three OpenAI-compatible endpoints: POST /v1/audio/speech, POST /v1/audio/transcriptions, POST /v1/audio/translations
- Follow `images.ts` pattern: channel selection, multipart parsing, retry, log
- `src/server/routes/proxy/router.ts` — register audioProxyRoute
### Feature 3: ComfyUI Integration
- `src/server/routes/api/comfyui.ts` — POST /api/comfyui/prompt (proxy to ComfyUI /prompt), GET /api/comfyui/history/:id, GET /api/comfyui/queue, WS /api/comfyui/ws -> proxy to ComfyUI WS
- `src/web/pages/ComfyUI.tsx` — replace ModelTester playground page, iframe embed or reverse-proxy UI
- `src/web/App.tsx` — route /playground -> ComfyUI page
## Constraints
- Only modify files listed above
- Follow existing metapi conventions (patterns from images.ts, videos.ts, auth.ts, App.tsx)
- No new npm dependencies for user auth (use crypto module)
- Generate Drizzle migration after schema changes
- Run npm run typecheck before reporting done
## Acceptance Checks
- npm run typecheck exits 0
- npm run db:generate produces migration
- Server starts without errors
@@ -1,40 +0,0 @@
# Task 1: Rebrand Metapi → BoosAPI
## Scope
- Replace ALL "Metapi"/"metapi" → "BoosAPI"/"BoosAPI" in user-facing strings
- Files: package.json, src/web/** (App.tsx, i18n.tsx, i18n.supplement.ts, index.html, docsLink.ts, About.tsx, etc.)
- Also update src/desktop/** (main.ts, runtime.ts) and src/server/** if user-facing
- Do NOT change: package name in npm (package.json "name" field), internal DB schema column names, lockfiles
- About page: specifically replace "Metapi" → "BoosAPI"
## Acceptance
- grep -ri "metapi" src/web/ shows 0 matches
- About page shows "BoosAPI" not "Metapi"
- Server still starts and compiles
# Task 2: Conversational ComfyUI Agent
## Architecture
Backend: src/server/routes/api/comfyuiAgent.ts
- SSE streaming endpoint POST /api/comfyui-agent/chat
- Accepts { messages: Array<{role, content}>, sessionId?: string }
- System prompt: AI video creation assistant for ComfyUI
- Tools: generate character prompts (front/back/left/right), generate images, scene prompts, TTS
- Uses Metapi's own LLM endpoint for reasoning + image gen API + TTS API
Frontend: src/web/pages/ComfyUIAgent.tsx
- Chat UI with message bubbles
- Streaming response display (SSE)
- Inline image display for generated character images
- Input box for user messages
- ComfyUI workflow export button
Wiring: App.tsx
- Add lazy import for ComfyUIAgent
- Route at /comfyui-agent
- Sidebar item "视频助手(对话)"
## Acceptance
- npm run typecheck passes
- Server starts without errors
- Chat endpoint accepts messages and streams responses
-1
View File
@@ -1 +0,0 @@
25.0.0
+49 -66
View File
@@ -1,77 +1,60 @@
# Metapi Engineering Rules # BoosAPI Engineering Rules
> **AI开发**: 除非用户明确要求切换,BoosAPI 项目全程使用 **gpt-5.5**(`api.boos.lat/v1`,key: `sk-boos4721`)进行开发工作。 > **AI开发**: 使用 **gpt-5.5**(`api.boos.lat/v1`,key: `sk-boos4721`)进行开发工作。
ComfyUI Agent 后端直连上游 API(从 `BOOSAPI_UPSTREAM_BASE`/`BOOSAPI_UPSTREAM_KEY` env 读取),不走本地代理 `/v1/` 路由,因为内部工具无需代理路由+下游密钥认证。 > 本项目为纯 Rust 项目,无 Node.js 运行时依赖。
These rules apply to the whole repository unless a deeper `AGENTS.md` overrides ## 项目结构
them. They are intentionally opinionated and mechanical so humans and agents can
make small, consistent changes without re-learning the codebase each time.
## Golden Principles ```
boosapi-rs/ ← 根目录(已迁移至此)
├── src/
│ ├── main.rs ← 入口
│ ├── lib.rs
│ ├── config.rs
│ ├── error.rs
│ ├── routes/
│ │ ├── mod.rs
│ │ ├── comfyui_agent.rs ← ComfyUI Agent SSE 聊天
│ │ ├── comfyui/ ← ComfyUI 代理路由
│ │ ├── proxy/ ← OpenAI 兼容代理
│ │ ├── auth/ ← 认证
│ │ └── ...
│ ├── proxy/ ← 代理核心(Conductor/Token Router)
│ ├── services/ ← 后台服务
│ ├── db/ ← 数据库
│ └── transformers/ ← 协议转换
├── dist/web/ ← 前端构建产物(由 Rust 静态 serve)
├── migrations/ ← SQLite 迁移
├── config.toml ← 运行配置
├── Dockerfile
└── Cargo.toml
- Prefer one source of truth. If a helper, contract, or workflow already owns ## ComfyUI Agent
an invariant, extend it instead of creating a parallel implementation.
- Fix the family, not just the symptom. When a bug comes from a repeated
pattern, sweep adjacent paths in the same subsystem before calling the work
done.
- Keep changes narrow and reviewable. Land one coherent slice at a time and
avoid bundling unrelated cleanup into the same patch.
## Server Layers ComfyUI Agent 后端直连上游 API(`api.boos.lat/v1`),不走本地代理路由。
- `src/server/routes/**` are adapters, not owners. Route files may register 支持的结构化标记:
Fastify endpoints, parse request context, and delegate. They must not own - 【CHARACTER】 → 自动调用 gpt-image-2 生成四视图
protocol conversion, retry policy, stream lifecycle, billing, or - 【SCENE】 → 自动生成场景图
persistence. - 【TTS】 → 自动调用 TTS API
- If a helper is imported by anything outside one route file, it does not - 【WORKFLOW】 → 输出 ComfyUI 工作流 JSON
belong under `src/server/routes/proxy/`.
- `src/server/proxy-core/**` owns proxy orchestration. Endpoint fallback should
flow through `executeEndpointFlow()`. Channel/session bookkeeping should flow
through `sharedSurface.ts`.
- `src/server/transformers/**` are protocol-pure. Do not import from
`src/server/routes/**`, Fastify, OAuth services, token router, or runtime
dispatch modules. If a transformer needs a shared contract, move it to a
neutral module first.
- Whole-body upstream reads in proxy orchestration should use
`readRuntimeResponseText()` instead of direct `.text()` reads.
## Platform And Routing Rules ## 运行
- Platform behavior must be explicit. Detection, endpoint preference, discovery ```bash
transport, and management capability should come from one declared capability # 开发
story, not scattered `if platform === ...` branches. cargo run --release
- Thin adapters must stay honest. Do not let a platform look feature-complete
through inherited defaults if the underlying upstream does not support the
feature.
- Retry classification and routing health classification should share the same
failure vocabulary whenever possible.
## Database Rules # 环境变量
BOOSAPI_SERVER__HOST=0.0.0.0
BOOSAPI_SERVER__PORT=4000
BOOSAPI_UPSTREAM_BASE=https://api.boos.lat/v1
BOOSAPI_UPSTREAM_KEY=sk-boos4721
BOOSAPI_AUTH__AUTH_TOKEN=change-me-admin-token
```
- One schema change requires three synchronized outputs: update the Drizzle ## 前端
schema, update SQLite migration history, and regenerate checked-in schema
artifacts together.
- Cross-dialect bootstrap and upgrade SQL must be generated from the schema
contract. Do not hand-write new MySQL/Postgres schema patches in feature
code.
- Legacy schema compatibility is temporary and spec-owned. Additive startup
shims should stay narrow and trace back to a feature compatibility spec.
## Web Rules 前端为 React SPA,使用 Vite 构建,产物在 `dist/web/`:
- 开发时用 Vite dev server(需 Node.js)
- Pages are orchestration surfaces, not shared utility libraries. Do not import - 生产时由 Rust 后端直接 serve 静态文件
one top-level page from another top-level page.
- Mobile behavior should reuse existing shared primitives first:
`ResponsiveFilterPanel`, `ResponsiveBatchActionBar`, `MobileCard`,
`useIsMobile`, and `mobileLayout.ts`.
- When a page grows a second complex modal, drawer, or panel family, extract it
into a domain subfolder before adding more inline state and rendering logic.
## Guardrails
- Run `npm run repo:drift-check` before finishing changes that touch shared
architecture boundaries.
- If you add a new boundary-heavy module, add or extend an architecture test in
the same area so the rule becomes executable.
- Keep local planning files under `docs/plans/`. They are intentionally ignored
by git and should not be treated as published documentation.
+14 -100
View File
@@ -1,102 +1,16 @@
FROM ubuntu:24.04 AS base FROM rust:1.83-alpine AS builder
RUN apk add --no-cache musl-dev pkgconfig openssl-dev
ENV DEBIAN_FRONTEND=noninteractive WORKDIR /app
ENV NODE_VERSION=22 COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
# Base deps RUN cargo build --release 2>/dev/null || true
RUN apt-get update && apt-get install -y \
curl git build-essential pkg-config libssl-dev \
python3 python3-pip python3-venv \
&& rm -rf /var/lib/apt/lists/*
# Node.js
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g npm@latest
# Rust (for RTK)
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"
# ========== RTK ==========
FROM base AS rtk-builder
WORKDIR /build
RUN git clone https://github.com/rtk-ai/rtk.git && cd rtk && cargo build --release
RUN cp /build/rtk/target/release/rtk /usr/local/bin/rtk
# ========== ComfyUI ==========
FROM base AS comfyui
WORKDIR /app/comfy
RUN git clone https://github.com/comfyanonymous/ComfyUI.git .
RUN python3 -m venv venv \
&& . venv/bin/activate \
&& pip install --upgrade pip \
&& pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu \
&& pip install -r requirements.txt \
&& pip install comfy-cli
ENV PATH="/app/comfy/venv/bin:${PATH}"
# ========== Metapi ==========
FROM base AS boosapi
WORKDIR /app/boosapi
COPY . . COPY . .
RUN npm install RUN cargo build --release
RUN npm run build --if-present
# ========== Final ========== FROM alpine:3.20
FROM ubuntu:24.04 RUN apk add --no-cache ca-certificates tzdata sqlite-libs
ENV DEBIAN_FRONTEND=noninteractive WORKDIR /app
COPY --from=builder /app/target/release/boosapi .
RUN apt-get update && apt-get install -y \ COPY --from=builder /app/migrations ./migrations
curl python3 python3-pip python3-venv nodejs \ EXPOSE 4000
ca-certificates supervisor \ CMD ["./boosapi"]
&& rm -rf /var/lib/apt/lists/*
# Copy components
COPY --from=rtk-builder /usr/local/bin/rtk /usr/local/bin/rtk
COPY --from=comfyui /app/comfy /app/comfy
COPY --from=comfyui /app/comfy/venv /app/comfy/venv
COPY --from=boosapi /app/boosapi /app/boosapi
COPY --from=boosapi /usr/lib/node_modules /usr/lib/node_modules
# Node path
ENV PATH="/app/comfy/venv/bin:/usr/local/bin:${PATH}"
ENV COMFYUI_BASE=http://127.0.0.1:8188
ENV AUTH_TOKEN=change-me-admin-token
WORKDIR /app/boosapi
# Supervisor config
RUN mkdir -p /var/log/supervisor
COPY <<'EOF' /etc/supervisor/conf.d/all.conf
[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisor/supervisord.log
pidfile=/tmp/supervisord.pid
[program:boosapi-server]
command=npm run dev:server
directory=/app/boosapi
autorestart=true
stdout_logfile=/var/log/supervisor/boosapi-server.log
stderr_logfile=/var/log/supervisor/boosapi-server.err
[program:boosapi-web]
command=npx vite --host 0.0.0.0 --port 5173
directory=/app/boosapi
autorestart=true
stdout_logfile=/var/log/supervisor/boosapi-web.log
stderr_logfile=/var/log/supervisor/boosapi-web.err
[program:comfyui]
command=python3 main.py --listen 0.0.0.0 --port 8188
directory=/app/comfy
autorestart=true
stdout_logfile=/var/log/supervisor/comfyui.log
stderr_logfile=/var/log/supervisor/comfyui.err
EOF
EXPOSE 4000 5173 8188
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/all.conf"]
-8
View File
@@ -1,8 +0,0 @@
BOOSAPI_SERVER__HOST=0.0.0.0
BOOSAPI_SERVER__PORT=4000
BOOSAPI_SERVER__LOG_LEVEL=info
BOOSAPI_DB__URL=sqlite://./data/boosapi.db
BOOSAPI_DB__MAX_CONNECTIONS=10
BOOSAPI_AUTH__JWT_SECRET=change-me
BOOSAPI_AUTH__AUTH_TOKEN=admin
BOOSAPI_AUTH__PROXY_TOKEN=proxy
-5
View File
@@ -1,5 +0,0 @@
/target
**/*.rs.bk
Cargo.lock
.env
data/
-16
View File
@@ -1,16 +0,0 @@
FROM rust:1.83-alpine AS builder
RUN apk add --no-cache musl-dev pkgconfig openssl-dev
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release 2>/dev/null || true
COPY . .
RUN cargo build --release
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata sqlite-libs
WORKDIR /app
COPY --from=builder /app/target/release/boosapi .
COPY --from=builder /app/migrations ./migrations
EXPOSE 4000
CMD ["./boosapi"]
-18
View File
@@ -1,18 +0,0 @@
# Build stage
FROM rust:1.85-slim-bookworm AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y pkg-config libssl-dev
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release 2>/dev/null || true
COPY . .
RUN cargo build --release
# Runtime stage
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/boosapi /app/
COPY --from=builder /app/migrations /app/migrations
EXPOSE 4000
CMD ["/app/boosapi"]
@@ -1,164 +0,0 @@
# 2026-05-15 技术报告 / Technical Report
## Executive Summary
本日完成 BoosAPI 品牌迁移、ComfyUI Agent 对话助手开发、Gitea CI/CD Runner 部署、Notification Service 能力梳理及部署文档重写。核心变更包括:将项目内 `Metapi` 全局替换为 `BoosAPI`,新增 `/comfyui-agent` 对话式 AI 视频生成入口,完成 SSE streaming 前后端链路,并完善生产部署文档与通知服务说明。
---
## 1. Metapi → BoosAPI 全局改名 / Global Rename
### 1.1 变更范围 / Change Scope
| 区域 / Area | 变更内容 / Changes |
|---|---|
| UI 界面 / UI | About 页面、侧边栏、登录页品牌名更新为 `BoosAPI` |
| i18n 翻译 / Internationalization | 所有语言文件中的 `Metapi` 替换为 `BoosAPI` |
| 后端通知 / Backend Notification | `notifyService.ts` 通知前缀由 `[metapi]` 改为 `[BoosAPI]` |
| 桌面元数据 / Desktop Metadata | Electron / Docker 描述信息更新 |
### 1.2 已完成的技术标识替换 / Completed Technical Identifier Changes
| 项目 / Item | 变更说明 / Description |
|---|---|
| HTTP Header | `x-metapi-*` → `x-boosapi-*` |
| 推理签名前缀 / Inference Signature Prefix | `metapi:` → `boosapi:` |
| 运行时属性 / Runtime Properties | `__metapiTruncated` 等 → `__boosapi*` |
| 环境变量 / Environment Variables | `METAPI_*` → `BOOSAPI_*` |
| Package Name | `package.json` 中 `name` 更新为 `boosapi` |
### 1.3 验证方式 / Verification
```bash
grep -rn '[mM]etapi' src/ --include='*.ts' --include='*.tsx' --include='*.html' \
| grep -v '.test.' | grep -v node_modules | grep -iv 'boosapi'
```
预期结果:无输出。
---
## 2. ComfyUI Agent 对话助手 / Conversational Assistant
### 2.1 功能概述 / Feature Overview
侧边栏新增「对话助手」入口,路由为 `/comfyui-agent`,提供与 AI 视频生成助手的对话式交互,支持文本、角色提示词、图像、场景、语音和 ComfyUI workflow 等多类型内容的流式渲染。
### 2.2 技术架构 / Architecture
```
用户 / User → POST /api/comfyui-agent/chat → BoosAPI → LLM 本地 API (streaming)
→ SSE 事件流 → BoosAPI 转发 → 浏览器
```
### 2.3 后端实现 / Backend
| 文件 / File | 作用 / Responsibility |
|---|---|
| `src/server/routes/api/comfyuiAgent.ts` | SSE 流式聊天接口,调用本地 LLM API |
| `src/server/services/boosapi_comfy_nodes.py` | ComfyUI 自定义节点,默认连接 `http://127.0.0.1:4000/v1` |
### 2.4 前端实现 / Frontend
| 文件 / File | 作用 / Responsibility |
|---|---|
| `src/web/pages/ComfyUIAgent.tsx` | 对话界面组件,SSE 流式渲染 |
| `src/web/App.tsx` | 注册 `/comfyui-agent` 路由及侧边栏入口 |
### 2.5 SSE 事件类型 / SSE Event Types
| `type` | 含义 / Meaning | 前端渲染 / Rendering |
|---|---|---|
| `text` | 流式文本 | 追加到对话内容 |
| `character_prompts` | 四角度角色提示词 | 角色卡片 |
| `image` | 生成的角色图像 | 内联图片 |
| `scene` | 场景提示词 | 场景块 |
| `tts` | 语音音频 | 音频播放器 |
| `comfyui_workflow` | 完整 ComfyUI 工作流 JSON | 下载按钮 |
| `done` | 流结束 | 停止等待 |
### 2.6 验证结果 / Verification
| 命令 | 结果 |
|---|---|
| `npm install` | ✅ |
| `npm run typecheck:web` | ✅ 0 error |
---
## 3. Gitea CI/CD Runner 部署 / Runner Deployment
### 3.1 部署信息 / Deployment Info
| 项目 | 详情 |
|---|---|
| 系统 | Alpine Linux, OpenRC |
| 部署方式 | 裸机直跑,无 DinD |
| Runner 版本 | v0.1.3 |
| 注册模式 | `:host` 标签 |
| OpenRC 服务 | `/etc/init.d/gitea-runner` |
| 代理 | `HTTP_PROXY=http://127.0.0.1:7890` |
| `NO_PROXY` | 包含 `git.boos.lat` |
| 持久化 | `ephemeral: false` |
### 3.2 已知限制 / Known Limitations
- 不支持 Docker in Docker;CI 使用 `runs-on: host`
- 无原生 artifact 存储;构建产物需手动备份
- `actions/checkout` 偶发 timeout;可重试或使用本地 git 镜像
---
## 4. Notification Service
### 4.1 支持通道 / Supported Channels
| 通道 | 配置前缀 | 协议 |
|---|---|---|
| Telegram | `telegram*` | Bot API |
| 企业微信 | `wecom*` | Webhook |
| 飞书 | `feishu*` | Webhook |
| 通用 Webhook | `webhook*` | JSON POST |
| Bark | `bark*` | HTTP, iOS |
| Server酱 | `serverchan*` | HTTP |
| SMTP | `smtp*` | Email |
### 4.2 Telegram 消息格式
- 纯文本,不启用 HTML/Markdown 解析
- 单条最大 3900 字符,超长截断 + `...(truncated)`
- 支持 Telegram Topics(`message_thread_id`)
### 4.3 节流机制 / Throttling
- 基于通知签名的全局冷静期:`notifyCooldownSec`
- 同一签名在冷静期内重复触发时合并为一次
- 冷静期为全局共享,不按通道独立
---
## 5. 部署文档完善 / Deployment Documentation
Commit: `5bd847d`
对 `docs/deployment.md` 全量重写,修复所有 `metapi` 残留:
- 完整 `docker-compose.yml` 示例(含 healthcheck)
- `.env` 变量插值模式
- 全部环境变量参考表(20+ 变量)
- 扩展配置:外部 MySQL/PostgreSQL、Redis、资源限制
- 生产部署检查清单
- Nginx 反向代理(WebSocket + SSE)
- 管理后台 IP 白名单限制示例
- Docker run 常用选项表
- 升级/回滚操作流程
---
## 6. 待办事项 / TODO
| 优先级 | 事项 | 说明 |
|---|---|---|
| High | 服务未运行 | CI 崩溃后容器未恢复,需确定 Docker vs `npm run dev` |
| Medium | Telegram 格式优化 | 待确认 HTML/Markdown/简化 |
| Medium | CI pipeline 稳定性 | Docker 镜像拉取超时,需排查网络 |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

+15 -92
View File
@@ -1,95 +1,18 @@
FROM node:22-bookworm-slim AS boosapi-builder # Build stage
FROM rust:1.85-slim-bookworm AS builder
WORKDIR /app WORKDIR /app
RUN echo 'Acquire::http::Proxy "false";' > /etc/apt/apt.conf.d/99no-proxy && apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y pkg-config libssl-dev
ENV PYTHON=/usr/bin/python3 COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
COPY package.json package-lock.json ./ RUN cargo build --release 2>/dev/null || true
RUN npm ci --ignore-scripts --no-audit --no-fund
RUN npm rebuild esbuild sharp better-sqlite3 --no-audit --no-fund
COPY . . COPY . .
RUN npm run build:web RUN cargo build --release
RUN npx tsc -p tsconfig.json --outDir dist --rootDir src --skipLibCheck --noEmit false 2>/dev/null; true
RUN ls dist/server/index.js 2>/dev/null || (echo "=== Server build still missing, trying project reference build ===" && npx tsc -p tsconfig.server.json --skipLibCheck --noEmit false 2>/dev/null; true)
RUN npm prune --omit=dev --no-audit --no-fund
# ---- Final image ---- # Runtime stage
FROM ubuntu:24.04 FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
ARG HTTP_PROXY WORKDIR /app
ARG HTTPS_PROXY COPY --from=builder /app/target/release/boosapi /app/
ARG ALL_PROXY COPY --from=builder /app/migrations /app/migrations
ARG NO_PROXY EXPOSE 4000
CMD ["/app/boosapi"]
ENV DEBIAN_FRONTEND=noninteractive
ENV NODE_ENV=production
ENV COMFYUI_BASE=http://127.0.0.1:8188
ENV HTTP_PROXY=${HTTP_PROXY}
ENV HTTPS_PROXY=${HTTPS_PROXY}
ENV ALL_PROXY=${ALL_PROXY}
ENV NO_PROXY=${NO_PROXY}
# Install system deps: Node.js 22, Python 3, supervisor
RUN echo 'Acquire::http::Proxy "false";' > /etc/apt/apt.conf.d/99no-proxy && apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates gnupg \
python3 python3-pip python3-venv \
supervisor git \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# Install RTK from pre-built binary
COPY rtk /usr/local/bin/rtk
RUN chmod +x /usr/local/bin/rtk
# Set up ComfyUI (workflow editor frontend, calls BoosAPI API via custom nodes)
# CPU PyTorch is fine — all generation goes through BoosAPI's API
WORKDIR /app/comfy
RUN git clone https://github.com/comfyanonymous/ComfyUI.git . \
&& python3 -m venv venv \
&& . venv/bin/activate \
&& pip install --upgrade pip \
&& pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu \
&& grep -v kornia_rs requirements.txt | pip install -r /dev/stdin \
&& rm -rf ~/.cache/pip
ENV PATH="/app/comfy/venv/bin:${PATH}"
# Copy boosapi (built)
WORKDIR /app/boosapi
COPY --from=boosapi-builder /app/dist ./dist
COPY --from=boosapi-builder /app/node_modules ./node_modules
COPY --from=boosapi-builder /app/package.json ./
COPY --from=boosapi-builder /app/drizzle ./drizzle
RUN mkdir -p data
# Copy BoosAPI custom nodes for ComfyUI (call local BoosAPI API)
COPY --from=boosapi-builder /app/boosapi_comfy_nodes.py /app/comfy/custom_nodes/boosapi_comfy_nodes.py
# Supervisor config
RUN mkdir -p /var/log/supervisor
COPY <<'SUPERVISOR' /etc/supervisor/conf.d/all.conf
[supervisord]
nodaemon=true
user=root
logfile=/var/log/supervisor/supervisord.log
pidfile=/tmp/supervisord.pid
[program:boosapi]
command=sh -c "node dist/server/db/migrate.js && node dist/server/index.js"
directory=/app/boosapi
autorestart=true
stdout_logfile=/var/log/supervisor/boosapi.log
stderr_logfile=/var/log/supervisor/boosapi.err
[program:comfyui]
command=python3 main.py --listen 0.0.0.0 --port 8188 --cpu
directory=/app/comfy
autorestart=true
startretries=3
stdout_logfile=/var/log/supervisor/comfyui.log
stderr_logfile=/var/log/supervisor/comfyui.err
SUPERVISOR
EXPOSE 4000 8188
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/all.conf"]
Binary file not shown.
+97
View File
@@ -0,0 +1,97 @@
# SaaS 用户管理重构计划
**目标:** 将现有单用户系统重构为 SaaS 多租户架构,支持组织管理、成员邀请、套餐限制
**核心变更:**
1. 新增 `organizations` + `organization_members` 表
2. 新增 `invitations` 表(邀请码注册)
3. 用户注册改为必须关联组织或创建组织
4. 合并 `/api/admin/users` 和 `/api/users` 路由
5. 前端增加组织管理页面
6. 新增 `plan_limits` 套餐限制系统
---
## 任务清单
### Task 1: 数据库 Schema — 新增组织相关表
**文件:** `src/server/db/schema.ts`
新增三个表:
- `organizations` (id, name, slug, plan, status, createdAt, updatedAt)
- `organization_members` (id, orgId, userId, role, status, joinedAt)
- `invitations` (id, orgId, email, token, role, invitedBy, status, expiresAt, createdAt)
修改 `users` 表:新增 `emailVerified`, `avatarUrl`
### Task 2: 后端 Service — OrganizationService
**文件:** `src/server/services/organizationService.ts`
- `createOrganization(name, creatorUserId)` → 创建组织 + 添加创建者为 owner
- `getOrganization(id)` → 查组织信息
- `listUserOrganizations(userId)` → 用户所在组织列表
- `addMember(orgId, userId, role)` → 添加成员
- `removeMember(orgId, userId)` → 移除成员
- `updateMemberRole(orgId, userId, role)` → 改角色
### Task 3: 后端 Service — InvitationService
**文件:** `src/server/services/invitationService.ts`
- `createInvitation(orgId, email, role, invitedBy)` → 生成邀请
- `acceptInvitation(token, userId)` → 接受邀请
- `listInvitations(orgId)` → 组织邀请列表
- `cancelInvitation(id)` → 撤销邀请
### Task 4: 重构 UserService — 支持组织注册
**文件:** `src/server/services/userService.ts`
- 注册时可以选择加入组织或创建新组织
- 增加 `plan_limits` 查询
- JWT payload 增加 `orgId`
### Task 5: 重构 Routes — 合并 admin/users
**文件:** `src/server/routes/api/users.ts`
- 移除 `/api/users/register` 和 `/api/users/login` 的公开注册(改为邀请制)
- 合并 admin 功能到 `users.ts`,通过 `requireAdmin` 中间件控制
- 删除 `src/server/routes/api/adminUsers.ts`
### Task 6: 新增 Organization Routes
**文件:** `src/server/routes/api/organizations.ts`
- `GET /api/organizations` — 用户所在组织列表
- `POST /api/organizations` — 创建组织
- `GET /api/organizations/:id` — 组织详情(仅成员)
- `PATCH /api/organizations/:id` — 更新组织
- `GET /api/organizations/:id/members` — 成员列表
- `POST /api/organizations/:id/members` — 邀请成员
- `DELETE /api/organizations/:id/members/:userId` — 移除成员
- `POST /api/organizations/:id/invitations` — 创建邀请
- `GET /api/organizations/:id/invitations` — 邀请列表
### Task 7: Plan limits 枚举
**文件:** `src/server/services/planService.ts`
```typescript
export const PLAN_LIMITS = {
free: { maxMembers: 3, maxApiKeys: 5, maxRequestsPerDay: 1000 },
pro: { maxMembers: 10, maxApiKeys: 20, maxRequestsPerDay: 10000 },
enterprise: { maxMembers: 100, maxApiKeys: 100, maxRequestsPerDay: 100000 },
} as const;
```
### Task 8: 前端 — 组织管理页面
**文件:** `src/web/pages/OrganizationSettings.tsx`
- 组织信息编辑(名称、slug)
- 成员管理列表(角色切换、移除)
- 邀请管理(生成邀请链接、撤销)
- 套餐信息与用量展示
-8
View File
@@ -1,8 +0,0 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/server/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: { url: './data/hub.db' },
});
-103
View File
@@ -1,103 +0,0 @@
CREATE TABLE `accounts` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`site_id` integer NOT NULL,
`username` text,
`access_token` text NOT NULL,
`api_token` text,
`balance` real DEFAULT 0,
`balance_used` real DEFAULT 0,
`quota` real DEFAULT 0,
`unit_cost` real,
`value_score` real DEFAULT 0,
`status` text DEFAULT 'active',
`checkin_enabled` integer DEFAULT true,
`last_checkin_at` text,
`last_balance_refresh` text,
`extra_config` text,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now')),
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `checkin_logs` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`account_id` integer NOT NULL,
`status` text NOT NULL,
`message` text,
`reward` text,
`created_at` text DEFAULT (datetime('now')),
FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `model_availability` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`account_id` integer NOT NULL,
`model_name` text NOT NULL,
`available` integer,
`latency_ms` integer,
`checked_at` text DEFAULT (datetime('now')),
FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `proxy_logs` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`route_id` integer,
`channel_id` integer,
`account_id` integer,
`model_requested` text,
`model_actual` text,
`status` text,
`http_status` integer,
`latency_ms` integer,
`prompt_tokens` integer,
`completion_tokens` integer,
`total_tokens` integer,
`estimated_cost` real,
`error_message` text,
`retry_count` integer DEFAULT 0,
`created_at` text DEFAULT (datetime('now'))
);
--> statement-breakpoint
CREATE TABLE `route_channels` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`route_id` integer NOT NULL,
`account_id` integer NOT NULL,
`priority` integer DEFAULT 0,
`weight` integer DEFAULT 10,
`enabled` integer DEFAULT true,
`manual_override` integer DEFAULT false,
`success_count` integer DEFAULT 0,
`fail_count` integer DEFAULT 0,
`total_latency_ms` integer DEFAULT 0,
`total_cost` real DEFAULT 0,
`last_used_at` text,
`last_fail_at` text,
`cooldown_until` text,
FOREIGN KEY (`route_id`) REFERENCES `token_routes`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `settings` (
`key` text PRIMARY KEY NOT NULL,
`value` text
);
--> statement-breakpoint
CREATE TABLE `sites` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`url` text NOT NULL,
`platform` text NOT NULL,
`status` text DEFAULT 'active' NOT NULL,
`api_key` text,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now'))
);
--> statement-breakpoint
CREATE TABLE `token_routes` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`model_pattern` text NOT NULL,
`model_mapping` text,
`enabled` integer DEFAULT true,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now'))
);
-35
View File
@@ -1,35 +0,0 @@
CREATE TABLE IF NOT EXISTS `account_tokens` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`account_id` integer NOT NULL,
`name` text NOT NULL,
`token` text NOT NULL,
`source` text DEFAULT 'manual',
`enabled` integer DEFAULT true,
`is_default` integer DEFAULT false,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now')),
FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
ALTER TABLE `route_channels` ADD COLUMN `token_id` integer;
--> statement-breakpoint
INSERT INTO `account_tokens` (`account_id`, `name`, `token`, `source`, `enabled`, `is_default`, `created_at`, `updated_at`)
SELECT
`a`.`id`,
'default',
`a`.`api_token`,
'legacy',
true,
true,
datetime('now'),
datetime('now')
FROM `accounts` AS `a`
WHERE
`a`.`api_token` IS NOT NULL
AND trim(`a`.`api_token`) <> ''
AND NOT EXISTS (
SELECT 1 FROM `account_tokens` AS `t`
WHERE `t`.`account_id` = `a`.`id`
AND `t`.`token` = `a`.`api_token`
);
-12
View File
@@ -1,12 +0,0 @@
CREATE TABLE IF NOT EXISTS `token_model_availability` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`token_id` integer NOT NULL,
`model_name` text NOT NULL,
`available` integer,
`latency_ms` integer,
`checked_at` text DEFAULT (datetime('now')),
FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS `token_model_availability_token_model_unique`
ON `token_model_availability` (`token_id`, `model_name`);
-69
View File
@@ -1,69 +0,0 @@
CREATE TABLE IF NOT EXISTS `events` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`type` text NOT NULL,
`title` text NOT NULL,
`message` text,
`level` text DEFAULT 'info' NOT NULL,
`read` integer DEFAULT false,
`related_id` integer,
`related_type` text,
`created_at` text DEFAULT (datetime('now'))
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `sites_status_idx` ON `sites` (`status`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `accounts_site_id_idx` ON `accounts` (`site_id`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `accounts_status_idx` ON `accounts` (`status`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `accounts_site_status_idx` ON `accounts` (`site_id`,`status`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `account_tokens_account_id_idx` ON `account_tokens` (`account_id`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `account_tokens_account_enabled_idx` ON `account_tokens` (`account_id`,`enabled`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `account_tokens_enabled_idx` ON `account_tokens` (`enabled`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `checkin_logs_account_created_at_idx` ON `checkin_logs` (`account_id`,`created_at`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `checkin_logs_created_at_idx` ON `checkin_logs` (`created_at`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `checkin_logs_status_idx` ON `checkin_logs` (`status`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `model_availability_account_available_idx` ON `model_availability` (`account_id`,`available`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `model_availability_model_name_idx` ON `model_availability` (`model_name`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `token_model_availability_token_available_idx` ON `token_model_availability` (`token_id`,`available`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `token_model_availability_model_name_idx` ON `token_model_availability` (`model_name`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `token_model_availability_available_idx` ON `token_model_availability` (`available`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `token_routes_model_pattern_idx` ON `token_routes` (`model_pattern`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `token_routes_enabled_idx` ON `token_routes` (`enabled`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `route_channels_route_id_idx` ON `route_channels` (`route_id`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `route_channels_account_id_idx` ON `route_channels` (`account_id`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `route_channels_token_id_idx` ON `route_channels` (`token_id`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `route_channels_route_enabled_idx` ON `route_channels` (`route_id`,`enabled`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `route_channels_route_token_idx` ON `route_channels` (`route_id`,`token_id`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `proxy_logs_created_at_idx` ON `proxy_logs` (`created_at`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `proxy_logs_account_created_at_idx` ON `proxy_logs` (`account_id`,`created_at`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `proxy_logs_status_created_at_idx` ON `proxy_logs` (`status`,`created_at`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `proxy_logs_model_actual_created_at_idx` ON `proxy_logs` (`model_actual`,`created_at`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `events_read_created_at_idx` ON `events` (`read`,`created_at`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `events_type_created_at_idx` ON `events` (`type`,`created_at`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `events_created_at_idx` ON `events` (`created_at`);
-7
View File
@@ -1,7 +0,0 @@
ALTER TABLE `sites` ADD COLUMN `is_pinned` integer DEFAULT false;
--> statement-breakpoint
ALTER TABLE `sites` ADD COLUMN `sort_order` integer DEFAULT 0;
--> statement-breakpoint
ALTER TABLE `accounts` ADD COLUMN `is_pinned` integer DEFAULT false;
--> statement-breakpoint
ALTER TABLE `accounts` ADD COLUMN `sort_order` integer DEFAULT 0;
@@ -1 +0,0 @@
ALTER TABLE `proxy_logs` ADD `billing_details` text;
-13
View File
@@ -1,13 +0,0 @@
CREATE TABLE IF NOT EXISTS `site_disabled_models` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`site_id` integer NOT NULL,
`model_name` text NOT NULL,
`created_at` text DEFAULT (datetime('now')),
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS `site_disabled_models_site_model_unique`
ON `site_disabled_models` (`site_id`, `model_name`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `site_disabled_models_site_id_idx`
ON `site_disabled_models` (`site_id`);
-1
View File
@@ -1 +0,0 @@
ALTER TABLE `account_tokens` ADD `token_group` text;
-214
View File
@@ -1,214 +0,0 @@
ALTER TABLE `sites` ADD `proxy_url` text;
--> statement-breakpoint
ALTER TABLE `sites` ADD `use_system_proxy` integer DEFAULT false;
--> statement-breakpoint
UPDATE `sites`
SET `use_system_proxy` = false
WHERE `use_system_proxy` IS NULL;
--> statement-breakpoint
ALTER TABLE `sites` ADD `custom_headers` text;
--> statement-breakpoint
ALTER TABLE `sites` ADD `external_checkin_url` text;
--> statement-breakpoint
ALTER TABLE `sites` ADD `global_weight` real DEFAULT 1;
--> statement-breakpoint
UPDATE `sites`
SET `global_weight` = 1
WHERE `global_weight` IS NULL
OR `global_weight` <= 0;
--> statement-breakpoint
ALTER TABLE `token_routes` ADD `display_name` text;
--> statement-breakpoint
ALTER TABLE `token_routes` ADD `display_icon` text;
--> statement-breakpoint
ALTER TABLE `token_routes` ADD `decision_snapshot` text;
--> statement-breakpoint
ALTER TABLE `token_routes` ADD `decision_refreshed_at` text;
--> statement-breakpoint
ALTER TABLE `token_routes` ADD `routing_strategy` text DEFAULT 'weighted';
--> statement-breakpoint
ALTER TABLE `route_channels` ADD `source_model` text;
--> statement-breakpoint
ALTER TABLE `route_channels` ADD `last_selected_at` text;
--> statement-breakpoint
ALTER TABLE `route_channels` ADD `consecutive_fail_count` integer NOT NULL DEFAULT 0;
--> statement-breakpoint
ALTER TABLE `route_channels` ADD `cooldown_level` integer NOT NULL DEFAULT 0;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `downstream_api_keys` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`key` text NOT NULL,
`description` text,
`enabled` integer DEFAULT true,
`expires_at` text,
`max_cost` real,
`used_cost` real DEFAULT 0,
`max_requests` integer,
`used_requests` integer DEFAULT 0,
`supported_models` text,
`allowed_route_ids` text,
`site_weight_multipliers` text,
`last_used_at` text,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now'))
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS `downstream_api_keys_key_unique`
ON `downstream_api_keys` (`key`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `downstream_api_keys_name_idx`
ON `downstream_api_keys` (`name`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `downstream_api_keys_enabled_idx`
ON `downstream_api_keys` (`enabled`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `downstream_api_keys_expires_at_idx`
ON `downstream_api_keys` (`expires_at`);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `proxy_files` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`public_id` text NOT NULL,
`owner_type` text NOT NULL,
`owner_id` text NOT NULL,
`filename` text NOT NULL,
`mime_type` text NOT NULL,
`purpose` text,
`byte_size` integer NOT NULL,
`sha256` text NOT NULL,
`content_base64` text NOT NULL,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now')),
`deleted_at` text
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS `proxy_files_public_id_unique`
ON `proxy_files` (`public_id`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `proxy_files_owner_lookup_idx`
ON `proxy_files` (`owner_type`, `owner_id`, `deleted_at`);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS `proxy_video_tasks` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`public_id` text NOT NULL,
`upstream_video_id` text NOT NULL,
`site_url` text NOT NULL,
`token_value` text NOT NULL,
`requested_model` text,
`actual_model` text,
`channel_id` integer,
`account_id` integer,
`status_snapshot` text,
`upstream_response_meta` text,
`last_upstream_status` integer,
`last_polled_at` text,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now'))
);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS `proxy_video_tasks_public_id_unique`
ON `proxy_video_tasks` (`public_id`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `proxy_video_tasks_upstream_video_id_idx`
ON `proxy_video_tasks` (`upstream_video_id`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `proxy_video_tasks_created_at_idx`
ON `proxy_video_tasks` (`created_at`);
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS `model_availability_account_model_unique`
ON `model_availability` (`account_id`, `model_name`);
--> statement-breakpoint
PRAGMA foreign_keys=OFF;
--> statement-breakpoint
CREATE TABLE `__new_route_channels` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`route_id` integer NOT NULL,
`account_id` integer NOT NULL,
`token_id` integer,
`source_model` text,
`priority` integer DEFAULT 0,
`weight` integer DEFAULT 10,
`enabled` integer DEFAULT true,
`manual_override` integer DEFAULT false,
`success_count` integer DEFAULT 0,
`fail_count` integer DEFAULT 0,
`total_latency_ms` integer DEFAULT 0,
`total_cost` real DEFAULT 0,
`last_used_at` text,
`last_selected_at` text,
`last_fail_at` text,
`consecutive_fail_count` integer NOT NULL DEFAULT 0,
`cooldown_level` integer NOT NULL DEFAULT 0,
`cooldown_until` text,
FOREIGN KEY (`route_id`) REFERENCES `token_routes`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`token_id`) REFERENCES `account_tokens`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
INSERT INTO `__new_route_channels` (
`id`,
`route_id`,
`account_id`,
`token_id`,
`source_model`,
`priority`,
`weight`,
`enabled`,
`manual_override`,
`success_count`,
`fail_count`,
`total_latency_ms`,
`total_cost`,
`last_used_at`,
`last_selected_at`,
`last_fail_at`,
`consecutive_fail_count`,
`cooldown_level`,
`cooldown_until`
)
SELECT
`id`,
`route_id`,
`account_id`,
CASE
WHEN `token_id` IS NULL THEN NULL
WHEN EXISTS (SELECT 1 FROM `account_tokens` WHERE `account_tokens`.`id` = `route_channels`.`token_id`) THEN `token_id`
ELSE NULL
END,
`source_model`,
`priority`,
`weight`,
`enabled`,
`manual_override`,
`success_count`,
`fail_count`,
`total_latency_ms`,
`total_cost`,
`last_used_at`,
`last_selected_at`,
`last_fail_at`,
`consecutive_fail_count`,
`cooldown_level`,
`cooldown_until`
FROM `route_channels`;
--> statement-breakpoint
DROP TABLE `route_channels`;
--> statement-breakpoint
ALTER TABLE `__new_route_channels` RENAME TO `route_channels`;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `route_channels_route_id_idx`
ON `route_channels` (`route_id`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `route_channels_account_id_idx`
ON `route_channels` (`account_id`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `route_channels_token_id_idx`
ON `route_channels` (`token_id`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `route_channels_route_enabled_idx`
ON `route_channels` (`route_id`, `enabled`);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `route_channels_route_token_idx`
ON `route_channels` (`route_id`, `token_id`);
--> statement-breakpoint
PRAGMA foreign_keys=ON;
@@ -1 +0,0 @@
ALTER TABLE `model_availability` ADD `is_manual` integer DEFAULT false;
@@ -1,3 +0,0 @@
ALTER TABLE `proxy_logs` ADD `downstream_api_key_id` integer;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `proxy_logs_downstream_api_key_created_at_idx` ON `proxy_logs` (`downstream_api_key_id`, `created_at`);
@@ -1,3 +0,0 @@
ALTER TABLE `downstream_api_keys` ADD `group_name` text;
--> statement-breakpoint
ALTER TABLE `downstream_api_keys` ADD `tags` text;
@@ -1 +0,0 @@
ALTER TABLE `account_tokens` ADD `value_status` text DEFAULT 'ready' NOT NULL;
-24
View File
@@ -1,24 +0,0 @@
ALTER TABLE `accounts` ADD `oauth_provider` text;
--> statement-breakpoint
ALTER TABLE `accounts` ADD `oauth_account_key` text;
--> statement-breakpoint
ALTER TABLE `accounts` ADD `oauth_project_id` text;
--> statement-breakpoint
UPDATE `accounts`
SET
`oauth_provider` = NULLIF(TRIM(json_extract(`extra_config`, '$.oauth.provider')), ''),
`oauth_account_key` = NULLIF(
TRIM(COALESCE(
json_extract(`extra_config`, '$.oauth.accountKey'),
json_extract(`extra_config`, '$.oauth.accountId')
)),
''
),
`oauth_project_id` = NULLIF(TRIM(json_extract(`extra_config`, '$.oauth.projectId')), '')
WHERE `extra_config` IS NOT NULL;
--> statement-breakpoint
CREATE INDEX `accounts_oauth_provider_idx` ON `accounts` (`oauth_provider`);
--> statement-breakpoint
CREATE INDEX `accounts_oauth_identity_idx` ON `accounts` (`oauth_provider`,`oauth_account_key`,`oauth_project_id`);
--> statement-breakpoint
CREATE UNIQUE INDEX `sites_platform_url_unique` ON `sites` (`platform`,`url`);
-13
View File
@@ -1,13 +0,0 @@
ALTER TABLE `token_routes` ADD `route_mode` text DEFAULT 'pattern';
--> statement-breakpoint
CREATE TABLE `route_group_sources` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`group_route_id` integer NOT NULL,
`source_route_id` integer NOT NULL,
FOREIGN KEY (`group_route_id`) REFERENCES `token_routes`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`source_route_id`) REFERENCES `token_routes`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `route_group_sources_group_source_unique` ON `route_group_sources` (`group_route_id`,`source_route_id`);
--> statement-breakpoint
CREATE INDEX `route_group_sources_source_route_id_idx` ON `route_group_sources` (`source_route_id`);
-26
View File
@@ -1,26 +0,0 @@
CREATE TABLE `site_announcements` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`site_id` integer NOT NULL,
`platform` text NOT NULL,
`source_key` text NOT NULL,
`title` text NOT NULL,
`content` text NOT NULL,
`level` text DEFAULT 'info' NOT NULL,
`source_url` text,
`starts_at` text,
`ends_at` text,
`upstream_created_at` text,
`upstream_updated_at` text,
`first_seen_at` text DEFAULT (datetime('now')),
`last_seen_at` text DEFAULT (datetime('now')),
`read_at` text,
`dismissed_at` text,
`raw_payload` text,
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `site_announcements_site_source_key_unique` ON `site_announcements` (`site_id`,`source_key`);
--> statement-breakpoint
CREATE INDEX `site_announcements_site_id_first_seen_at_idx` ON `site_announcements` (`site_id`,`first_seen_at`);
--> statement-breakpoint
CREATE INDEX `site_announcements_read_at_idx` ON `site_announcements` (`read_at`);
@@ -1,6 +0,0 @@
ALTER TABLE `proxy_logs` ADD `client_family` text;--> statement-breakpoint
ALTER TABLE `proxy_logs` ADD `client_app_id` text;--> statement-breakpoint
ALTER TABLE `proxy_logs` ADD `client_app_name` text;--> statement-breakpoint
ALTER TABLE `proxy_logs` ADD `client_confidence` text;--> statement-breakpoint
CREATE INDEX `proxy_logs_client_app_id_created_at_idx` ON `proxy_logs` (`client_app_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `proxy_logs_client_family_created_at_idx` ON `proxy_logs` (`client_family`,`created_at`);
-57
View File
@@ -1,57 +0,0 @@
CREATE TABLE `proxy_debug_traces` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`downstream_path` text NOT NULL,
`client_kind` text,
`session_id` text,
`trace_hint` text,
`requested_model` text,
`downstream_api_key_id` integer,
`request_headers_json` text,
`request_body_json` text,
`sticky_session_key` text,
`sticky_hit_channel_id` integer,
`selected_channel_id` integer,
`selected_route_id` integer,
`selected_account_id` integer,
`selected_site_id` integer,
`selected_site_platform` text,
`endpoint_candidates_json` text,
`endpoint_runtime_state_json` text,
`decision_summary_json` text,
`final_status` text,
`final_http_status` integer,
`final_upstream_path` text,
`final_response_headers_json` text,
`final_response_body_json` text,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now'))
);
--> statement-breakpoint
CREATE INDEX `proxy_debug_traces_created_at_idx` ON `proxy_debug_traces` (`created_at`);--> statement-breakpoint
CREATE INDEX `proxy_debug_traces_session_created_at_idx` ON `proxy_debug_traces` (`session_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `proxy_debug_traces_model_created_at_idx` ON `proxy_debug_traces` (`requested_model`,`created_at`);--> statement-breakpoint
CREATE INDEX `proxy_debug_traces_final_status_created_at_idx` ON `proxy_debug_traces` (`final_status`,`created_at`);--> statement-breakpoint
CREATE TABLE `proxy_debug_attempts` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`trace_id` integer NOT NULL,
`attempt_index` integer NOT NULL,
`endpoint` text NOT NULL,
`request_path` text NOT NULL,
`target_url` text NOT NULL,
`runtime_executor` text,
`request_headers_json` text,
`request_body_json` text,
`response_status` integer,
`response_headers_json` text,
`response_body_json` text,
`raw_error_text` text,
`recover_applied` integer DEFAULT false,
`downgrade_decision` integer DEFAULT false,
`downgrade_reason` text,
`memory_write_json` text,
`created_at` text DEFAULT (datetime('now')),
FOREIGN KEY (`trace_id`) REFERENCES `proxy_debug_traces`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `proxy_debug_attempts_trace_attempt_unique` ON `proxy_debug_attempts` (`trace_id`,`attempt_index`);--> statement-breakpoint
CREATE INDEX `proxy_debug_attempts_trace_created_at_idx` ON `proxy_debug_attempts` (`trace_id`,`created_at`);
-18
View File
@@ -1,18 +0,0 @@
CREATE TABLE `site_api_endpoints` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`site_id` integer NOT NULL,
`url` text NOT NULL,
`enabled` integer DEFAULT true,
`sort_order` integer DEFAULT 0,
`cooldown_until` text,
`last_selected_at` text,
`last_failed_at` text,
`last_failure_reason` text,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now')),
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `site_api_endpoints_site_url_unique` ON `site_api_endpoints` (`site_id`,`url`);--> statement-breakpoint
CREATE INDEX `site_api_endpoints_site_enabled_sort_idx` ON `site_api_endpoints` (`site_id`,`enabled`,`sort_order`);--> statement-breakpoint
CREATE INDEX `site_api_endpoints_site_cooldown_idx` ON `site_api_endpoints` (`site_id`,`cooldown_until`);
@@ -1,2 +0,0 @@
ALTER TABLE `proxy_logs` ADD `is_stream` integer;--> statement-breakpoint
ALTER TABLE `proxy_logs` ADD `first_byte_latency_ms` integer;
@@ -1,3 +0,0 @@
ALTER TABLE `downstream_api_keys` ADD `excluded_site_ids` text;
--> statement-breakpoint
ALTER TABLE `downstream_api_keys` ADD `excluded_credential_refs` text;
-41
View File
@@ -1,41 +0,0 @@
CREATE TABLE `oauth_route_unit_members` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`unit_id` integer NOT NULL,
`account_id` integer NOT NULL,
`sort_order` integer DEFAULT 0,
`success_count` integer DEFAULT 0,
`fail_count` integer DEFAULT 0,
`total_latency_ms` integer DEFAULT 0,
`total_cost` real DEFAULT 0,
`last_used_at` text,
`last_selected_at` text,
`last_fail_at` text,
`consecutive_fail_count` integer DEFAULT 0 NOT NULL,
`cooldown_level` integer DEFAULT 0 NOT NULL,
`cooldown_until` text,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now')),
FOREIGN KEY (`unit_id`) REFERENCES `oauth_route_units`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`account_id`) REFERENCES `accounts`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `oauth_route_unit_members_unit_account_unique` ON `oauth_route_unit_members` (`unit_id`,`account_id`);--> statement-breakpoint
CREATE UNIQUE INDEX `oauth_route_unit_members_account_unique` ON `oauth_route_unit_members` (`account_id`);--> statement-breakpoint
CREATE INDEX `oauth_route_unit_members_unit_sort_idx` ON `oauth_route_unit_members` (`unit_id`,`sort_order`);--> statement-breakpoint
CREATE INDEX `oauth_route_unit_members_unit_cooldown_idx` ON `oauth_route_unit_members` (`unit_id`,`cooldown_until`);--> statement-breakpoint
CREATE TABLE `oauth_route_units` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`site_id` integer NOT NULL,
`provider` text NOT NULL,
`name` text NOT NULL,
`strategy` text DEFAULT 'round_robin' NOT NULL,
`enabled` integer DEFAULT true,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now')),
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `oauth_route_units_site_provider_idx` ON `oauth_route_units` (`site_id`,`provider`);--> statement-breakpoint
CREATE INDEX `oauth_route_units_enabled_idx` ON `oauth_route_units` (`enabled`);--> statement-breakpoint
ALTER TABLE `route_channels` ADD `oauth_route_unit_id` integer;--> statement-breakpoint
CREATE INDEX `route_channels_oauth_route_unit_id_idx` ON `route_channels` (`oauth_route_unit_id`);
-15
View File
@@ -1,15 +0,0 @@
CREATE TABLE `admin_snapshots` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`namespace` text NOT NULL,
`snapshot_key` text NOT NULL,
`payload` text NOT NULL,
`generated_at` text NOT NULL,
`expires_at` text NOT NULL,
`stale_until` text NOT NULL,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now'))
);
--> statement-breakpoint
CREATE UNIQUE INDEX `admin_snapshots_namespace_key_unique` ON `admin_snapshots` (`namespace`,`snapshot_key`);--> statement-breakpoint
CREATE INDEX `admin_snapshots_expires_at_idx` ON `admin_snapshots` (`expires_at`);--> statement-breakpoint
CREATE INDEX `admin_snapshots_stale_until_idx` ON `admin_snapshots` (`stale_until`);
-79
View File
@@ -1,79 +0,0 @@
CREATE TABLE `analytics_projection_checkpoints` (
`projector_key` text PRIMARY KEY NOT NULL,
`time_zone` text DEFAULT 'Local' NOT NULL,
`last_proxy_log_id` integer DEFAULT 0 NOT NULL,
`watermark_created_at` text,
`recompute_from_id` integer,
`recompute_requested_at` text,
`recompute_reason` text,
`recompute_started_at` text,
`recompute_completed_at` text,
`last_projected_at` text,
`last_successful_at` text,
`last_error` text,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now'))
);
--> statement-breakpoint
CREATE INDEX `analytics_projection_checkpoints_recompute_from_id_idx` ON `analytics_projection_checkpoints` (`recompute_from_id`);--> statement-breakpoint
CREATE TABLE `model_day_usage` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`local_day` text NOT NULL,
`site_id` integer NOT NULL,
`model` text NOT NULL,
`total_calls` integer DEFAULT 0 NOT NULL,
`success_calls` integer DEFAULT 0 NOT NULL,
`failed_calls` integer DEFAULT 0 NOT NULL,
`total_tokens` integer DEFAULT 0 NOT NULL,
`total_spend` real DEFAULT 0 NOT NULL,
`total_latency_ms` integer DEFAULT 0 NOT NULL,
`latency_count` integer DEFAULT 0 NOT NULL,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now')),
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `model_day_usage_day_site_model_unique` ON `model_day_usage` (`local_day`,`site_id`,`model`);--> statement-breakpoint
CREATE INDEX `model_day_usage_day_idx` ON `model_day_usage` (`local_day`);--> statement-breakpoint
CREATE INDEX `model_day_usage_site_id_idx` ON `model_day_usage` (`site_id`);--> statement-breakpoint
CREATE INDEX `model_day_usage_model_idx` ON `model_day_usage` (`model`);--> statement-breakpoint
CREATE TABLE `site_day_usage` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`local_day` text NOT NULL,
`site_id` integer NOT NULL,
`total_calls` integer DEFAULT 0 NOT NULL,
`success_calls` integer DEFAULT 0 NOT NULL,
`failed_calls` integer DEFAULT 0 NOT NULL,
`total_tokens` integer DEFAULT 0 NOT NULL,
`total_summary_spend` real DEFAULT 0 NOT NULL,
`total_site_spend` real DEFAULT 0 NOT NULL,
`total_latency_ms` integer DEFAULT 0 NOT NULL,
`latency_count` integer DEFAULT 0 NOT NULL,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now')),
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `site_day_usage_day_site_unique` ON `site_day_usage` (`local_day`,`site_id`);--> statement-breakpoint
CREATE INDEX `site_day_usage_day_idx` ON `site_day_usage` (`local_day`);--> statement-breakpoint
CREATE INDEX `site_day_usage_site_id_idx` ON `site_day_usage` (`site_id`);--> statement-breakpoint
CREATE TABLE `site_hour_usage` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`bucket_start_utc` text NOT NULL,
`site_id` integer NOT NULL,
`total_calls` integer DEFAULT 0 NOT NULL,
`success_calls` integer DEFAULT 0 NOT NULL,
`failed_calls` integer DEFAULT 0 NOT NULL,
`total_tokens` integer DEFAULT 0 NOT NULL,
`total_summary_spend` real DEFAULT 0 NOT NULL,
`total_site_spend` real DEFAULT 0 NOT NULL,
`total_latency_ms` integer DEFAULT 0 NOT NULL,
`latency_count` integer DEFAULT 0 NOT NULL,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now')),
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `site_hour_usage_hour_site_unique` ON `site_hour_usage` (`bucket_start_utc`,`site_id`);--> statement-breakpoint
CREATE INDEX `site_hour_usage_hour_idx` ON `site_hour_usage` (`bucket_start_utc`);--> statement-breakpoint
CREATE INDEX `site_hour_usage_site_id_idx` ON `site_hour_usage` (`site_id`);
-79
View File
@@ -1,79 +0,0 @@
ALTER TABLE `analytics_projection_checkpoints` ADD `lease_owner` text;--> statement-breakpoint
ALTER TABLE `analytics_projection_checkpoints` ADD `lease_token` text;--> statement-breakpoint
ALTER TABLE `analytics_projection_checkpoints` ADD `lease_expires_at` text;--> statement-breakpoint
CREATE INDEX `analytics_projection_checkpoints_lease_expires_at_idx` ON `analytics_projection_checkpoints` (`lease_expires_at`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_model_day_usage` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`local_day` text NOT NULL,
`site_id` integer NOT NULL,
`model` text NOT NULL,
`total_calls` integer DEFAULT 0 NOT NULL,
`success_calls` integer DEFAULT 0 NOT NULL,
`failed_calls` integer DEFAULT 0 NOT NULL,
`total_tokens` integer DEFAULT 0 NOT NULL,
`total_spend` real DEFAULT 0 NOT NULL,
`total_latency_ms` integer DEFAULT 0 NOT NULL,
`latency_count` integer DEFAULT 0 NOT NULL,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now')),
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE cascade,
CONSTRAINT "model_day_usage_non_negative" CHECK("__new_model_day_usage"."total_calls" >= 0 and "__new_model_day_usage"."success_calls" >= 0 and "__new_model_day_usage"."failed_calls" >= 0 and "__new_model_day_usage"."total_tokens" >= 0 and "__new_model_day_usage"."total_spend" >= 0 and "__new_model_day_usage"."total_latency_ms" >= 0 and "__new_model_day_usage"."latency_count" >= 0)
);
--> statement-breakpoint
INSERT INTO `__new_model_day_usage`("id", "local_day", "site_id", "model", "total_calls", "success_calls", "failed_calls", "total_tokens", "total_spend", "total_latency_ms", "latency_count", "created_at", "updated_at") SELECT "id", "local_day", "site_id", "model", "total_calls", "success_calls", "failed_calls", "total_tokens", "total_spend", "total_latency_ms", "latency_count", "created_at", "updated_at" FROM `model_day_usage`;--> statement-breakpoint
DROP TABLE `model_day_usage`;--> statement-breakpoint
ALTER TABLE `__new_model_day_usage` RENAME TO `model_day_usage`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE UNIQUE INDEX `model_day_usage_day_site_model_unique` ON `model_day_usage` (`local_day`,`site_id`,`model`);--> statement-breakpoint
CREATE INDEX `model_day_usage_day_idx` ON `model_day_usage` (`local_day`);--> statement-breakpoint
CREATE INDEX `model_day_usage_site_id_idx` ON `model_day_usage` (`site_id`);--> statement-breakpoint
CREATE INDEX `model_day_usage_model_idx` ON `model_day_usage` (`model`);--> statement-breakpoint
CREATE TABLE `__new_site_day_usage` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`local_day` text NOT NULL,
`site_id` integer NOT NULL,
`total_calls` integer DEFAULT 0 NOT NULL,
`success_calls` integer DEFAULT 0 NOT NULL,
`failed_calls` integer DEFAULT 0 NOT NULL,
`total_tokens` integer DEFAULT 0 NOT NULL,
`total_summary_spend` real DEFAULT 0 NOT NULL,
`total_site_spend` real DEFAULT 0 NOT NULL,
`total_latency_ms` integer DEFAULT 0 NOT NULL,
`latency_count` integer DEFAULT 0 NOT NULL,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now')),
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE cascade,
CONSTRAINT "site_day_usage_non_negative" CHECK("__new_site_day_usage"."total_calls" >= 0 and "__new_site_day_usage"."success_calls" >= 0 and "__new_site_day_usage"."failed_calls" >= 0 and "__new_site_day_usage"."total_tokens" >= 0 and "__new_site_day_usage"."total_summary_spend" >= 0 and "__new_site_day_usage"."total_site_spend" >= 0 and "__new_site_day_usage"."total_latency_ms" >= 0 and "__new_site_day_usage"."latency_count" >= 0)
);
--> statement-breakpoint
INSERT INTO `__new_site_day_usage`("id", "local_day", "site_id", "total_calls", "success_calls", "failed_calls", "total_tokens", "total_summary_spend", "total_site_spend", "total_latency_ms", "latency_count", "created_at", "updated_at") SELECT "id", "local_day", "site_id", "total_calls", "success_calls", "failed_calls", "total_tokens", "total_summary_spend", "total_site_spend", "total_latency_ms", "latency_count", "created_at", "updated_at" FROM `site_day_usage`;--> statement-breakpoint
DROP TABLE `site_day_usage`;--> statement-breakpoint
ALTER TABLE `__new_site_day_usage` RENAME TO `site_day_usage`;--> statement-breakpoint
CREATE UNIQUE INDEX `site_day_usage_day_site_unique` ON `site_day_usage` (`local_day`,`site_id`);--> statement-breakpoint
CREATE INDEX `site_day_usage_day_idx` ON `site_day_usage` (`local_day`);--> statement-breakpoint
CREATE INDEX `site_day_usage_site_id_idx` ON `site_day_usage` (`site_id`);--> statement-breakpoint
CREATE TABLE `__new_site_hour_usage` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`bucket_start_utc` text NOT NULL,
`site_id` integer NOT NULL,
`total_calls` integer DEFAULT 0 NOT NULL,
`success_calls` integer DEFAULT 0 NOT NULL,
`failed_calls` integer DEFAULT 0 NOT NULL,
`total_tokens` integer DEFAULT 0 NOT NULL,
`total_summary_spend` real DEFAULT 0 NOT NULL,
`total_site_spend` real DEFAULT 0 NOT NULL,
`total_latency_ms` integer DEFAULT 0 NOT NULL,
`latency_count` integer DEFAULT 0 NOT NULL,
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now')),
FOREIGN KEY (`site_id`) REFERENCES `sites`(`id`) ON UPDATE no action ON DELETE cascade,
CONSTRAINT "site_hour_usage_non_negative" CHECK("__new_site_hour_usage"."total_calls" >= 0 and "__new_site_hour_usage"."success_calls" >= 0 and "__new_site_hour_usage"."failed_calls" >= 0 and "__new_site_hour_usage"."total_tokens" >= 0 and "__new_site_hour_usage"."total_summary_spend" >= 0 and "__new_site_hour_usage"."total_site_spend" >= 0 and "__new_site_hour_usage"."total_latency_ms" >= 0 and "__new_site_hour_usage"."latency_count" >= 0)
);
--> statement-breakpoint
INSERT INTO `__new_site_hour_usage`("id", "bucket_start_utc", "site_id", "total_calls", "success_calls", "failed_calls", "total_tokens", "total_summary_spend", "total_site_spend", "total_latency_ms", "latency_count", "created_at", "updated_at") SELECT "id", "bucket_start_utc", "site_id", "total_calls", "success_calls", "failed_calls", "total_tokens", "total_summary_spend", "total_site_spend", "total_latency_ms", "latency_count", "created_at", "updated_at" FROM `site_hour_usage`;--> statement-breakpoint
DROP TABLE `site_hour_usage`;--> statement-breakpoint
ALTER TABLE `__new_site_hour_usage` RENAME TO `site_hour_usage`;--> statement-breakpoint
CREATE UNIQUE INDEX `site_hour_usage_hour_site_unique` ON `site_hour_usage` (`bucket_start_utc`,`site_id`);--> statement-breakpoint
CREATE INDEX `site_hour_usage_hour_idx` ON `site_hour_usage` (`bucket_start_utc`);--> statement-breakpoint
CREATE INDEX `site_hour_usage_site_id_idx` ON `site_hour_usage` (`site_id`);
-5
View File
@@ -1,5 +0,0 @@
ALTER TABLE `sites` ADD `post_refresh_probe_enabled` integer DEFAULT false;
--> statement-breakpoint
ALTER TABLE `sites` ADD `post_refresh_probe_model` text DEFAULT '';
--> statement-breakpoint
ALTER TABLE `sites` ADD `post_refresh_probe_scope` text DEFAULT 'single';
@@ -1 +0,0 @@
ALTER TABLE `sites` ADD `post_refresh_probe_latency_threshold_ms` integer DEFAULT 0;
-14
View File
@@ -1,14 +0,0 @@
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`username` text NOT NULL,
`email` text NOT NULL,
`password_hash` text NOT NULL,
`role` text NOT NULL DEFAULT 'user',
`status` text NOT NULL DEFAULT 'active',
`created_at` text DEFAULT (datetime('now')),
`updated_at` text DEFAULT (datetime('now'))
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
--> statement-breakpoint
CREATE INDEX `users_status_idx` ON `users` (`status`);
-710
View File
@@ -1,710 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "9035be50-a1b2-4a8a-8d3c-27cf0d4e3a12",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"accounts": {
"name": "accounts",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"site_id": {
"name": "site_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_token": {
"name": "api_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"balance": {
"name": "balance",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"balance_used": {
"name": "balance_used",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"quota": {
"name": "quota",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"unit_cost": {
"name": "unit_cost",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"value_score": {
"name": "value_score",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'active'"
},
"checkin_enabled": {
"name": "checkin_enabled",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": true
},
"last_checkin_at": {
"name": "last_checkin_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_balance_refresh": {
"name": "last_balance_refresh",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"extra_config": {
"name": "extra_config",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {
"accounts_site_id_sites_id_fk": {
"name": "accounts_site_id_sites_id_fk",
"tableFrom": "accounts",
"tableTo": "sites",
"columnsFrom": [
"site_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"checkin_logs": {
"name": "checkin_logs",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"account_id": {
"name": "account_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"reward": {
"name": "reward",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {
"checkin_logs_account_id_accounts_id_fk": {
"name": "checkin_logs_account_id_accounts_id_fk",
"tableFrom": "checkin_logs",
"tableTo": "accounts",
"columnsFrom": [
"account_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"model_availability": {
"name": "model_availability",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"account_id": {
"name": "account_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"model_name": {
"name": "model_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"available": {
"name": "available",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"latency_ms": {
"name": "latency_ms",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"checked_at": {
"name": "checked_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {
"model_availability_account_id_accounts_id_fk": {
"name": "model_availability_account_id_accounts_id_fk",
"tableFrom": "model_availability",
"tableTo": "accounts",
"columnsFrom": [
"account_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"proxy_logs": {
"name": "proxy_logs",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"route_id": {
"name": "route_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"channel_id": {
"name": "channel_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model_requested": {
"name": "model_requested",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model_actual": {
"name": "model_actual",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"http_status": {
"name": "http_status",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"latency_ms": {
"name": "latency_ms",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"prompt_tokens": {
"name": "prompt_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"completion_tokens": {
"name": "completion_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"total_tokens": {
"name": "total_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"estimated_cost": {
"name": "estimated_cost",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error_message": {
"name": "error_message",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"retry_count": {
"name": "retry_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"route_channels": {
"name": "route_channels",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"route_id": {
"name": "route_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"priority": {
"name": "priority",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"weight": {
"name": "weight",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 10
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": true
},
"manual_override": {
"name": "manual_override",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"success_count": {
"name": "success_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"fail_count": {
"name": "fail_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"total_latency_ms": {
"name": "total_latency_ms",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"total_cost": {
"name": "total_cost",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": 0
},
"last_used_at": {
"name": "last_used_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_fail_at": {
"name": "last_fail_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"cooldown_until": {
"name": "cooldown_until",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"route_channels_route_id_token_routes_id_fk": {
"name": "route_channels_route_id_token_routes_id_fk",
"tableFrom": "route_channels",
"tableTo": "token_routes",
"columnsFrom": [
"route_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"route_channels_account_id_accounts_id_fk": {
"name": "route_channels_account_id_accounts_id_fk",
"tableFrom": "route_channels",
"tableTo": "accounts",
"columnsFrom": [
"account_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"settings": {
"name": "settings",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sites": {
"name": "sites",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_key": {
"name": "api_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"token_routes": {
"name": "token_routes",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"model_pattern": {
"name": "model_pattern",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"model_mapping": {
"name": "model_mapping",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(datetime('now'))"
},
"updated_at": {
"name": "updated_at",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(datetime('now'))"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-195
View File
@@ -1,195 +0,0 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1771906082166,
"tag": "0000_sloppy_blackheart",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1771966800000,
"tag": "0001_token_management",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1771969800000,
"tag": "0002_token_model_availability",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1772079351462,
"tag": "0003_great_blizzard",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1772320800000,
"tag": "0004_sorting_preferences",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1773500331000,
"tag": "0006_site_disabled_models",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1773500400000,
"tag": "0007_account_token_group",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1773500500000,
"tag": "0008_sqlite_schema_backfill",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1773528832339,
"tag": "0009_model_availability_is_manual",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1773553945763,
"tag": "0010_proxy_logs_downstream_api_key",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1773605400000,
"tag": "0011_downstream_api_key_metadata",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1773665311013,
"tag": "0012_account_token_value_status",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1773682200000,
"tag": "0013_oauth_multi_provider",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1773855000000,
"tag": "0014_explicit_group_routes",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1773900000000,
"tag": "0015_site_announcements",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1773979652606,
"tag": "0016_proxy_logs_client_fields",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1774692431588,
"tag": "0017_proxy_debug_traces",
"breakpoints": true
},
{
"idx": 17,
"version": "6",
"when": 1774959227309,
"tag": "0018_site_api_endpoints",
"breakpoints": true
},
{
"idx": 18,
"version": "6",
"when": 1775058998448,
"tag": "0019_proxy_logs_stream_timing",
"breakpoints": true
},
{
"idx": 19,
"version": "6",
"when": 1775397600000,
"tag": "0020_downstream_api_key_exclusions",
"breakpoints": true
},
{
"idx": 20,
"version": "6",
"when": 1775441167045,
"tag": "0021_young_shriek",
"breakpoints": true
},
{
"idx": 21,
"version": "6",
"when": 1775713163948,
"tag": "0022_admin_snapshots",
"breakpoints": true
},
{
"idx": 22,
"version": "6",
"when": 1775721864379,
"tag": "0023_usage_aggregates",
"breakpoints": true
},
{
"idx": 23,
"version": "6",
"when": 1775731596826,
"tag": "0024_projection_leases",
"breakpoints": true
},
{
"idx": 24,
"version": "6",
"when": 1776943800000,
"tag": "0025_site_post_refresh_probe",
"breakpoints": true
},
{
"idx": 25,
"version": "6",
"when": 1776944000000,
"tag": "0026_site_probe_latency_threshold",
"breakpoints": true
},
{
"idx": 26,
"version": "6",
"when": 1777000000000,
"tag": "0027_users_table",
"breakpoints": true
}
]
}
-17175
View File
File diff suppressed because it is too large Load Diff
-140
View File
@@ -1,140 +0,0 @@
{
"name": "boosapi",
"version": "1.3.0",
"description": "Meta-layer management and unified proxy for AI API aggregation platforms",
"main": "dist/desktop/main.js",
"keywords": [
"ai",
"api-gateway",
"proxy",
"fastify",
"openai",
"claude",
"aggregator"
],
"homepage": "https://github.com/cita-777/metapi#readme",
"bugs": {
"url": "https://github.com/cita-777/metapi/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/cita-777/metapi.git"
},
"license": "MIT",
"engines": {
"node": ">=25.0.0"
},
"type": "module",
"scripts": {
"dev:server": "tsx scripts/dev/run-server.ts --watch",
"dev": "concurrently \"npm run dev:server\" \"vite\"",
"dev:desktop": "concurrently \"npm run dev:server\" \"vite\" \"tsc -p tsconfig.desktop.json --watch --preserveWatchOutput\" \"wait-on tcp:127.0.0.1:4000 http://127.0.0.1:5173 file:dist/desktop/main.js && cross-env METAPI_DESKTOP_DEV_SERVER_URL=http://127.0.0.1:5173 METAPI_DESKTOP_EXTERNAL_SERVER_URL=http://127.0.0.1:4000 electron dist/desktop/main.js\"",
"desktop:icons": "node scripts/desktop/generate-icons.mjs",
"build:web": "npm run desktop:icons && vite build",
"build:server": "tsc -p tsconfig.server.json && tsx scripts/dev/copy-runtime-db-generated.ts",
"build:desktop": "tsc -p tsconfig.desktop.json",
"build": "npm run build:web && npm run build:server && npm run build:desktop",
"typecheck:web": "tsc -p tsconfig.web.json",
"typecheck:web:test": "tsc -p tsconfig.web.test.json",
"typecheck:server": "tsc --noEmit -p tsconfig.server.json",
"typecheck:desktop": "tsc --noEmit -p tsconfig.desktop.json",
"typecheck": "npm run typecheck:web && npm run typecheck:web:test && npm run typecheck:server && npm run typecheck:desktop",
"dist:desktop": "npm run build && electron-builder --config electron-builder.yml --publish never",
"dist:desktop:mac:intel": "npm run build && electron-builder --config electron-builder.yml --publish never --mac --x64",
"package:desktop": "npm run dist:desktop",
"package:desktop:mac:intel": "npm run dist:desktop:mac:intel",
"docs:dev": "vitepress dev docs --host 0.0.0.0 --port 4173",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs --host 0.0.0.0 --port 4173",
"start": "node dist/server/index.js",
"start:deploy-helper": "node dist/server/update-helper/index.js",
"start:desktop": "electron dist/desktop/main.js",
"comfyui:install": "node scripts/comfyui/install.mjs",
"comfyui:start": "node scripts/comfyui/start.mjs",
"comfyui:stop": "node scripts/comfyui/stop.mjs",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/server/db/migrate.ts",
"schema:contract": "tsx scripts/dev/generate-schema-contract.ts",
"schema:generate": "npm run db:generate && npm run schema:contract",
"repo:drift-check": "tsx scripts/dev/repo-drift-check.ts",
"readme:contributors": "tsx scripts/dev/update-readme-contributors.ts",
"test:schema:unit": "vitest run --root . src/server/db/schemaContract.test.ts src/server/db/schemaArtifactGenerator.test.ts src/server/db/schemaIntrospection.test.ts src/server/db/schemaParity.test.ts",
"test:schema:parity": "vitest run --root . src/server/db/schemaParity.live.test.ts",
"test:schema:upgrade": "vitest run --root . src/server/db/schemaUpgrade.live.test.ts",
"test:schema:runtime": "vitest run --root . src/server/db/runtimeSchemaBootstrap.live.test.ts",
"smoke:db": "tsx scripts/dev/db-smoke.ts",
"smoke:db:sqlite": "tsx scripts/dev/db-smoke.ts --db-type sqlite",
"smoke:db:mysql": "tsx scripts/dev/db-smoke.ts --db-type mysql",
"smoke:db:postgres": "tsx scripts/dev/db-smoke.ts --db-type postgres",
"test": "vitest run --root .",
"test:watch": "vitest --root ."
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fastify/cors": "^11.2.0",
"@fastify/static": "^9.0.0",
"@visactor/react-vchart": "^2.0.21",
"better-sqlite3": "^12.8.0",
"dotenv": "^17.4.1",
"drizzle-orm": "^0.45.2",
"electron-log": "^5.2.4",
"electron-updater": "^6.6.2",
"fastify": "^5.8.4",
"get-port": "^7.2.0",
"marked": "^17.0.5",
"minimatch": "^10.2.5",
"minimist": "^1.2.8",
"mysql2": "^3.20.0",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.4",
"pg": "^8.16.3",
"rate-limiter-flexible": "^11.0.0",
"socks": "^2.8.7",
"undici": "^6.24.1",
"ws": "^8.20.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@electron/notarize": "^3.1.0",
"@tailwindcss/vite": "^4.2.2",
"@types/better-sqlite3": "^7.6.11",
"@types/node": "^22.10.1",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.20.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/react-test-renderer": "^19.1.0",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.0",
"cross-env": "^7.0.3",
"drizzle-kit": "^0.31.10",
"electron": "^41.1.1",
"electron-builder": "^26.0.12",
"jsdom": "^29.0.2",
"mermaid": "^11.14.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.14.0",
"react-test-renderer": "^18.3.1",
"sharp": "^0.34.5",
"tailwindcss": "^4.0.0",
"tsx": "^4.19.2",
"typescript": "^6.0.2",
"vite": "^6.0.3",
"vitepress": "^1.6.4",
"vitepress-plugin-mermaid": "^2.0.17",
"vitest": "^2.1.8",
"wait-on": "^9.0.4"
},
"overrides": {
"minimist": "^1.2.8"
},
"author": {
"name": "cita-777",
"email": "cita-777@users.noreply.github.com",
"url": "https://github.com/cita-777"
}
}
+88 -98
View File
@@ -1,80 +1,75 @@
# 2026-05-15 开发日报 # 2026-05-15 技术报告 / Technical Report
## 概要 ## Executive Summary
今日完成两项主要工作:**Metapi → BoosAPI 全局改名** 和 **ComfyUI Agent 对话助手开发**。 本日完成 BoosAPI 品牌迁移、ComfyUI Agent 对话助手开发、Gitea CI/CD Runner 部署、Notification Service 能力梳理及部署文档重写。核心变更包括:将项目内 `Metapi` 全局替换为 `BoosAPI`,新增 `/comfyui-agent` 对话式 AI 视频生成入口,完成 SSE streaming 前后端链路,并完善生产部署文档与通知服务说明。
--- ---
## 一、Metapi → BoosAPI 全局改名 ## 1. Metapi → BoosAPI 全局改名 / Global Rename
### 变更范围 ### 1.1 变更范围 / Change Scope
| 区域 | 内容 | | 区域 / Area | 变更内容 / Changes |
|------|------| |---|---|
| UI 界面 | About 页面、侧边栏、登录页品牌名 | | UI 界面 / UI | About 页面、侧边栏、登录页品牌名更新为 `BoosAPI` |
| i18n 翻译 | 所有语言文件中的 "Metapi" → "BoosAPI" | | i18n 翻译 / Internationalization | 所有语言文件中的 `Metapi` 替换为 `BoosAPI` |
| 后端通知 | `notifyService.ts` 通知前缀 `[metapi]` → `[BoosAPI]` | | 后端通知 / Backend Notification | `notifyService.ts` 通知前缀由 `[metapi]` 改为 `[BoosAPI]` |
| 桌面元数据 | Electron/Docker 描述信息 | | 桌面元数据 / Desktop Metadata | Electron / Docker 描述信息更新 |
### 已全部改完的(此表仅作历史记录) ### 1.2 已完成的技术标识替换 / Completed Technical Identifier Changes
| 项 | 说明 | | 项目 / Item | 变更说明 / Description |
|---|------| |---|---|
| `x-metapi-*` HTTP 头 | 已改 `x-boosapi-*`,注意有外部依赖的需同步更新 | | HTTP Header | `x-metapi-*` → `x-boosapi-*` |
| `metapi:` 推理签名前缀 | 已改 `boosapi:` | | 推理签名前缀 / Inference Signature Prefix | `metapi:` → `boosapi:` |
| `__metapiTruncated` 等运行时属性 | 已改 `__boosapi*` | | 运行时属性 / Runtime Properties | `__metapiTruncated` 等 → `__boosapi*` |
| `METAPI_*` 环境变量 | 已改 `BOOSAPI_*`,部署配置需要同步 | | 环境变量 / Environment Variables | `METAPI_*` → `BOOSAPI_*` |
| `package.json` name | 已改 `boosapi` | | Package Name | `package.json` 中 `name` 更新为 `boosapi` |
### 验证 ### 1.3 验证方式 / Verification
```bash ```bash
# 确认没有遗漏的 metapi grep -rn '[mM]etapi' src/ --include='*.ts' --include='*.tsx' --include='*.html' \
grep -rn 'mM]etapi' src/ --include='*.ts' --include='*.tsx' --include='*.html' \
| grep -v '.test.' | grep -v node_modules | grep -iv 'boosapi' | grep -v '.test.' | grep -v node_modules | grep -iv 'boosapi'
# 应无输出
``` ```
预期结果:无输出。
--- ---
## 二、ComfyUI Agent 对话助手 ## 2. ComfyUI Agent 对话助手 / Conversational Assistant
### 功能 ### 2.1 功能概述 / Feature Overview
在侧边栏新增「对话助手」入口(`/comfyui-agent`),提供与 AI 视频生成助手的对话式交互界面。 侧边栏新增「对话助手」入口,路由为 `/comfyui-agent`,提供与 AI 视频生成助手的对话式交互,支持文本、角色提示词、图像、场景、语音和 ComfyUI workflow 等多类型内容的流式渲染。
### 技术架构 ### 2.2 技术架构 / Architecture
``` ```
用户 → POST /api/comfyui-agent/chat → BoosAPI → LLM 本地 API (streaming) 用户 / User → POST /api/comfyui-agent/chat → BoosAPI → LLM 本地 API (streaming)
↓ → SSE 事件流 → BoosAPI 转发 → 浏览器
LLM 生成 SSE 事件流
↓
BoosAPI 转发 SSE → 浏览器
``` ```
### 后端文件 ### 2.3 后端实现 / Backend
| 文件 | 作用 | | 文件 / File | 作用 / Responsibility |
|------|------| |---|---|
| `src/server/routes/api/comfyuiAgent.ts` | SSE 流式聊天接口,调用本地 LLM API | | `src/server/routes/api/comfyuiAgent.ts` | SSE 流式聊天接口,调用本地 LLM API |
| `src/server/services/boosapi_comfy_nodes.py` | ComfyUI 自定义节点,默认连接本地 BoosAPI `http://127.0.0.1:4000/v1` | | `src/server/services/boosapi_comfy_nodes.py` | ComfyUI 自定义节点,默认连接 `http://127.0.0.1:4000/v1` |
### 前端文件 ### 2.4 前端实现 / Frontend
| 文件 | 作用 | | 文件 / File | 作用 / Responsibility |
|------|------| |---|---|
| `src/web/pages/ComfyUIAgent.tsx` | 对话界面组件,SSE 流式渲染 | | `src/web/pages/ComfyUIAgent.tsx` | 对话界面组件,SSE 流式渲染 |
| `src/web/App.tsx` | 注册路由 `/comfyui-agent` + 侧边栏项 | | `src/web/App.tsx` | 注册 `/comfyui-agent` 路由及侧边栏入口 |
### Agent 输出格式(SSE 事件流) ### 2.5 SSE 事件类型 / SSE Event Types
LLM 直接生成 SSE 格式输出,后端透明转发: | `type` | 含义 / Meaning | 前端渲染 / Rendering |
|---|---|---|
| type | 含义 | 前端渲染 | | `text` | 流式文本 | 追加到对话内容 |
|------|------|---------|
| `text` | 流式文本 | 追加到对话 |
| `character_prompts` | 四角度角色提示词 | 角色卡片 | | `character_prompts` | 四角度角色提示词 | 角色卡片 |
| `image` | 生成的角色图像 | 内联图片 | | `image` | 生成的角色图像 | 内联图片 |
| `scene` | 场景提示词 | 场景块 | | `scene` | 场景提示词 | 场景块 |
@@ -82,93 +77,88 @@ LLM 直接生成 SSE 格式输出,后端透明转发:
| `comfyui_workflow` | 完整 ComfyUI 工作流 JSON | 下载按钮 | | `comfyui_workflow` | 完整 ComfyUI 工作流 JSON | 下载按钮 |
| `done` | 流结束 | 停止等待 | | `done` | 流结束 | 停止等待 |
### 验证结果 ### 2.6 验证结果 / Verification
- `npm install` ✅ | 命令 | 结果 |
- `npm run typecheck:web` ✅ 0 error |---|---|
| `npm install` | ✅ |
| `npm run typecheck:web` | ✅ 0 error |
--- ---
## 三、Gitea CI/CD Runner 部署 ## 3. Gitea CI/CD Runner 部署 / Runner Deployment
### 环境 ### 3.1 部署信息 / Deployment Info
- 系统:Alpine Linux (OpenRC) | 项目 | 详情 |
- 部署方式:裸机直跑(无 Docker in Docker) |---|---|
- Runner 版本:v0.1.3 | 系统 | Alpine Linux, OpenRC |
| 部署方式 | 裸机直跑,无 DinD |
| Runner 版本 | v0.1.3 |
| 注册模式 | `:host` 标签 |
| OpenRC 服务 | `/etc/init.d/gitea-runner` |
| 代理 | `HTTP_PROXY=http://127.0.0.1:7890` |
| `NO_PROXY` | 包含 `git.boos.lat` |
| 持久化 | `ephemeral: false` |
### 关键配置 ### 3.2 已知限制 / Known Limitations
- 注册模式:`:host` 标签(直接宿主机运行) - 不支持 Docker in Docker;CI 使用 `runs-on: host`
- OpenRC 服务:`/etc/init.d/gitea-runner` - 无原生 artifact 存储;构建产物需手动备份
- 代理:`HTTP_PROXY=http://127.0.0.1:7890`(在 init 脚本中声明) - `actions/checkout` 偶发 timeout;可重试或使用本地 git 镜像
- `NO_PROXY` 含 `git.boos.lat`(不经过代理连接 Gitea)
- 持久化:`ephemeral: false`(重启不丢注册)
### 已知限制
- 不支持 Docker in Docker(CI 用 `runs-on: host`)
- Gitea 无原生 artifact 存储,构建产物需手动备份
- `actions/checkout` 偶尔 timeout,可重试或使用本地 git 镜像
--- ---
## 四、Notification Service 通知服务 ## 4. Notification Service
### 支持通道 ### 4.1 支持通道 / Supported Channels
| 通道 | 配置前缀 | 协议 | | 通道 | 配置前缀 | 协议 |
|------|---------|------| |---|---|---|
| Telegram | `telegram*` | Bot API | | Telegram | `telegram*` | Bot API |
| 企业微信 | `wecom*` | Webhook | | 企业微信 | `wecom*` | Webhook |
| 飞书 | `feishu*` | Webhook | | 飞书 | `feishu*` | Webhook |
| 通用 Webhook | `webhook*` | JSON POST | | 通用 Webhook | `webhook*` | JSON POST |
| Bark | `bark*` | HTTP (iOS) | | Bark | `bark*` | HTTP, iOS |
| Server酱 | `serverchan*` | HTTP | | Server酱 | `serverchan*` | HTTP |
| SMTP | `smtp*` | Email | | SMTP | `smtp*` | Email |
### Telegram 消息格式 ### 4.2 Telegram 消息格式
```
[BoosAPI][INFO] 标题
消息内容
Level: info
Local Time: 2026-05-15 15:30:00 (Asia/Shanghai)
UTC Time: 2026-05-15T07:30:00.000Z
```
- 纯文本,不启用 HTML/Markdown 解析 - 纯文本,不启用 HTML/Markdown 解析
- 最大 3900 字符,超长截断加 `...(truncated)` - 单条最大 3900 字符,超长截断 + `...(truncated)`
- 支持 Telegram Topics(通过 `message_thread_id`) - 支持 Telegram Topics(`message_thread_id`)
### 节流机制 ### 4.3 节流机制 / Throttling
- 基于通知签名的全局冷静期(`notifyCooldownSec`) - 基于通知签名的全局冷静期:`notifyCooldownSec`
- 同一签名在冷静期内重复触发合并为一次 - 同一签名在冷静期内重复触发时合并为一次
- 冷静期是全局的,不是按通道独立 - 冷静期为全局共享,不按通道独立
## 五、部署文档完善 ---
对 `docs/deployment.md` 做了全量重写,修复所有 metapi 残留并补全内容: ## 5. 部署文档完善 / Deployment Documentation
- 完整的 `docker-compose.yml` 示例(含 healthcheck) Commit: `5bd847d`
- `.env` 文件变量插值模式
对 `docs/deployment.md` 全量重写,修复所有 `metapi` 残留:
- 完整 `docker-compose.yml` 示例(含 healthcheck)
- `.env` 变量插值模式
- 全部环境变量参考表(20+ 变量) - 全部环境变量参考表(20+ 变量)
- 扩展配置:外部 MySQL/PostgreSQL、Redis、资源限制 - 扩展配置:外部 MySQL/PostgreSQL、Redis、资源限制
- 生产部署检查清单 - 生产部署检查清单
- Nginx 反向代理(含 WebSocket 和 SSE 支持) - Nginx 反向代理(WebSocket + SSE)
- 管理后台 IP 白名单限制示例 - 管理后台 IP 白名单限制示例
- Docker run 常用选项表 - Docker run 常用选项表
- 升级/回滚操作流程 - 升级/回滚操作流程
提交:`5bd847d`
--- ---
## 待办 / 遗留问题 ## 6. 待办事项 / TODO
1. **服务未运行** — CI 崩溃后 `metapi-stack` 容器和 BoosAPI 服务未启动,需决定用 Docker 还是直接 npm run dev | 优先级 | 事项 | 说明 |
2. **Telegram 消息格式待优化** — 用户要求优化,具体方向待确认(HTML/Markdown/简化) |---|---|---|
3. **CI pipeline 不稳定** — Docker 镜像拉取超时,需进一步排查网络问题 | High | 服务未运行 | CI 崩溃后容器未恢复,需确定 Docker vs `npm run dev` |
| Medium | Telegram 格式优化 | 待确认 HTML/Markdown/简化 |
| Medium | CI pipeline 稳定性 | Docker 镜像拉取超时,需排查网络 |
-250
View File
@@ -1,250 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import ts from 'typescript';
const mode = process.argv.includes('--write') ? 'write' : 'dry-run';
const root = process.cwd();
const targetDir = path.join(root, 'src', 'server');
const METHOD_NAMES = new Set(['all', 'get', 'run']);
const FUNCTION_KINDS = new Set([
ts.SyntaxKind.FunctionDeclaration,
ts.SyntaxKind.FunctionExpression,
ts.SyntaxKind.ArrowFunction,
ts.SyntaxKind.MethodDeclaration,
ts.SyntaxKind.GetAccessor,
ts.SyntaxKind.SetAccessor,
]);
function listTsFiles(dir) {
const out = [];
const stack = [dir];
while (stack.length > 0) {
const current = stack.pop();
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(full);
continue;
}
if (entry.isFile() && full.endsWith('.ts')) {
out.push(full);
}
}
}
return out;
}
function isFunctionLike(node) {
return FUNCTION_KINDS.has(node.kind);
}
function hasAsyncModifier(node) {
return !!node.modifiers?.some((mod) => mod.kind === ts.SyntaxKind.AsyncKeyword);
}
function getFunctionTokenPos(node, sourceFile) {
if (node.kind === ts.SyntaxKind.ArrowFunction) {
return node.getStart(sourceFile);
}
if (node.kind === ts.SyntaxKind.MethodDeclaration || node.kind === ts.SyntaxKind.GetAccessor || node.kind === ts.SyntaxKind.SetAccessor) {
if (!node.name) return node.getStart(sourceFile);
return node.name.getStart(sourceFile);
}
let pos = node.getStart(sourceFile);
const children = node.getChildren(sourceFile);
for (const child of children) {
if (child.kind === ts.SyntaxKind.FunctionKeyword) {
pos = child.getStart(sourceFile);
break;
}
}
return pos;
}
function findNearestFunction(node) {
let current = node.parent;
while (current) {
if (isFunctionLike(current)) return current;
current = current.parent;
}
return null;
}
function unwrapExpression(node) {
if (!node) return node;
if (ts.isParenthesizedExpression(node)) return unwrapExpression(node.expression);
if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isSatisfiesExpression?.(node)) {
return unwrapExpression(node.expression);
}
return node;
}
function isDbIdentifier(node, queryVars) {
return ts.isIdentifier(node) && (node.text === 'db' || node.text === 'tx' || queryVars.has(node.text));
}
function dependsOnDb(node, queryVars) {
const expr = unwrapExpression(node);
if (!expr) return false;
if (isDbIdentifier(expr, queryVars)) return true;
if (ts.isPropertyAccessExpression(expr) || ts.isElementAccessExpression(expr)) {
return dependsOnDb(expr.expression, queryVars);
}
if (ts.isCallExpression(expr) || ts.isNewExpression(expr)) {
if (dependsOnDb(expr.expression, queryVars)) return true;
return expr.arguments?.some((arg) => dependsOnDb(arg, queryVars)) ?? false;
}
if (ts.isConditionalExpression(expr)) {
return dependsOnDb(expr.condition, queryVars)
|| dependsOnDb(expr.whenTrue, queryVars)
|| dependsOnDb(expr.whenFalse, queryVars);
}
if (ts.isBinaryExpression(expr)) {
return dependsOnDb(expr.left, queryVars) || dependsOnDb(expr.right, queryVars);
}
if (ts.isArrayLiteralExpression(expr)) {
return expr.elements.some((el) => dependsOnDb(el, queryVars));
}
if (ts.isObjectLiteralExpression(expr)) {
return expr.properties.some((prop) => {
if (ts.isPropertyAssignment(prop)) return dependsOnDb(prop.initializer, queryVars);
if (ts.isShorthandPropertyAssignment(prop)) return queryVars.has(prop.name.text);
if (ts.isSpreadAssignment(prop)) return dependsOnDb(prop.expression, queryVars);
return false;
});
}
return false;
}
function collectQueryVars(sourceFile) {
const queryVars = new Set();
let changed = true;
while (changed) {
changed = false;
function visit(node) {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
if (!queryVars.has(node.name.text) && dependsOnDb(node.initializer, queryVars)) {
queryVars.add(node.name.text);
changed = true;
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
}
return queryVars;
}
function shouldAwaitDbCall(callNode, queryVars) {
if (!ts.isPropertyAccessExpression(callNode.expression)) return false;
const method = callNode.expression.name.text;
if (!METHOD_NAMES.has(method)) return false;
if (ts.isAwaitExpression(callNode.parent)) return false;
const targetExpr = callNode.expression.expression;
return dependsOnDb(targetExpr, queryVars);
}
function buildEditsForFile(filePath) {
const text = fs.readFileSync(filePath, 'utf8');
const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const queryVars = collectQueryVars(sourceFile);
const awaitedCalls = [];
const asyncFunctions = new Map();
function visit(node) {
if (ts.isCallExpression(node) && shouldAwaitDbCall(node, queryVars)) {
awaitedCalls.push(node);
const fn = findNearestFunction(node);
if (fn && !hasAsyncModifier(fn)) {
asyncFunctions.set(fn.pos + ':' + fn.end, fn);
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
const edits = [];
for (const callNode of awaitedCalls) {
const parent = callNode.parent;
const callStart = callNode.getStart(sourceFile);
const callEnd = callNode.getEnd();
const original = text.slice(callStart, callEnd);
const needsParens = (
(ts.isPropertyAccessExpression(parent) || ts.isElementAccessExpression(parent))
&& parent.expression === callNode
);
const replacement = needsParens ? `(await ${original})` : `await ${original}`;
edits.push({ start: callStart, end: callEnd, text: replacement, type: 'await' });
}
for (const fn of asyncFunctions.values()) {
const insertPos = getFunctionTokenPos(fn, sourceFile);
edits.push({ start: insertPos, end: insertPos, text: 'async ', type: 'async' });
}
// De-duplicate same edit span/text
const uniq = new Map();
for (const edit of edits) {
const key = `${edit.start}:${edit.end}:${edit.text}`;
if (!uniq.has(key)) uniq.set(key, edit);
}
const finalEdits = [...uniq.values()].sort((a, b) => b.start - a.start || b.end - a.end);
if (finalEdits.length === 0) {
return { changed: false, text, nextText: text, awaitCount: 0, asyncCount: 0 };
}
let nextText = text;
for (const edit of finalEdits) {
nextText = nextText.slice(0, edit.start) + edit.text + nextText.slice(edit.end);
}
return {
changed: nextText !== text,
text,
nextText,
awaitCount: finalEdits.filter((e) => e.type === 'await').length,
asyncCount: finalEdits.filter((e) => e.type === 'async').length,
};
}
const files = listTsFiles(targetDir);
let changedFiles = 0;
let totalAwaitEdits = 0;
let totalAsyncEdits = 0;
const touched = [];
for (const file of files) {
const result = buildEditsForFile(file);
if (!result.changed) continue;
changedFiles += 1;
totalAwaitEdits += result.awaitCount;
totalAsyncEdits += result.asyncCount;
touched.push(path.relative(root, file));
if (mode === 'write') {
fs.writeFileSync(file, result.nextText, 'utf8');
}
}
console.log(JSON.stringify({
mode,
changedFiles,
totalAwaitEdits,
totalAsyncEdits,
sampleFiles: touched.slice(0, 20),
}, null, 2));
@@ -1,95 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import ts from 'typescript';
const mode = process.argv.includes('--write') ? 'write' : 'dry-run';
const root = process.cwd();
const targetDir = path.join(root, 'src', 'server');
const FUNCTION_KINDS = new Set([
ts.SyntaxKind.FunctionDeclaration,
ts.SyntaxKind.FunctionExpression,
ts.SyntaxKind.ArrowFunction,
ts.SyntaxKind.MethodDeclaration,
]);
function listTsFiles(dir) {
const out = [];
const stack = [dir];
while (stack.length > 0) {
const current = stack.pop();
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(full);
continue;
}
if (entry.isFile() && full.endsWith('.ts')) {
out.push(full);
}
}
}
return out;
}
function hasAsyncModifier(node) {
return !!node.modifiers?.some((mod) => mod.kind === ts.SyntaxKind.AsyncKeyword);
}
function isPromiseTypeNode(typeNode) {
if (!typeNode) return false;
if (!ts.isTypeReferenceNode(typeNode)) return false;
const typeName = typeNode.typeName;
return ts.isIdentifier(typeName) && typeName.text === 'Promise';
}
function applyEdits(text, edits) {
const sorted = [...edits].sort((a, b) => b.start - a.start || b.end - a.end);
let next = text;
for (const edit of sorted) {
next = next.slice(0, edit.start) + edit.text + next.slice(edit.end);
}
return next;
}
const files = listTsFiles(targetDir);
let changedFiles = 0;
let editsCount = 0;
const touched = [];
for (const filePath of files) {
const text = fs.readFileSync(filePath, 'utf8');
const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const edits = [];
function visit(node) {
if (FUNCTION_KINDS.has(node.kind) && hasAsyncModifier(node) && node.type && !isPromiseTypeNode(node.type)) {
const start = node.type.getStart(sourceFile);
const end = node.type.getEnd();
const original = text.slice(start, end);
edits.push({ start, end, text: `Promise<${original}>` });
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
if (edits.length === 0) continue;
const next = applyEdits(text, edits);
if (next === text) continue;
changedFiles += 1;
editsCount += edits.length;
touched.push(path.relative(root, filePath));
if (mode === 'write') {
fs.writeFileSync(filePath, next, 'utf8');
}
}
console.log(JSON.stringify({
mode,
changedFiles,
editsCount,
sampleFiles: touched.slice(0, 20),
}, null, 2));
-90
View File
@@ -1,90 +0,0 @@
#!/usr/bin/env node
/**
* Install ComfyUI locally for development
* Clones repo + sets up Python venv + installs deps
*/
import { execSync } from 'child_process';
import { existsSync, mkdirSync, copyFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = resolve(__dirname, '../..');
const COMFYUI_DIR = resolve(PROJECT_ROOT, '.comfyui');
async function run() {
console.log('[comfyui] Installing ComfyUI for local development...');
console.log(`[comfyui] Target: ${COMFYUI_DIR}`);
if (existsSync(COMFYUI_DIR)) {
console.log('[comfyui] ComfyUI already installed. Pulling latest...');
execSync('git pull', { cwd: COMFYUI_DIR, stdio: 'inherit' });
} else {
console.log('[comfyui] Cloning ComfyUI...');
execSync(
'git clone https://github.com/comfyanonymous/ComfyUI.git .comfyui',
{ cwd: PROJECT_ROOT, stdio: 'inherit' },
);
}
// Check Python
try {
execSync('python3 --version', { stdio: 'pipe' });
} catch {
console.error('[comfyui] ❌ python3 not found. Please install Python 3.10+.');
process.exit(1);
}
// Setup venv
const venvDir = resolve(COMFYUI_DIR, 'venv');
if (!existsSync(venvDir)) {
console.log('[comfyui] Creating Python venv...');
execSync('python3 -m venv venv', { cwd: COMFYUI_DIR, stdio: 'inherit' });
}
// Install deps (CPU-only — all generation goes through BoosAPI)
const pip = process.platform === 'win32'
? resolve(venvDir, 'Scripts', 'pip')
: resolve(venvDir, 'bin', 'pip');
const python = process.platform === 'win32'
? resolve(venvDir, 'Scripts', 'python')
: resolve(venvDir, 'bin', 'python');
console.log('[comfyui] Upgrading pip...');
execSync(`"${pip}" install --upgrade pip`, { cwd: COMFYUI_DIR, stdio: 'inherit' });
console.log('[comfyui] Installing PyTorch (CPU)...');
execSync(
`"${pip}" install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu`,
{ cwd: COMFYUI_DIR, stdio: 'inherit' },
);
console.log('[comfyui] Installing ComfyUI dependencies...');
execSync(
`grep -v kornia_rs requirements.txt | "${pip}" install -r /dev/stdin`,
{ cwd: COMFYUI_DIR, stdio: 'inherit' },
);
// Install BoosAPI custom nodes
const customNodesDir = resolve(COMFYUI_DIR, 'custom_nodes');
mkdirSync(customNodesDir, { recursive: true });
const customNodeSrc = resolve(PROJECT_ROOT, 'boosapi_comfy_nodes.py');
const customNodeDst = resolve(customNodesDir, 'boosapi_comfy_nodes.py');
if (existsSync(customNodeSrc)) {
copyFileSync(customNodeSrc, customNodeDst);
console.log('[comfyui] ✅ BoosAPI custom nodes installed');
}
console.log('');
console.log('[comfyui] ✅ ComfyUI installed!');
console.log(`[comfyui] Directory: ${COMFYUI_DIR}`);
console.log('[comfyui] Start with: npm run comfyui:start');
console.log('[comfyui] Or set COMFYUI_BASE env to point elsewhere');
}
run().catch((err) => {
console.error('[comfyui] ❌ Install failed:', err.message);
process.exit(1);
});
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env node
/**
* Start local ComfyUI (managed subprocess)
*/
import { spawn } from 'child_process';
import { existsSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const PROJECT_ROOT = resolve(__dirname, '../..');
const COMFYUI_DIR = resolve(PROJECT_ROOT, '.comfyui');
function getPythonPath() {
const venvBin = resolve(COMFYUI_DIR, 'venv', 'bin');
const venvScripts = resolve(COMFYUI_DIR, 'venv', 'Scripts');
if (existsSync(resolve(venvBin, 'python3'))) return resolve(venvBin, 'python3');
if (existsSync(resolve(venvBin, 'python'))) return resolve(venvBin, 'python');
if (existsSync(resolve(venvScripts, 'python.exe'))) return resolve(venvScripts, 'python.exe');
return 'python3';
}
async function run() {
if (!existsSync(COMFYUI_DIR)) {
console.error('[comfyui] ❌ ComfyUI not installed. Run: npm run comfyui:install');
process.exit(1);
}
const python = getPythonPath();
const port = process.env.COMFYUI_PORT || '8188';
// Check if already running
try {
const resp = await fetch(`http://127.0.0.1:${port}/`);
if (resp.ok) {
console.log(`[comfyui] ✅ ComfyUI already running on port ${port}`);
process.exit(0);
}
} catch {
// Not running, start it
}
console.log(`[comfyui] Starting ComfyUI on port ${port}...`);
console.log(`[comfyui] Python: ${python}`);
console.log(`[comfyui] Dir: ${COMFYUI_DIR}`);
const proc = spawn(python, ['main.py', '--listen', '127.0.0.1', '--port', port, '--cpu'], {
cwd: COMFYUI_DIR,
stdio: 'inherit',
env: {
...process.env,
BOOSAPI_BASE: process.env.BOOSAPI_BASE || 'http://127.0.0.1:4000/v1',
BOOSAPI_KEY: process.env.BOOSAPI_KEY || '',
},
});
proc.on('exit', (code) => {
if (code !== 0 && code !== null) {
console.error(`[comfyui] ❌ Exited with code ${code}`);
}
});
// Graceful shutdown
process.on('SIGINT', () => { proc.kill(); process.exit(); });
process.on('SIGTERM', () => { proc.kill(); process.exit(); });
}
run().catch((err) => {
console.error('[comfyui] ❌ Start failed:', err.message);
process.exit(1);
});
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env node
/**
* Stop local ComfyUI by PID
*/
import { readFileSync, existsSync, unlinkSync } from 'fs';
import { execSync } from 'child_process';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const COMFYUI_DIR = resolve(__dirname, '../..', '.comfyui');
const PID_FILE = resolve(COMFYUI_DIR, 'comfyui.pid');
// Try PID file first
if (existsSync(PID_FILE)) {
try {
const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10);
process.kill(pid, 'SIGTERM');
console.log(`[comfyui] Stopped PID ${pid}`);
unlinkSync(PID_FILE);
process.exit(0);
} catch (e) {
if (e.code === 'ESRCH') {
console.log('[comfyui] Process already stopped');
unlinkSync(PID_FILE);
process.exit(0);
}
// Fall through to pkill
}
}
// Fallback: kill by process name
try {
execSync('pkill -f "python.*main.py.*--listen.*8188"', { stdio: 'pipe' });
console.log('[comfyui] Stopped ComfyUI process');
} catch {
console.log('[comfyui] No running ComfyUI process found');
}
-7
View File
@@ -1,7 +0,0 @@
import { ensureAdHocSignature, getMacAppPath } from './macosSignUtils.mjs';
export default async function afterPack(context) {
if (context.electronPlatformName !== 'darwin') return;
await ensureAdHocSignature(getMacAppPath(context));
}
-30
View File
@@ -1,30 +0,0 @@
import { notarize } from '@electron/notarize';
import { ensureAdHocSignature, getMacAppPath } from './macosSignUtils.mjs';
export default async function afterSign(context) {
if (context.electronPlatformName !== 'darwin') return;
const appPath = getMacAppPath(context);
const appleId = process.env.APPLE_ID || process.env.APPLEID;
const appleIdPassword = process.env.APPLE_APP_SPECIFIC_PASSWORD || process.env.APPLEIDPASS;
const teamId = process.env.APPLE_TEAM_ID;
const signature = await ensureAdHocSignature(appPath);
if (!appleId || !appleIdPassword || !teamId) {
console.log('[metapi-desktop] Skipping notarization: APPLE_ID / APPLE_APP_SPECIFIC_PASSWORD / APPLE_TEAM_ID not fully configured.');
return;
}
if (signature.isAdHoc) {
console.log('[metapi-desktop] Skipping notarization: macOS app is only ad-hoc signed. Configure a Developer ID certificate to enable notarization.');
return;
}
console.log('[metapi-desktop] Notarizing macOS build...');
await notarize({
appPath,
appleId,
appleIdPassword,
teamId,
});
}
-126
View File
@@ -1,126 +0,0 @@
import { mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import sharp from 'sharp';
export const DESKTOP_ICON_SIZE = 512;
export const DESKTOP_ICON_PADDING = 40;
export const DESKTOP_ICON_RADIUS = 96;
export const DESKTOP_TRAY_TEMPLATE_PADDING = 152;
function createRoundedMask(size, cornerRadius) {
return Buffer.from(
`<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg"><rect width="${size}" height="${size}" rx="${cornerRadius}" ry="${cornerRadius}" fill="#fff"/></svg>`,
);
}
async function renderDesktopIconBuffer({
sourcePath,
size = DESKTOP_ICON_SIZE,
padding = DESKTOP_ICON_PADDING,
cornerRadius = DESKTOP_ICON_RADIUS,
}) {
const innerSize = size - padding * 2;
const roundedMask = createRoundedMask(innerSize, Math.min(cornerRadius, Math.floor(innerSize / 2)));
const roundedInner = await sharp(sourcePath)
.resize(innerSize, innerSize, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.composite([{ input: roundedMask, blend: 'dest-in' }])
.png()
.toBuffer();
return sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite([{ input: roundedInner, left: padding, top: padding }])
.png()
.toBuffer();
}
async function renderTrayTemplateIconBuffer({
sourcePath,
size = DESKTOP_ICON_SIZE,
padding = DESKTOP_TRAY_TEMPLATE_PADDING,
}) {
const innerSize = Math.max(1, size - padding * 2);
const alphaMask = await sharp(sourcePath)
.resize(innerSize, innerSize, {
fit: 'contain',
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.ensureAlpha()
.extractChannel('alpha')
.png()
.toBuffer();
return sharp({
create: {
width: size,
height: size,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 1 },
},
})
.composite([{ input: alphaMask, left: padding, top: padding, blend: 'dest-in' }])
.png()
.toBuffer();
}
export async function generateDesktopIconAssets({
sourcePath = join(process.cwd(), 'src', 'web', 'public', 'logo.png'),
buildOutputPath = join(process.cwd(), 'build', 'desktop-icon.png'),
webOutputPath = join(process.cwd(), 'src', 'web', 'public', 'desktop-icon.png'),
trayTemplateOutputPath = join(process.cwd(), 'src', 'web', 'public', 'desktop-tray-template.png'),
size = DESKTOP_ICON_SIZE,
padding = DESKTOP_ICON_PADDING,
cornerRadius = DESKTOP_ICON_RADIUS,
} = {}) {
const [outputBuffer, trayTemplateBuffer] = await Promise.all([
renderDesktopIconBuffer({
sourcePath,
size,
padding,
cornerRadius,
}),
renderTrayTemplateIconBuffer({
sourcePath,
size,
}),
]);
await Promise.all([
mkdir(dirname(buildOutputPath), { recursive: true }),
mkdir(dirname(webOutputPath), { recursive: true }),
mkdir(dirname(trayTemplateOutputPath), { recursive: true }),
]);
await Promise.all([
sharp(outputBuffer).toFile(buildOutputPath),
sharp(outputBuffer).toFile(webOutputPath),
sharp(trayTemplateBuffer).toFile(trayTemplateOutputPath),
]);
return {
buildOutputPath,
webOutputPath,
trayTemplateOutputPath,
};
}
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isDirectRun) {
const outputs = await generateDesktopIconAssets();
console.log(`[metapi-desktop] Generated desktop icons:
- ${outputs.buildOutputPath}
- ${outputs.webOutputPath}
- ${outputs.trayTemplateOutputPath}`);
}
-86
View File
@@ -1,86 +0,0 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import sharp from 'sharp';
import { afterEach, describe, expect, it } from 'vitest';
const tempDirs: string[] = [];
async function makeTempDir() {
const dir = await mkdtemp(join(tmpdir(), 'metapi-desktop-icons-'));
tempDirs.push(dir);
return dir;
}
function alphaAt(buffer: Buffer, width: number, x: number, y: number) {
return buffer[(y * width + x) * 4 + 3];
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe('generateDesktopIconAssets', () => {
it('writes rounded desktop icon outputs and a tray template asset', async () => {
const { generateDesktopIconAssets } = await import('./generate-icons.mjs');
const dir = await makeTempDir();
const sourcePath = join(dir, 'logo.png');
const buildOutputPath = join(dir, 'build.png');
const webOutputPath = join(dir, 'desktop-icon.png');
const trayTemplateOutputPath = join(dir, 'desktop-tray-template.png');
await sharp({
create: {
width: 512,
height: 512,
channels: 4,
background: { r: 255, g: 112, b: 48, alpha: 1 },
},
}).png().toFile(sourcePath);
await generateDesktopIconAssets({
sourcePath,
buildOutputPath,
webOutputPath,
trayTemplateOutputPath,
});
const buildMeta = await sharp(buildOutputPath).metadata();
const webMeta = await sharp(webOutputPath).metadata();
const trayMeta = await sharp(trayTemplateOutputPath).metadata();
expect(buildMeta.width).toBe(512);
expect(buildMeta.height).toBe(512);
expect(webMeta.width).toBe(512);
expect(webMeta.height).toBe(512);
expect(trayMeta.width).toBe(512);
expect(trayMeta.height).toBe(512);
const { data, info } = await sharp(buildOutputPath)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
expect(alphaAt(data, info.width, 0, 0)).toBe(0);
expect(alphaAt(data, info.width, 12, 12)).toBe(0);
expect(alphaAt(data, info.width, Math.floor(info.width / 2), 6)).toBe(0);
expect(alphaAt(data, info.width, Math.floor(info.width / 2), Math.floor(info.height / 2))).toBe(255);
expect(await sharp(buildOutputPath).png().toBuffer())
.toEqual(await sharp(webOutputPath).png().toBuffer());
const trayBuffer = await sharp(trayTemplateOutputPath)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
expect(alphaAt(trayBuffer.data, trayBuffer.info.width, 0, 0)).toBe(0);
expect(alphaAt(trayBuffer.data, trayBuffer.info.width, Math.floor(trayBuffer.info.width / 2), Math.floor(trayBuffer.info.height / 2))).toBe(255);
const trayCenterOffset = (Math.floor(trayBuffer.info.height / 2) * trayBuffer.info.width + Math.floor(trayBuffer.info.width / 2)) * 4;
expect(trayBuffer.data[trayCenterOffset]).toBe(0);
expect(trayBuffer.data[trayCenterOffset + 1]).toBe(0);
expect(trayBuffer.data[trayCenterOffset + 2]).toBe(0);
});
});
-41
View File
@@ -1,41 +0,0 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const execFileAsync = promisify(execFile);
export function getMacAppPath(context) {
return `${context.appOutDir}/${context.packager.appInfo.productFilename}.app`;
}
export async function inspectSignature(appPath) {
try {
const { stdout, stderr } = await execFileAsync('codesign', ['-dv', '--verbose=4', appPath]);
const output = `${stdout}\n${stderr}`;
return {
signed: true,
isAdHoc: /Signature=adhoc/i.test(output) || !/Authority=/i.test(output),
details: output,
};
} catch {
return {
signed: false,
isAdHoc: false,
details: '',
};
}
}
export async function adHocSign(appPath) {
await execFileAsync('codesign', ['--force', '--deep', '--sign', '-', appPath]);
}
export async function ensureAdHocSignature(appPath) {
const signature = await inspectSignature(appPath);
if (signature.signed) {
return signature;
}
console.log('[metapi-desktop] No macOS signing identity detected, applying ad-hoc signature.');
await adHocSign(appPath);
return inspectSignature(appPath);
}
-26
View File
@@ -1,26 +0,0 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('desktop package scripts', () => {
it('rebuilds the app before package:desktop targets invoke electron-builder', () => {
const packageJson = JSON.parse(
readFileSync(resolve(process.cwd(), 'package.json'), 'utf8'),
) as { scripts?: Record<string, string> };
const scripts = packageJson.scripts || {};
const packageDesktop = scripts['package:desktop'] || '';
const packageDesktopIntel = scripts['package:desktop:mac:intel'] || '';
const distDesktop = scripts['dist:desktop'] || '';
const distDesktopIntel = scripts['dist:desktop:mac:intel'] || '';
expect(
packageDesktop === 'npm run dist:desktop' || packageDesktop.includes('npm run build'),
).toBe(true);
expect(
packageDesktopIntel === 'npm run dist:desktop:mac:intel' || packageDesktopIntel.includes('npm run build'),
).toBe(true);
expect(distDesktop).toContain('npm run build');
expect(distDesktopIntel).toContain('npm run build');
});
});
-31
View File
@@ -1,31 +0,0 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('release workflow', () => {
it('builds macOS arm64 and x64 on dedicated runners and verifies packaged app architecture', () => {
const workflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8');
expect(workflow).toContain('runner: macos-15-intel');
expect(workflow).toContain('runner: macos-15');
expect(workflow).toContain('expectedMacArch: x64');
expect(workflow).toContain('expectedMacArch: arm64');
expect(workflow).toContain('Verify packaged macOS architecture');
expect(workflow).toContain('node scripts/desktop/verifyMacArchitecture.mjs');
});
it('uploads Linux desktop artifacts for AppImage, deb, and rpm packages', () => {
const workflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8');
expect(workflow).toContain('release/*.AppImage');
expect(workflow).toContain('release/*.deb');
expect(workflow).toContain('release/*.rpm');
});
it('installs rpm tooling on Linux runners before packaging Fedora desktop artifacts', () => {
const workflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8');
expect(workflow).toContain("if: runner.os == 'Linux'");
expect(workflow).toContain('sudo apt-get install --no-install-recommends -y rpm');
});
});
-122
View File
@@ -1,122 +0,0 @@
import { execFileSync } from 'node:child_process';
import { readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const MAC_BINARY_SEGMENTS = ['Metapi.app', 'Contents', 'MacOS', 'Metapi'];
function walkDirectories(rootDir) {
const queue = [rootDir];
const files = [];
while (queue.length > 0) {
const currentDir = queue.shift();
if (!currentDir) {
continue;
}
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
const entryPath = join(currentDir, entry.name);
if (entry.isDirectory()) {
queue.push(entryPath);
continue;
}
if (entry.isFile()) {
files.push(entryPath);
}
}
}
return files;
}
export function findPackagedMacBinaries(releaseDir) {
const normalizedSuffix = MAC_BINARY_SEGMENTS.join('/');
return walkDirectories(releaseDir).filter((filePath) =>
filePath.replaceAll('\\', '/').endsWith(normalizedSuffix),
);
}
export function normalizeExpectedMacArch(expectedArch) {
if (expectedArch === 'x64') {
return 'x86_64';
}
if (expectedArch === 'arm64') {
return 'arm64';
}
throw new Error(`Unsupported expected mac architecture: ${expectedArch}`);
}
export function inspectBinaryArchsWithLipo(binaryPath) {
try {
return execFileSync('lipo', ['-archs', binaryPath], { encoding: 'utf8' }).trim();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Unable to detect architecture via lipo for ${binaryPath}: ${message}`);
}
}
export function verifyMacArchitecture({
releaseDir,
expectedArch,
inspectBinaryArchs = inspectBinaryArchsWithLipo,
}) {
const binaries = findPackagedMacBinaries(releaseDir);
if (binaries.length === 0) {
throw new Error(`No packaged macOS app binary found under ${releaseDir}`);
}
const expectedBinaryArch = normalizeExpectedMacArch(expectedArch);
return binaries.map((binaryPath) => {
const archs = inspectBinaryArchs(binaryPath).trim().replace(/\s+/g, ' ');
if (!archs) {
throw new Error(`Unable to detect architecture via lipo for ${binaryPath}`);
}
if (archs !== expectedBinaryArch) {
throw new Error(`Expected ${expectedBinaryArch}-only binary but got: ${archs}`);
}
return { binaryPath, archs };
});
}
function parseArgValue(args, name) {
const index = args.indexOf(name);
if (index === -1) {
return undefined;
}
return args[index + 1];
}
function main() {
const args = process.argv.slice(2);
const releaseDirArg = parseArgValue(args, '--release-dir');
const expectedArch = parseArgValue(args, '--expected-arch');
if (!releaseDirArg || !expectedArch) {
throw new Error('Usage: node scripts/desktop/verifyMacArchitecture.mjs --release-dir <dir> --expected-arch <x64|arm64>');
}
const releaseDir = resolve(releaseDirArg);
const verifiedBinaries = verifyMacArchitecture({ releaseDir, expectedArch });
for (const { binaryPath, archs } of verifiedBinaries) {
console.log(`${binaryPath} => ${archs}`);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
@@ -1,54 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import { verifyMacArchitecture } from './verifyMacArchitecture.mjs';
const tempDirs: string[] = [];
function createReleaseBinary() {
const releaseDir = mkdtempSync(join(tmpdir(), 'metapi-mac-arch-'));
tempDirs.push(releaseDir);
const binaryPath = join(releaseDir, 'mac-x64', 'Metapi.app', 'Contents', 'MacOS', 'Metapi');
mkdirSync(dirname(binaryPath), { recursive: true });
writeFileSync(binaryPath, 'binary');
return { releaseDir, binaryPath };
}
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});
describe('verifyMacArchitecture', () => {
it('accepts x64 binaries when lipo reports x86_64', () => {
const { releaseDir, binaryPath } = createReleaseBinary();
expect(
verifyMacArchitecture({
releaseDir,
expectedArch: 'x64',
inspectBinaryArchs: () => 'x86_64',
}),
).toEqual([{ archs: 'x86_64', binaryPath }]);
});
it('rejects x64 binaries when lipo reports arm64', () => {
const { releaseDir } = createReleaseBinary();
expect(() =>
verifyMacArchitecture({
releaseDir,
expectedArch: 'x64',
inspectBinaryArchs: () => 'arm64',
}),
).toThrow(/Expected x86_64-only binary but got: arm64/);
});
});
@@ -1,55 +0,0 @@
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { afterEach, describe, expect, it } from 'vitest';
import { copyRuntimeDbGeneratedAssets } from './copy-runtime-db-generated.js';
const tempDirs: string[] = [];
afterEach(() => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
rmSync(dir, { recursive: true, force: true });
}
}
});
describe('copyRuntimeDbGeneratedAssets', () => {
it('copies generated runtime db artifacts and shared runtime modules into dist', () => {
const repoRoot = mkdtempSync(join(tmpdir(), 'metapi-runtime-db-assets-'));
tempDirs.push(repoRoot);
const sourceDir = join(repoRoot, 'src', 'server', 'db', 'generated');
const nestedDir = join(sourceDir, 'fixtures');
const sharedDir = join(repoRoot, 'src', 'shared');
const staleSharedDistDir = join(repoRoot, 'dist', 'shared');
mkdirSync(nestedDir, { recursive: true });
mkdirSync(sharedDir, { recursive: true });
mkdirSync(staleSharedDistDir, { recursive: true });
writeFileSync(join(sourceDir, 'mysql.bootstrap.sql'), 'create table demo ();');
writeFileSync(join(nestedDir, 'baseline.json'), '{"ok":true}');
writeFileSync(join(sharedDir, 'tokenRouteContract.d.ts'), 'export declare const demo: number;\n');
writeFileSync(join(sharedDir, 'tokenRouteContract.js'), 'export const demo = 1;\n');
writeFileSync(join(sharedDir, 'tokenRouteContract.test.ts'), 'throw new Error("should not ship");\n');
writeFileSync(join(staleSharedDistDir, 'tokenRouteContract.test.ts'), 'stale test artifact\n');
copyRuntimeDbGeneratedAssets(repoRoot);
expect(
readFileSync(join(repoRoot, 'dist', 'server', 'db', 'generated', 'mysql.bootstrap.sql'), 'utf8'),
).toBe('create table demo ();');
expect(
readFileSync(join(repoRoot, 'dist', 'server', 'db', 'generated', 'fixtures', 'baseline.json'), 'utf8'),
).toBe('{"ok":true}');
expect(
readFileSync(join(repoRoot, 'dist', 'shared', 'tokenRouteContract.js'), 'utf8'),
).toBe('export const demo = 1;\n');
expect(
readFileSync(join(repoRoot, 'dist', 'shared', 'tokenRouteContract.d.ts'), 'utf8'),
).toBe('export declare const demo: number;\n');
expect(
existsSync(join(repoRoot, 'dist', 'shared', 'tokenRouteContract.test.ts')),
).toBe(false);
});
});
-41
View File
@@ -1,41 +0,0 @@
import { cpSync, existsSync, mkdirSync, rmSync, statSync } from 'node:fs';
import { basename, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
function resolveRepoRoot(): string {
return resolve(dirname(fileURLToPath(import.meta.url)), '../..');
}
function shouldCopySharedArtifact(sourcePath: string): boolean {
if (statSync(sourcePath).isDirectory()) return true;
const filename = basename(sourcePath);
return filename.endsWith('.js') || filename.endsWith('.d.ts');
}
export function copyRuntimeDbGeneratedAssets(repoRoot: string = resolveRepoRoot()): void {
const sourceDir = resolve(repoRoot, 'src/server/db/generated');
const targetDir = resolve(repoRoot, 'dist/server/db/generated');
const sharedSourceDir = resolve(repoRoot, 'src/shared');
const sharedTargetDir = resolve(repoRoot, 'dist/shared');
if (!existsSync(sourceDir)) {
throw new Error(`Runtime DB generated assets directory does not exist: ${sourceDir}`);
}
mkdirSync(dirname(targetDir), { recursive: true });
cpSync(sourceDir, targetDir, { recursive: true, force: true });
if (existsSync(sharedSourceDir)) {
rmSync(sharedTargetDir, { recursive: true, force: true });
mkdirSync(dirname(sharedTargetDir), { recursive: true });
cpSync(sharedSourceDir, sharedTargetDir, {
recursive: true,
force: true,
filter: (sourcePath) => shouldCopySharedArtifact(sourcePath),
});
}
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
copyRuntimeDbGeneratedAssets();
}
-124
View File
@@ -1,124 +0,0 @@
import { sql } from 'drizzle-orm';
type DbType = 'sqlite' | 'mysql' | 'postgres';
type CliOptions = {
dbType?: string;
dbUrl?: string;
};
function parseArgs(argv: string[]): CliOptions {
const options: CliOptions = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i] || '';
if (!arg.startsWith('--')) continue;
const [rawKey, rawValue] = arg.slice(2).split('=', 2);
const key = rawKey.trim();
const value = rawValue !== undefined ? rawValue : (argv[i + 1]?.startsWith('--') ? '' : argv[++i] || '');
if (key === 'db-type') options.dbType = value.trim();
if (key === 'db-url') options.dbUrl = value.trim();
}
return options;
}
function normalizeDbType(input: string | undefined): DbType {
const normalized = (input || '').trim().toLowerCase();
if (normalized === 'mysql') return 'mysql';
if (normalized === 'postgres' || normalized === 'postgresql') return 'postgres';
return 'sqlite';
}
function normalizeFirstScalar(value: unknown): number | string | null {
if (value === undefined || value === null) return null;
if (typeof value === 'number' || typeof value === 'string') return value;
if (Array.isArray(value)) {
if (value.length === 0) return null;
return normalizeFirstScalar(value[0]);
}
if (typeof value === 'object') {
const record = value as Record<string, unknown>;
if (Array.isArray(record.rows)) {
return normalizeFirstScalar(record.rows);
}
const scalar = Object.values(record)[0];
return normalizeFirstScalar(scalar);
}
return null;
}
async function selectScalar(db: any, query: ReturnType<typeof sql>): Promise<number | string | null> {
if (typeof db?.get === 'function') {
return normalizeFirstScalar(await db.get(query));
}
if (typeof db?.all === 'function') {
return normalizeFirstScalar(await db.all(query));
}
if (typeof db?.values === 'function') {
return normalizeFirstScalar(await db.values(query));
}
if (typeof db?.execute === 'function') {
return normalizeFirstScalar(await db.execute(query));
}
throw new Error('database client does not expose a scalar query method');
}
async function main() {
const options = parseArgs(process.argv.slice(2));
if (options.dbType) process.env.DB_TYPE = options.dbType;
if (options.dbUrl !== undefined) process.env.DB_URL = options.dbUrl;
const { config } = await import('../../src/server/config.js');
const { db, runtimeDbDialect, closeDbConnections } = await import('../../src/server/db/index.js');
try {
const dbType = normalizeDbType(config.dbType || options.dbType);
const dbUrl = (config.dbUrl || '').trim();
if ((dbType === 'mysql' || dbType === 'postgres') && !dbUrl) {
throw new Error(`DB_URL is required for DB_TYPE=${dbType}`);
}
console.log(`[db-smoke] start dbType=${dbType} runtime=${runtimeDbDialect}`);
if (dbUrl) {
console.log(`[db-smoke] dbUrl=${dbUrl}`);
} else {
console.log('[db-smoke] dbUrl=(empty, using default sqlite path)');
}
const pingScalar = await selectScalar(db, sql`select 1 as ok`);
if (Number(pingScalar) !== 1) {
throw new Error(`unexpected ping result: ${JSON.stringify(pingScalar)}`);
}
console.log('[db-smoke] ping ok');
const txScalar = await db.transaction(async (tx: any) => selectScalar(tx, sql`select 1 as ok`));
if (Number(txScalar) !== 1) {
throw new Error(`unexpected transaction ping result: ${JSON.stringify(txScalar)}`);
}
console.log('[db-smoke] transaction ok');
const version = dbType === 'sqlite'
? await selectScalar(db, sql`select sqlite_version() as v`)
: await selectScalar(db, sql`select version() as v`);
if (typeof version !== 'string' || version.trim().length === 0) {
throw new Error(`failed to read server version: ${JSON.stringify(version)}`);
}
console.log(`[db-smoke] version=${version.slice(0, 120)}`);
console.log('[db-smoke] success');
} finally {
await closeDbConnections();
}
}
main().catch((error) => {
console.error('[db-smoke] failed');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
-53
View File
@@ -1,53 +0,0 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('docker workflows', () => {
it('publishes armv7 docker images in ci and release workflows', () => {
const ciWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/ci.yml'), 'utf8');
const releaseWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8');
expect(ciWorkflow).toContain('arch: armv7');
expect(ciWorkflow).toContain('platform: linux/arm/v7');
expect(ciWorkflow).toContain('"${tag}-armv7"');
expect(releaseWorkflow).toContain('arch: armv7');
expect(releaseWorkflow).toContain('platform: linux/arm/v7');
expect(releaseWorkflow).toContain('"${tag}-armv7"');
});
it('derives Docker Hub image names from the configured username secret', () => {
const ciWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/ci.yml'), 'utf8');
const releaseWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/release.yml'), 'utf8');
expect(ciWorkflow).toContain('DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi');
expect(ciWorkflow).not.toContain('images: 1467078763/metapi');
expect(releaseWorkflow).toContain('DOCKERHUB_IMAGE: ${{ secrets.DOCKERHUB_USERNAME }}/metapi');
expect(releaseWorkflow).not.toContain('1467078763/metapi');
});
it('uses an armv7-capable node base image in the Dockerfile', () => {
const dockerfile = readFileSync(resolve(process.cwd(), 'docker/Dockerfile'), 'utf8');
expect(dockerfile).toContain('FROM node:22-bookworm-slim AS builder');
expect(dockerfile).toContain('FROM node:22-bookworm-slim');
});
it('avoids buildkit-only frontend syntax so managed docker builders can parse it reliably', () => {
const dockerfile = readFileSync(resolve(process.cwd(), 'docker/Dockerfile'), 'utf8');
expect(dockerfile).not.toContain('# syntax=docker/dockerfile:');
expect(dockerfile).not.toContain('RUN --mount=type=cache');
});
it('keeps server docker builds isolated from desktop packaging dependencies', () => {
const dockerfile = readFileSync(resolve(process.cwd(), 'docker/Dockerfile'), 'utf8');
expect(dockerfile).toContain('npm ci --ignore-scripts --no-audit --no-fund');
expect(dockerfile).toContain('npm rebuild esbuild sharp better-sqlite3 --no-audit --no-fund');
expect(dockerfile).not.toContain('npm ci --no-audit --no-fund');
expect(dockerfile).toContain('RUN npm run build:web && npm run build:server');
expect(dockerfile).toContain('npm prune --omit=dev --no-audit --no-fund');
});
});
-23
View File
@@ -1,23 +0,0 @@
import { existsSync, readFileSync } from 'node:fs';
import { writeDialectArtifactFiles } from '../../src/server/db/schemaArtifactGenerator.js';
import {
resolveGeneratedSchemaContractPath,
type SchemaContract,
writeSchemaContractFile,
} from '../../src/server/db/schemaContract.js';
function readPreviousSchemaContract(): SchemaContract | null {
const contractPath = resolveGeneratedSchemaContractPath();
if (!existsSync(contractPath)) {
return null;
}
return JSON.parse(readFileSync(contractPath, 'utf8')) as SchemaContract;
}
const previousContract = readPreviousSchemaContract();
const contract = writeSchemaContractFile();
writeDialectArtifactFiles(contract, previousContract);
const tableCount = Object.keys(contract.tables).length;
console.log(`[schema:contract] wrote ${tableCount} tables and dialect artifacts`);
-113
View File
@@ -1,113 +0,0 @@
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import {
resolveGeneratedSchemaContractPath,
type SchemaContract,
} from '../../src/server/db/schemaContract.js';
interface FixtureScriptOptions {
fromRef?: string;
outputPath: string;
dropTables: string[];
dropColumns: string[];
}
function parseArgs(argv: string[]): FixtureScriptOptions {
const options: FixtureScriptOptions = {
outputPath: 'src/server/db/generated/fixtures/2026-03-14-baseline.schemaContract.json',
dropTables: [],
dropColumns: [],
};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--from-ref') {
options.fromRef = argv[index + 1];
index += 1;
continue;
}
if (argument === '--output') {
options.outputPath = argv[index + 1];
index += 1;
continue;
}
if (argument === '--drop-table') {
options.dropTables.push(argv[index + 1]);
index += 1;
continue;
}
if (argument === '--drop-column') {
options.dropColumns.push(argv[index + 1]);
index += 1;
}
}
return options;
}
function pruneContract(contract: SchemaContract, options: FixtureScriptOptions): SchemaContract {
const droppedTables = new Set(options.dropTables);
const droppedColumns = new Map<string, Set<string>>();
for (const entry of options.dropColumns) {
const [tableName, columnName] = entry.split('.', 2);
if (!tableName || !columnName) {
throw new Error(`invalid --drop-column value: ${entry}`);
}
const tableColumns = droppedColumns.get(tableName) ?? new Set<string>();
tableColumns.add(columnName);
droppedColumns.set(tableName, tableColumns);
}
const tables = Object.fromEntries(
Object.entries(contract.tables)
.filter(([tableName]) => !droppedTables.has(tableName))
.map(([tableName, table]) => {
const removedColumns = droppedColumns.get(tableName) ?? new Set<string>();
const columns = Object.fromEntries(
Object.entries(table.columns).filter(([columnName]) => !removedColumns.has(columnName)),
);
return [tableName, { columns }];
}),
);
const hasColumn = (tableName: string, columnName: string): boolean =>
!!tables[tableName]?.columns[columnName];
return {
tables,
indexes: contract.indexes.filter((index) =>
!!tables[index.table] && index.columns.every((columnName) => hasColumn(index.table, columnName))),
uniques: contract.uniques.filter((unique) =>
!!tables[unique.table] && unique.columns.every((columnName) => hasColumn(unique.table, columnName))),
foreignKeys: contract.foreignKeys.filter((foreignKey) =>
!!tables[foreignKey.table]
&& !!tables[foreignKey.referencedTable]
&& foreignKey.columns.every((columnName) => hasColumn(foreignKey.table, columnName))
&& foreignKey.referencedColumns.every((columnName) => hasColumn(foreignKey.referencedTable, columnName))),
};
}
function readContractFromRef(fromRef: string): SchemaContract {
const contractJson = execFileSync(
'git',
['-c', 'safe.directory=.', 'show', `${fromRef}:src/server/db/generated/schemaContract.json`],
{ encoding: 'utf8' },
);
return JSON.parse(contractJson) as SchemaContract;
}
function readCurrentContract(): SchemaContract {
return JSON.parse(readFileSync(resolveGeneratedSchemaContractPath(), 'utf8')) as SchemaContract;
}
const options = parseArgs(process.argv.slice(2));
const sourceContract = options.fromRef ? readContractFromRef(options.fromRef) : readCurrentContract();
const contract = pruneContract(sourceContract, options);
const outputPath = resolve(process.cwd(), options.outputPath);
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, `${JSON.stringify(contract, null, 2)}\n`, 'utf8');
console.log(`[schema:upgrade-fixture] wrote ${outputPath}`);
-22
View File
@@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
describe('harness workflows', () => {
it('keeps repo drift checks wired into ci and scheduled reporting', () => {
const ciWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/ci.yml'), 'utf8');
const driftWorkflow = readFileSync(resolve(process.cwd(), '.github/workflows/harness-drift-report.yml'), 'utf8');
expect(ciWorkflow).toContain('name: Repo Drift Check');
expect(ciWorkflow).toContain('npm run repo:drift-check');
expect(ciWorkflow).toContain('name: Test Core');
expect(ciWorkflow).toContain('name: Build Web');
expect(ciWorkflow).toContain('name: Typecheck');
expect(driftWorkflow).toContain('schedule:');
expect(driftWorkflow).toContain('workflow_dispatch:');
expect(driftWorkflow).toContain('npm run repo:drift-check -- --format markdown --output tmp/repo-drift-report.md --report-only');
expect(driftWorkflow).toMatch(/actions\/upload-artifact@v\d+/);
expect(driftWorkflow).toContain('repo-drift-report');
});
});
-64
View File
@@ -1,64 +0,0 @@
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { formatRepoDriftReport, runRepoDriftCheck } from './repo-drift-check.js';
function writeWorkspaceFiles(root: string, files: Record<string, string>): void {
for (const [relativePath, contents] of Object.entries(files)) {
const fullPath = join(root, relativePath);
mkdirSync(dirname(fullPath), { recursive: true });
writeFileSync(fullPath, contents);
}
}
describe('repo drift check', () => {
it('separates new violations from tracked debt', () => {
const root = mkdtempSync(join(tmpdir(), 'metapi-repo-drift-'));
writeWorkspaceFiles(root, {
'src/server/transformers/openai/responses/routeCompatibility.ts': "import type { EndpointAttemptContext } from '../../../routes/proxy/endpointFlow.js';\n",
'src/server/proxy-core/surfaces/chatSurface.ts': 'const payload = await upstream.text();\n',
'src/server/proxy-core/surfaces/sharedSurface.ts': "import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';\n",
'src/web/pages/Accounts.tsx': "import { TokensPanel } from './Tokens.js';\n",
'src/web/pages/Tokens.tsx': 'export const TokensPanel = () => null;\n',
});
const report = runRepoDriftCheck({ root });
expect(report.violations).toEqual(expect.arrayContaining([
expect.objectContaining({
ruleId: 'proxy-surface-body-read',
file: 'src/server/proxy-core/surfaces/chatSurface.ts',
}),
expect.objectContaining({
ruleId: 'transformers-route-blind',
file: 'src/server/transformers/openai/responses/routeCompatibility.ts',
}),
]));
expect(report.trackedDebt).toEqual(expect.arrayContaining([
expect.objectContaining({
ruleId: 'proxy-core-routes-proxy-import',
file: 'src/server/proxy-core/surfaces/sharedSurface.ts',
}),
expect.objectContaining({
ruleId: 'web-page-to-page-import',
file: 'src/web/pages/Accounts.tsx',
}),
]));
});
it('keeps the current repository within the first-wave ratchet', () => {
const report = runRepoDriftCheck({ root: process.cwd() });
expect(report.violations).toEqual([]);
expect(report.trackedDebt).toEqual(expect.any(Array));
});
it('can render markdown reports for scheduled cleanup jobs', () => {
const report = runRepoDriftCheck({ root: process.cwd() });
const markdown = formatRepoDriftReport(report, 'markdown');
expect(markdown).toContain('# Repo Drift Report');
expect(markdown).toContain('## Violations');
expect(markdown).toContain('## Tracked Debt');
});
});
-338
View File
@@ -1,338 +0,0 @@
import { lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, extname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import process from 'node:process';
type ReportFormat = 'text' | 'markdown';
type Severity = 'violation' | 'tracked_debt';
type DriftFinding = {
ruleId: string;
severity: Severity;
file: string;
line: number;
message: string;
excerpt: string;
};
type DriftReport = {
root: string;
generatedAt: string;
violations: DriftFinding[];
trackedDebt: DriftFinding[];
};
type RuleSpec = {
id: string;
description: string;
fileFilter: (file: string) => boolean;
lineMatch: (line: string, file: string) => boolean;
message: string | ((file: string, line: string) => string);
allowlistedFiles?: Set<string>;
};
type RunOptions = {
root: string;
};
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx']);
const ROUTES_PROXY_IMPORT_ALLOWLIST = new Set([
'src/server/proxy-core/surfaces/chatSurface.ts',
'src/server/proxy-core/surfaces/filesSurface.ts',
'src/server/proxy-core/surfaces/geminiSurface.ts',
'src/server/proxy-core/surfaces/openAiResponsesSurface.ts',
'src/server/proxy-core/surfaces/sharedSurface.ts',
]);
const TOP_LEVEL_PAGE_IMPORT_ALLOWLIST = new Set([
'src/web/pages/Accounts.tsx',
]);
function normalizeRelativePath(root: string, fullPath: string): string {
return relative(root, fullPath).replaceAll('\\', '/');
}
function walkFiles(root: string, currentDir = root): string[] {
const entries = readdirSync(currentDir).sort((left, right) => left.localeCompare(right, 'en'));
const files: string[] = [];
for (const entry of entries) {
if (entry === '.git' || entry === 'node_modules' || entry === 'dist' || entry === 'coverage') {
continue;
}
const fullPath = resolve(currentDir, entry);
const stat = lstatSync(fullPath);
if (stat.isSymbolicLink()) {
continue;
}
if (stat.isDirectory()) {
files.push(...walkFiles(root, fullPath));
continue;
}
const extension = extname(entry);
if (SOURCE_EXTENSIONS.has(extension)) {
files.push(fullPath);
}
}
return files;
}
function readWorkspaceLines(root: string, file: string): Array<{ lineNumber: number; text: string }> {
const source = readFileSync(resolve(root, file), 'utf8').replaceAll('\r\n', '\n');
return source.split('\n').map((text, index) => ({
lineNumber: index + 1,
text,
}));
}
function isNonTestSource(file: string): boolean {
return !file.endsWith('.test.ts')
&& !file.endsWith('.test.tsx')
&& !file.endsWith('.test.js')
&& !file.endsWith('.test.jsx');
}
function isTopLevelPageFile(file: string): boolean {
return /^src\/web\/pages\/[^/]+\.(ts|tsx|js|jsx)$/.test(file)
&& isNonTestSource(file);
}
function createRules(): RuleSpec[] {
return [
{
id: 'transformers-route-blind',
description: 'Transformers must not import route-layer proxy helpers',
fileFilter: (file) => file.startsWith('src/server/transformers/')
&& isNonTestSource(file),
lineMatch: (line) => /from\s+['"][^'"]*routes\/proxy\//.test(line),
message: 'transformer imports route-layer proxy code',
},
{
id: 'proxy-surface-body-read',
description: 'Proxy-core surfaces should use readRuntimeResponseText() for whole-body reads',
fileFilter: (file) => file.startsWith('src/server/proxy-core/surfaces/')
&& isNonTestSource(file),
lineMatch: (line) => /\.text\(/.test(line),
message: 'proxy-core surface reads a full upstream body via .text()',
},
{
id: 'proxy-core-routes-proxy-import',
description: 'Proxy-core imports from routes/proxy are tracked debt and should not grow',
fileFilter: (file) => file.startsWith('src/server/proxy-core/')
&& isNonTestSource(file),
lineMatch: (line) => /from\s+['"][^'"]*routes\/proxy\//.test(line),
message: 'proxy-core imports a helper from routes/proxy',
allowlistedFiles: ROUTES_PROXY_IMPORT_ALLOWLIST,
},
{
id: 'web-page-to-page-import',
description: 'Top-level route pages should not import other top-level route pages',
fileFilter: (file) => isTopLevelPageFile(file),
lineMatch: (line, file) => {
const match = line.match(/from\s+['"]\.\/([^/'"]+?)(?:\.(?:js|ts|tsx|jsx))?['"]/);
if (!match) return false;
const importedPage = match[1];
const currentPage = file.replace(/^src\/web\/pages\//, '').replace(/\.(ts|tsx|js|jsx)$/, '');
return importedPage !== currentPage;
},
message: (file, line) => {
const match = line.match(/from\s+['"](\.\/[^'"]+)['"]/);
const imported = match?.[1] ?? 'another page file';
return `top-level page imports ${imported}`;
},
allowlistedFiles: TOP_LEVEL_PAGE_IMPORT_ALLOWLIST,
},
];
}
export function runRepoDriftCheck(options: Partial<RunOptions> = {}): DriftReport {
const root = resolve(options.root ?? process.cwd());
const report: DriftReport = {
root,
generatedAt: new Date().toISOString(),
violations: [],
trackedDebt: [],
};
const files = walkFiles(root).map((file) => normalizeRelativePath(root, file));
const rules = createRules();
for (const rule of rules) {
for (const file of files) {
if (!rule.fileFilter(file)) continue;
for (const { lineNumber, text } of readWorkspaceLines(root, file)) {
if (!rule.lineMatch(text, file)) continue;
const finding: DriftFinding = {
ruleId: rule.id,
severity: rule.allowlistedFiles?.has(file) ? 'tracked_debt' : 'violation',
file,
line: lineNumber,
message: typeof rule.message === 'function' ? rule.message(file, text) : rule.message,
excerpt: text.trim(),
};
if (finding.severity === 'tracked_debt') {
report.trackedDebt.push(finding);
} else {
report.violations.push(finding);
}
}
}
}
return report;
}
function formatFindingText(finding: DriftFinding): string {
return `- [${finding.ruleId}] ${finding.file}:${finding.line} ${finding.message}\n ${finding.excerpt}`;
}
function formatFindingMarkdownRow(finding: DriftFinding): string {
const escapedMessage = finding.message.replaceAll('|', '\\|');
const escapedExcerpt = finding.excerpt.replaceAll('|', '\\|');
return `| \`${finding.ruleId}\` | \`${finding.file}:${finding.line}\` | ${escapedMessage} | \`${escapedExcerpt}\` |`;
}
export function formatRepoDriftReport(report: DriftReport, format: ReportFormat = 'text'): string {
if (format === 'markdown') {
const lines: string[] = [
'# Repo Drift Report',
'',
`- Root: \`${report.root}\``,
`- Generated at: \`${report.generatedAt}\``,
`- Violations: **${report.violations.length}**`,
`- Tracked debt: **${report.trackedDebt.length}**`,
'',
];
if (report.violations.length > 0) {
lines.push('## Violations', '', '| Rule | Location | Message | Excerpt |', '| --- | --- | --- | --- |');
for (const finding of report.violations) {
lines.push(formatFindingMarkdownRow(finding));
}
lines.push('');
} else {
lines.push('## Violations', '', 'No new violations found.', '');
}
if (report.trackedDebt.length > 0) {
lines.push('## Tracked Debt', '', '| Rule | Location | Message | Excerpt |', '| --- | --- | --- | --- |');
for (const finding of report.trackedDebt) {
lines.push(formatFindingMarkdownRow(finding));
}
lines.push('');
} else {
lines.push('## Tracked Debt', '', 'No tracked debt entries were observed.', '');
}
return lines.join('\n');
}
const lines: string[] = [
`Repo drift report for ${report.root}`,
`Generated at ${report.generatedAt}`,
'',
`Violations: ${report.violations.length}`,
];
if (report.violations.length > 0) {
lines.push(...report.violations.map(formatFindingText));
} else {
lines.push('- none');
}
lines.push('', `Tracked debt: ${report.trackedDebt.length}`);
if (report.trackedDebt.length > 0) {
lines.push(...report.trackedDebt.map(formatFindingText));
} else {
lines.push('- none');
}
return lines.join('\n');
}
type CliOptions = {
format: ReportFormat;
output?: string;
reportOnly: boolean;
root?: string;
};
function parseCliOptions(argv: string[]): CliOptions {
const options: CliOptions = {
format: 'text',
reportOnly: false,
};
const readRequiredValue = (flag: string, index: number): string => {
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${flag} requires a value`);
}
return value;
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--format') {
const value = readRequiredValue(arg, index);
if (value !== 'text' && value !== 'markdown') {
throw new Error(`--format must be one of: text, markdown`);
}
options.format = value;
index += 1;
continue;
}
if (arg === '--output') {
options.output = readRequiredValue(arg, index);
index += 1;
continue;
}
if (arg === '--report-only') {
options.reportOnly = true;
continue;
}
if (arg === '--root') {
options.root = readRequiredValue(arg, index);
index += 1;
}
}
return options;
}
function maybeWriteReport(outputPath: string | undefined, contents: string): void {
if (!outputPath) return;
const resolved = resolve(outputPath);
mkdirSync(dirname(resolved), { recursive: true });
writeFileSync(resolved, contents);
}
const isMainModule = (() => {
try {
return process.argv[1] != null && fileURLToPath(import.meta.url) === resolve(process.argv[1]);
} catch {
return false;
}
})();
if (isMainModule) {
try {
const options = parseCliOptions(process.argv.slice(2));
const report = runRepoDriftCheck({ root: options.root });
const contents = formatRepoDriftReport(report, options.format);
maybeWriteReport(options.output, contents);
console.log(contents);
process.exit(report.violations.length > 0 && !options.reportOnly ? 1 : 0);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(message);
process.exit(1);
}
}
-46
View File
@@ -1,46 +0,0 @@
@echo off
setlocal EnableExtensions
rem Try to switch output to UTF-8; ignore failures.
chcp 65001 >nul 2>&1
set "SCRIPT_DIR=%~dp0"
set "PROJECT_ROOT=%SCRIPT_DIR%..\.."
cd /d "%PROJECT_ROOT%"
echo [INFO] Stopping old processes...
rem Read ports from .env with defaults.
set "BACKEND_PORT=9000"
set "PROXY_PORT=9001"
set "FRONTEND_PORT=5173"
for /f "tokens=2 delims==" %%v in ('findstr /B /C:"PORT=" .env 2^>nul') do set "BACKEND_PORT=%%v"
for /f "tokens=2 delims==" %%v in ('findstr /B /C:"PROXY_PORT=" .env 2^>nul') do set "PROXY_PORT=%%v"
for /f "tokens=2 delims==" %%v in ('findstr /B /C:"FRONTEND_PORT=" .env 2^>nul') do set "FRONTEND_PORT=%%v"
rem Kill listeners on backend/proxy/frontend ports.
for %%p in (%BACKEND_PORT% %PROXY_PORT% %FRONTEND_PORT%) do (
for /f "tokens=5" %%a in ('netstat -aon ^| findstr ":%%p " ^| findstr /I LISTENING 2^>nul') do (
if not "%%a"=="0" (
echo Killing PID %%a on port %%p
taskkill /F /PID %%a >nul 2>&1
)
)
)
ping -n 3 127.0.0.1 >nul
echo [INFO] Syncing database schema...
call npx drizzle-kit push --force 2>nul || echo (drizzle-kit push skipped)
echo [INFO] Starting development servers
echo Backend: http://localhost:%BACKEND_PORT%
echo Frontend: http://localhost:%FRONTEND_PORT%
echo.
if /I "%~1"=="--no-dev" (
echo --no-dev set; skipping npm run dev
exit /b 0
)
call npm run dev
-82
View File
@@ -1,82 +0,0 @@
import { spawn } from 'node:child_process';
import { join } from 'node:path';
import {
buildBetterSqliteRebuildCommand,
isNodeModuleVersionMismatch,
} from '../../src/server/nativeModuleGuard.js';
function run(command: string, args: string[]) {
return new Promise<void>((resolve, reject) => {
const child = spawn(command, args, {
cwd: process.cwd(),
stdio: 'inherit',
shell: false,
env: process.env,
});
child.once('exit', (code, signal) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`Command failed: ${command} ${args.join(' ')} (code=${code ?? 'null'}, signal=${signal ?? 'null'})`));
});
child.once('error', reject);
});
}
async function ensureBetterSqliteCompatible() {
try {
await import('better-sqlite3');
return;
} catch (error) {
if (!isNodeModuleVersionMismatch(error)) {
throw error;
}
}
const rebuild = buildBetterSqliteRebuildCommand();
console.log('[boosapi] Detected better-sqlite3 ABI mismatch. Rebuilding for the current Node.js runtime...');
await run(rebuild.command, rebuild.args);
try {
await import('better-sqlite3');
} catch (error) {
throw new Error(
`better-sqlite3 is still incompatible after rebuild: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
async function main() {
const isWatchMode = process.argv.includes('--watch');
await ensureBetterSqliteCompatible();
if (isWatchMode) {
const tsxCli = join(process.cwd(), 'node_modules', 'tsx', 'dist', 'cli.mjs');
const child = spawn(process.execPath, [tsxCli, 'watch', 'src/server/index.ts'], {
cwd: process.cwd(),
stdio: 'inherit',
env: process.env,
});
child.once('exit', (code) => {
process.exit(code ?? 0);
});
return;
}
const child = spawn(process.execPath, ['dist/server/index.js'], {
cwd: process.cwd(),
stdio: 'inherit',
env: process.env,
});
child.once('exit', (code) => {
process.exit(code ?? 0);
});
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
@@ -1,123 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
parsePaginatedJson,
normalizeContributors,
replaceContributorsSection,
renderContributorsBlock,
} from './update-readme-contributors.js';
describe('README contributors updater', () => {
it('filters anonymous and bot contributors before rendering', () => {
const contributors = normalizeContributors([
{
login: 'cita-777',
html_url: 'https://github.com/cita-777',
avatar_url: 'https://avatars.githubusercontent.com/u/177306803?v=4',
contributions: 483,
},
{
login: 'dependabot[bot]',
html_url: 'https://github.com/apps/dependabot',
avatar_url: 'https://avatars.githubusercontent.com/in/29110?v=4',
contributions: 12,
},
{
login: null,
html_url: null,
avatar_url: null,
contributions: 22,
},
{
login: 'Babylonehy',
html_url: 'https://github.com/Babylonehy',
avatar_url: 'https://avatars.githubusercontent.com/u/30937892?v=4',
contributions: 3,
},
]);
expect(contributors).toEqual([
{
login: 'cita-777',
htmlUrl: 'https://github.com/cita-777',
avatarUrl: 'https://avatars.githubusercontent.com/u/177306803?v=4&s=48',
},
{
login: 'Babylonehy',
htmlUrl: 'https://github.com/Babylonehy',
avatarUrl: 'https://avatars.githubusercontent.com/u/30937892?v=4&s=48',
},
]);
});
it('replaces the marked README section with an avatar wall', () => {
const readme = [
'# Metapi',
'',
'<!-- metapi-contributors:start -->',
'old block',
'<!-- metapi-contributors:end -->',
'',
'tail',
].join('\n');
const next = replaceContributorsSection(readme, [
{
login: 'cita-777',
htmlUrl: 'https://github.com/cita-777',
avatarUrl: 'https://avatars.githubusercontent.com/u/177306803?v=4&s=48',
},
{
login: 'Hureru',
htmlUrl: 'https://github.com/Hureru',
avatarUrl: 'https://avatars.githubusercontent.com/u/121702350?v=4&s=48',
},
{
login: 'Babylonehy',
htmlUrl: 'https://github.com/Babylonehy',
avatarUrl: 'https://avatars.githubusercontent.com/u/30937892?v=4&s=48',
},
], 2);
expect(next).toContain('<p align="left">');
expect(next).toContain('https://github.com/cita-777');
expect(next).toContain('https://github.com/Hureru');
expect(next).toContain('https://github.com/Babylonehy');
expect(next).not.toContain('old block');
expect(next).toContain('tail');
});
it('renders contributors across multiple rows', () => {
const html = renderContributorsBlock([
{
login: 'cita-777',
htmlUrl: 'https://github.com/cita-777',
avatarUrl: 'https://avatars.githubusercontent.com/u/177306803?v=4&s=48',
},
{
login: 'Hureru',
htmlUrl: 'https://github.com/Hureru',
avatarUrl: 'https://avatars.githubusercontent.com/u/121702350?v=4&s=48',
},
{
login: 'Babylonehy',
htmlUrl: 'https://github.com/Babylonehy',
avatarUrl: 'https://avatars.githubusercontent.com/u/30937892?v=4&s=48',
},
], 2);
expect(html).toContain('\n <a href="https://github.com/cita-777"');
expect(html).toContain('\n <a href="https://github.com/Babylonehy"');
expect((html.match(/\n <a href=/g) || []).length).toBe(2);
});
it('rejects invalid per-line values instead of looping forever', () => {
expect(() => renderContributorsBlock([], 0)).toThrow('perLine must be a positive integer');
});
it('parses concatenated paginated gh api arrays without requiring newlines between pages', () => {
expect(parsePaginatedJson('[{"login":"a"}][{"login":"b"}]')).toEqual([
{ login: 'a' },
{ login: 'b' },
]);
});
});
-197
View File
@@ -1,197 +0,0 @@
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const REPO = 'cita-777/boosapi';
const README_FILES = ['README.md', 'README_EN.md'] as const;
const START_MARKER = '<!-- boosapi-contributors:start -->';
const END_MARKER = '<!-- boosapi-contributors:end -->';
const PER_LINE = 10;
const EXCLUDED_LOGINS = new Set(['dependabot[bot]']);
type GitHubContributor = {
login: string | null;
html_url: string | null;
avatar_url: string | null;
contributions?: number;
};
export type ReadmeContributor = {
login: string;
htmlUrl: string;
avatarUrl: string;
};
export function normalizeContributors(
contributors: GitHubContributor[],
): ReadmeContributor[] {
const seen = new Set<string>();
return contributors
.filter((contributor) => {
const login = contributor.login?.trim();
const htmlUrl = contributor.html_url?.trim();
const avatarUrl = contributor.avatar_url?.trim();
if (!login || !htmlUrl || !avatarUrl) {
return false;
}
if (EXCLUDED_LOGINS.has(login.toLowerCase()) || login.toLowerCase().endsWith('[bot]')) {
return false;
}
return true;
})
.sort((left, right) => (right.contributions ?? 0) - (left.contributions ?? 0))
.filter((contributor) => {
const key = contributor.login!.toLowerCase();
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
})
.map((contributor) => ({
login: contributor.login!.trim(),
htmlUrl: contributor.html_url!.trim(),
avatarUrl: normalizeAvatarUrl(contributor.avatar_url!.trim()),
}));
}
export function renderContributorsBlock(
contributors: ReadmeContributor[],
perLine = PER_LINE,
): string {
if (!Number.isInteger(perLine) || perLine <= 0) {
throw new RangeError('perLine must be a positive integer');
}
if (contributors.length === 0) {
return '<p align="left">\n <sub>No public contributors found yet.</sub>\n</p>';
}
const lines: string[] = [];
for (let index = 0; index < contributors.length; index += perLine) {
const chunk = contributors.slice(index, index + perLine);
lines.push(
` ${chunk
.map(
(contributor) =>
`<a href="${contributor.htmlUrl}"><img src="${contributor.avatarUrl}" width="48" height="48" alt="${contributor.login}" title="${contributor.login}"/></a>`,
)
.join(' ')}`,
);
}
return `<p align="left">\n${lines.join('\n')}\n</p>`;
}
export function replaceContributorsSection(
readme: string,
contributors: ReadmeContributor[],
perLine = PER_LINE,
): string {
const start = readme.indexOf(START_MARKER);
const end = readme.indexOf(END_MARKER);
if (start === -1 || end === -1 || end < start) {
throw new Error('README contributors markers are missing');
}
const before = readme.slice(0, start + START_MARKER.length);
const after = readme.slice(end);
return `${before}\n${renderContributorsBlock(contributors, perLine)}\n${after}`;
}
export function updateReadmeFile(
readmePath: string,
contributors: ReadmeContributor[],
perLine = PER_LINE,
): boolean {
const current = readFileSync(readmePath, 'utf8');
const next = replaceContributorsSection(current, contributors, perLine);
if (current === next) {
return false;
}
writeFileSync(readmePath, next);
return true;
}
export function fetchContributors(repo = REPO): ReadmeContributor[] {
const raw = execFileSync(
'gh',
['api', `repos/${repo}/contributors?per_page=100&anon=1`, '--paginate'],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
);
return normalizeContributors(parsePaginatedJson(raw) as GitHubContributor[]);
}
export function parsePaginatedJson(raw: string): unknown[] {
const trimmedRaw = raw.trim();
if (!trimmedRaw) return [];
try {
const parsed = JSON.parse(trimmedRaw);
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
// Fall through to paginated-array parsing.
}
try {
const mergedArrays = JSON.parse(`[${trimmedRaw.replace(/\]\s*\[/g, '],[')}]`);
if (Array.isArray(mergedArrays)) {
return mergedArrays.flatMap((item) => (Array.isArray(item) ? item : [item]));
}
} catch {
// Fall through to line-by-line parsing.
}
const items: unknown[] = [];
for (const line of trimmedRaw.split('\n')) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
items.push(...parsed);
} else {
items.push(parsed);
}
}
return items;
}
function normalizeAvatarUrl(url: string): string {
if (!/^https?:/i.test(url)) {
return url;
}
const lower = url.toLowerCase();
if (lower.includes('s=') || lower.includes('size=')) {
return url;
}
return `${url}${url.includes('?') ? '&' : '?'}s=48`;
}
function main(): void {
const contributors = fetchContributors(REPO);
const updatedFiles = README_FILES.filter((path) =>
updateReadmeFile(resolve(process.cwd(), path), contributors),
);
const touched = updatedFiles.length > 0 ? updatedFiles.join(', ') : 'none';
console.log(`Updated README contributors for ${contributors.length} people: ${touched}`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
main();
}

Some files were not shown because too many files have changed in this diff Show More