feat: add full-stack usage statistics

This commit is contained in:
kx_guo 2026-10-26 16:30:32 +08:00
parent 9d74feaa62
commit 1582ee8b94
23 changed files with 434 additions and 93 deletions

6
.gitignore vendored
View File

@ -2,3 +2,9 @@ dist/
node_modules/
.DS_Store
*.log
__pycache__/
*.py[cod]
.venv/
venv/
.env
backend/data/

127
README.md
View File

@ -1,60 +1,85 @@
# 蝶雅产品AI中心
面向产品部的对外统一工作台,当前只展示两个正式产品工具:风险预警、成本识别。项目采用原生 HTML、CSS、JavaScript 模块,无第三方依赖,可直接部署到宝塔 Nginx 静态网站。
面向产品部的对外统一工作台,当前只展示两个正式产品工具:风险预警、成本识别。项目按 frontend/ + backend/ 分层,采用 Vite 前端、FastAPI 后端与 SQLite 持久化统计。
## 已集成入口
## 目录
- frontend/:工作台卡片、浅色/深色主题、使用统计页面、前端测试及构建配置。
- backend/:FastAPI API、SQLite 数据库、统计接口和后端测试。
- deploy/nginx.conf.example:Nginx 静态站点与 API 反向代理配置示例。
## 使用统计
点击“打开工作台”时,前端向 POST /api/apps/{app_id}/click 发送一次请求,后端在 SQLite 中为该入口增加一次点击。使用统计页面通过 GET /api/stats 读取总次数、分应用次数和最近更新时间。后端只接受 risk、cost 两个白名单入口。
数据默认保存在 backend/data/usage.sqlite3。数据库使用事务与 WAL,服务重启后记录会保留。运行数据、密钥和依赖环境不会提交 Git。
## 本地运行
后端需要 Python 3.10+:
```powershell
cd backend
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install -r requirements.txt
uvicorn app.main:app --reload --host 127.0.0.1 --port 8800
```
另开终端启动前端:
```powershell
cd frontend
npm install
npm run dev
```
访问 http://127.0.0.1:5173。Vite 会把 /api 代理到 127.0.0.1:8800。
前端检查和构建:
```powershell
cd frontend
npm test
npm run build
```
后端测试:
```powershell
cd backend
python -m unittest discover -s tests -p "test_*.py" -v
```
## 宝塔部署
先在 /www/wwwroot/dieya-app-portal/backend 安装后端依赖:
```bash
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/gunicorn -w 2 -k uvicorn.workers.UvicornWorker app.main:app -b 127.0.0.1:8800
```
宝塔使用 Python 项目或 Supervisor 托管该命令。工作目录是 backend,运行用户建议为 www,并确保 backend/data 可写。
再构建前端:
```bash
cd /www/wwwroot/dieya-app-portal/frontend
npm install
npm run build
```
网站根目录设为 /www/wwwroot/dieya-app-portal/frontend/dist。Nginx 把 /api/ 转发到 127.0.0.1:8800,其余路径返回 index.html,配置示例见 deploy/nginx.conf.example。前后端使用同一门户域名时无需额外配置 CORS。
部署后可用 curl https://你的域名/api/health 检查后端,用 curl https://你的域名/api/stats 查看统计。打开工作台后刷新“使用统计”,对应次数应增加 1。
## 应用范围
| 应用 | 地址 | 环境 |
| --- | --- | --- |
| 风险预警 | http://risk-prod.dieya.net.cn/ | 正式 |
| 成本识别 | http://cost.dieya.net.cn/ | 正式 |
其他业务入口、测试版应用和制度小助手不在本门户展示。
## 本地启动
需要 Node.js 18 或更高版本。项目没有第三方依赖,不需要 `npm install`:
```sh
npm test
npm run dev
```
默认访问 http://127.0.0.1:5173,也可以指定端口:`npm run dev -- --port 8080`。
## 宝塔部署
生产环境只部署 `dist/` 目录中的静态文件:
```sh
npm run build
```
将 `dist/` 里面的文件上传到网站目录,或在宝塔网站设置中将网站根目录指向:
```text
/www/wwwroot/dieya-product-ai-center/dist
```
不需要创建 Node 项目,不需要 PHP,不需要反向代理。网站默认文档使用 `index.html`。服务器需为 `.mjs` 文件返回 JavaScript 类型,Nginx 可在站点配置中加入:
```nginx
location ~ \.mjs$ {
types { application/javascript mjs; }
add_header Cache-Control "no-cache";
try_files $uri =404;
}
```
## 修改入口
编辑 `src/apps.mjs` 中的 `applications` 数组,然后重新构建并部署 `dist`。当前配置会校验应用必须是产品部正式入口,不允许测试环境配置混入对外门户。
## 页面功能
- 参考多智能体工作台的简洁卡片风格,支持浅色和深色主题按钮切换。
- 顶部品牌导航、产品部工具卡片和响应式布局,避免复杂的筛选与侧栏层级。
- 每张卡片展示产品部标签、正式应用状态和打开工作台按钮。
- 主题选择保存在当前浏览器中,不影响应用权限。
目标业务系统仍需自行负责登录、钉钉免登、可信域名和权限校验。本门户只负责统一展示和跳转。
其他业务入口、测试版应用和制度小助手不在门户展示。目标业务系统仍负责登录、钉钉免登及权限校验。

4
backend/.env.example Normal file
View File

@ -0,0 +1,4 @@
PORTAL_DATABASE=./data/usage.sqlite3
PORTAL_CORS_ORIGINS=
HOST=127.0.0.1
PORT=8800

1
backend/app/__init__.py Normal file
View File

@ -0,0 +1 @@
"""蝶雅产品AI中心统计 API。"""

16
backend/app/catalog.py Normal file
View File

@ -0,0 +1,16 @@
"""Server-side allowlist for public product workbench links."""
APPLICATIONS = {
"risk": {
"id": "risk",
"name": "风险预警",
"url": "http://risk-prod.dieya.net.cn/",
"category": "product",
},
"cost": {
"id": "cost",
"name": "成本识别",
"url": "http://cost.dieya.net.cn/",
"category": "product",
},
}

56
backend/app/database.py Normal file
View File

@ -0,0 +1,56 @@
"""SQLite persistence for workbench click counts."""
import os
import sqlite3
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
BACKEND_ROOT = Path(__file__).resolve().parents[1]
def database_path() -> Path:
configured = Path(os.getenv("PORTAL_DATABASE", "./data/usage.sqlite3")).expanduser()
return configured if configured.is_absolute() else BACKEND_ROOT / configured
def connect(path: Path | None = None) -> sqlite3.Connection:
resolved = path or database_path()
resolved.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(resolved, timeout=15)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA busy_timeout=15000")
connection.execute("PRAGMA foreign_keys=ON")
return connection
def initialize(path: Path | None = None) -> None:
with connect(path) as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute(
"""
CREATE TABLE IF NOT EXISTS application_usage (
app_id TEXT PRIMARY KEY,
click_count INTEGER NOT NULL DEFAULT 0 CHECK (click_count >= 0),
updated_at TEXT
)
"""
)
connection.commit()
@contextmanager
def session(path: Path | None = None):
connection = connect(path)
try:
yield connection
connection.commit()
except Exception:
connection.rollback()
raise
finally:
connection.close()
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")

85
backend/app/main.py Normal file
View File

@ -0,0 +1,85 @@
"""FastAPI entry point for persistent product workbench usage statistics."""
import os
from contextlib import asynccontextmanager
from urllib.parse import urlsplit
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from .catalog import APPLICATIONS
from .database import initialize, session, utc_now
@asynccontextmanager
async def lifespan(_app: FastAPI):
initialize()
yield
app = FastAPI(title="蝶雅产品AI中心统计 API", version="1.0.0", lifespan=lifespan)
cors_origins = [origin.strip() for origin in os.getenv("PORTAL_CORS_ORIGINS", "").split(",") if origin.strip()]
if cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=cors_origins,
allow_methods=["GET", "POST"],
allow_headers=["Content-Type"],
)
@app.get("/api/health")
def health():
with session() as connection:
connection.execute("SELECT 1").fetchone()
return {"status": "ok"}
@app.get("/api/stats")
def get_stats():
with session() as connection:
rows = connection.execute(
"SELECT app_id, click_count, updated_at FROM application_usage ORDER BY app_id"
).fetchall()
counts = {row["app_id"]: row for row in rows}
items = [
{
**application,
"click_count": counts[app_id]["click_count"] if app_id in counts else 0,
"updated_at": counts[app_id]["updated_at"] if app_id in counts else None,
}
for app_id, application in APPLICATIONS.items()
]
timestamps = [item["updated_at"] for item in items if item["updated_at"]]
return {
"items": items,
"total_clicks": sum(item["click_count"] for item in items),
"updated_at": max(timestamps) if timestamps else None,
}
@app.post("/api/apps/{app_id}/click", status_code=201)
def record_click(app_id: str, request: Request):
application = APPLICATIONS.get(app_id)
if application is None:
raise HTTPException(status_code=404, detail="工作台不存在")
origin = request.headers.get("origin")
request_host = request.headers.get("x-forwarded-host") or request.headers.get("host", "")
origin_host = urlsplit(origin).netloc if origin else ""
if origin and origin_host.lower() != request_host.lower():
allowed = {item.strip().rstrip("/") for item in os.getenv("PORTAL_CORS_ORIGINS", "").split(",") if item.strip()}
if origin.rstrip("/") not in allowed:
raise HTTPException(status_code=403, detail="来源不受允许")
now = utc_now()
with session() as connection:
connection.execute(
"""INSERT INTO application_usage (app_id, click_count, updated_at) VALUES (?, 1, ?)
ON CONFLICT(app_id) DO UPDATE SET click_count = click_count + 1, updated_at = excluded.updated_at""",
(app_id, now),
)
row = connection.execute(
"SELECT click_count FROM application_usage WHERE app_id = ?", (app_id,)
).fetchone()
return {"app_id": app_id, "click_count": row["click_count"], "updated_at": now}

3
backend/requirements.txt Normal file
View File

@ -0,0 +1,3 @@
fastapi>=0.115,<1.0
uvicorn[standard]>=0.34,<1.0
httpx>=0.28,<1.0

View File

56
backend/tests/test_api.py Normal file
View File

@ -0,0 +1,56 @@
"""API tests using isolated SQLite files."""
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from fastapi.testclient import TestClient
from app.database import initialize, session
from app.main import app
class UsageApiTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.db_path = Path(self.temp.name) / "usage.sqlite3"
initialize(self.db_path)
self.initialize_patch = patch("app.main.initialize", side_effect=lambda: initialize(self.db_path))
self.initialize_patch.start()
self.database_patch = patch("app.main.session", side_effect=lambda: session(self.db_path))
self.database_patch.start()
self.client_context = TestClient(app)
self.client = self.client_context.__enter__()
def tearDown(self):
self.client_context.__exit__(None, None, None)
self.database_patch.stop()
self.initialize_patch.stop()
self.temp.cleanup()
def test_health_and_empty_stats(self):
self.assertEqual(self.client.get("/api/health").json(), {"status": "ok"})
result = self.client.get("/api/stats").json()
self.assertEqual(result["total_clicks"], 0)
self.assertEqual({item["id"]: item["click_count"] for item in result["items"]}, {"risk": 0, "cost": 0})
def test_each_click_increments_once_and_persists(self):
self.assertEqual(self.client.post("/api/apps/risk/click", json={}).status_code, 201)
self.assertEqual(self.client.post("/api/apps/risk/click", json={}).status_code, 201)
self.assertEqual(self.client.post("/api/apps/cost/click", json={}).status_code, 201)
stats = self.client.get("/api/stats").json()
self.assertEqual(stats["total_clicks"], 3)
self.assertEqual({item["id"]: item["click_count"] for item in stats["items"]}, {"risk": 2, "cost": 1})
self.assertIsNotNone(stats["updated_at"])
def test_only_allowlisted_apps_can_be_counted(self):
self.assertEqual(self.client.post("/api/apps/unknown/click", json={}).status_code, 404)
def test_rejects_cross_origin_click(self):
response = self.client.post("/api/apps/risk/click", json={}, headers={"Origin": "https://untrusted.example"})
self.assertEqual(response.status_code, 403)
if __name__ == "__main__":
unittest.main()

View File

@ -1,21 +1,19 @@
# 把 dist/ 的内容上传到此目录,再按实际域名、路径与 HTTPS 证书修改配置。
server {
listen 80;
server_name portal.example.com;
root /var/www/dieya-product-ai-center;
index index.html;
# 在宝塔现有站点 server {} 中合并 location;保留证书和域名配置。
root /www/wwwroot/dieya-app-portal/frontend/dist;
index index.html;
location / {
try_files $uri $uri/ =404;
}
# .mjs 必须使用 JavaScript MIME 类型。
location ~ \.mjs$ {
types { application/javascript mjs; }
add_header Cache-Control "no-cache";
}
location = /index.html {
add_header Cache-Control "no-cache";
}
location ^~ /api/ {
proxy_pass http://127.0.0.1:8800;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_connect_timeout 5s;
proxy_read_timeout 30s;
}
location / {
try_files $uri $uri/ /index.html;
}

View File

Before

Width:  |  Height:  |  Size: 257 B

After

Width:  |  Height:  |  Size: 257 B

View File

@ -8,6 +8,7 @@
<title>蝶雅产品AI中心</title>
<link rel="icon" href="./favicon.svg" type="image/svg+xml">
<link rel="stylesheet" href="./src/styles.css">
<link rel="stylesheet" href="./src/stats.css">
</head>
<body>
<a class="skip-link" href="#main">跳转到智能体列表</a>
@ -20,8 +21,8 @@
</a>
<nav class="nav" aria-label="主导航">
<button class="nav-icon" type="button" aria-label="打开新的工作台标签页" title="打开新的工作台标签页">+</button>
<a class="nav-link active" href="./">智能体广场</a>
<a class="nav-link" href="#stats">使用统计</a>
<button class="nav-link active" type="button" data-view="home">智能体广场</button>
<button class="nav-link" type="button" data-view="stats">使用统计</button>
<a class="nav-link" href="#feedback">问题反馈</a>
<div class="theme-switch" role="group" aria-label="选择主题">
<button class="theme-button active" type="button" data-theme="light" aria-label="浅色主题" aria-pressed="true">☼</button>
@ -30,13 +31,21 @@
</nav>
</header>
<main id="main" class="main" tabindex="-1">
<section class="intro" aria-labelledby="page-title">
<p class="eyebrow">PRODUCT AI CENTER</p>
<h1 id="page-title">选择智能体</h1>
<p class="intro-description">选择需要使用的产品工具,立即开始工作。</p>
<section id="home-view" aria-labelledby="page-title">
<section class="intro">
<p class="eyebrow">PRODUCT AI CENTER</p>
<h1 id="page-title">选择智能体</h1>
<p class="intro-description">选择需要使用的产品工具,立即开始工作。</p>
</section>
<section id="agent-grid" class="agent-grid" aria-label="产品部智能体列表"></section>
<section id="empty-state" class="empty-state" hidden aria-live="polite">暂无可用的产品工具。</section>
</section>
<section id="stats-view" class="stats-view" hidden aria-labelledby="stats-title">
<div class="stats-heading"><div><p class="eyebrow">USAGE ANALYTICS</p><h1 id="stats-title">使用统计</h1><p class="intro-description">记录每次打开工作台的使用情况。</p></div><button id="refresh-stats" class="refresh-button" type="button">刷新数据</button></div>
<div class="stats-cards"><article class="stat-card"><span>累计打开次数</span><strong id="stats-total">--</strong></article><article class="stat-card"><span>统计范围</span><strong id="stats-scope">产品部工具</strong></article><article class="stat-card"><span>最近更新</span><strong id="stats-updated">--</strong></article></div>
<div class="stats-table-wrap"><table class="stats-table"><thead><tr><th>工作台</th><th>访问次数</th><th>入口状态</th></tr></thead><tbody id="stats-table-body"></tbody></table></div>
<p id="stats-error" class="stats-error" hidden>统计服务暂时不可用,请确认后端服务已启动。</p>
</section>
<section id="agent-grid" class="agent-grid" aria-label="产品部智能体列表"></section>
<section id="empty-state" class="empty-state" hidden aria-live="polite">暂无可用的产品工具。</section>
<footer class="footer"><span>蝶雅产品AI中心</span><span>连接产品 · 提升效率</span></footer>
</main>
</div>

17
frontend/package.json Normal file
View File

@ -0,0 +1,17 @@
{
"name": "dieya-product-ai-center-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "蝶雅产品AI中心",
"engines": { "node": ">=18" },
"scripts": {
"dev": "vite --host 127.0.0.1",
"start": "vite preview --host 127.0.0.1",
"build": "vite build --outDir dist",
"test": "node --test tests/*.test.mjs"
},
"devDependencies": {
"vite": "^6.0.7"
}
}

View File

@ -8,7 +8,7 @@ const arg = (name, fallback) => { const index = args.indexOf(name); return index
const port = Number(arg('--port', process.env.PORT || '5173'));
const host = arg('--host', '127.0.0.1');
const root = path.resolve(fileURLToPath(new URL('../', import.meta.url)));
const publicFiles = new Set(['index.html', 'favicon.svg', 'src/styles.css', 'src/apps.mjs', 'src/main.mjs']);
const publicFiles = new Set(['index.html', 'favicon.svg', 'src/styles.css', 'src/stats.css', 'src/apps.mjs', 'src/main.mjs']);
const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml' };
const server = http.createServer(async (request, response) => {
if (!['GET', 'HEAD'].includes(request.method)) { response.writeHead(405, { Allow: 'GET, HEAD' }); response.end(); return; }

View File

@ -38,6 +38,61 @@ function render() {
document.querySelector('#empty-state').hidden = applications.length > 0;
}
function showView(view, updateHash = true) {
const stats = view === 'stats';
document.querySelector('#home-view').hidden = stats;
document.querySelector('#stats-view').hidden = !stats;
document.querySelectorAll('[data-view]').forEach(button => {
const active = button.dataset.view === view;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
});
if (updateHash) history.replaceState(null, '', stats ? '#stats' : '#');
if (stats) loadStats();
}
async function loadStats() {
const error = document.querySelector('#stats-error');
error.hidden = true;
try {
const response = await fetch('/api/stats', { headers: { Accept: 'application/json' } });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
const records = payload.items || [];
document.querySelector('#stats-total').textContent = Number(payload.total_clicks || 0).toLocaleString('zh-CN');
document.querySelector('#stats-updated').textContent = payload.updated_at
? new Date(payload.updated_at).toLocaleString('zh-CN', { hour12: false })
: '暂无记录';
document.querySelector('#stats-table-body').innerHTML = records.map(item => `<tr><td><span class="stats-app-name">${escapeHtml(item.name)}</span><small>${escapeHtml(item.url)}</small></td><td class="stats-count">${Number(item.click_count).toLocaleString('zh-CN')}</td><td><span class="stats-ready">正式应用</span></td></tr>`).join('');
} catch {
error.hidden = false;
}
}
async function trackAndOpen(app) {
const request = fetch(`/api/apps/${encodeURIComponent(app.id)}/click`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({}),
keepalive: true
}).catch(() => undefined);
// Give the same-origin request a chance to reach the API, then open the tool.
await Promise.race([request, new Promise(resolve => setTimeout(resolve, 180))]);
window.location.assign(app.url);
}
document.querySelectorAll('[data-theme]').forEach(button => button.addEventListener('click', () => applyTheme(button.dataset.theme)));
document.querySelectorAll('[data-view]').forEach(button => button.addEventListener('click', () => showView(button.dataset.view)));
document.querySelector('#refresh-stats').addEventListener('click', loadStats);
window.addEventListener('hashchange', () => showView(location.hash === '#stats' ? 'stats' : 'home', false));
grid.addEventListener('click', event => {
const link = event.target.closest('.agent-card__button');
if (!link) return;
event.preventDefault();
const card = link.closest('.agent-card');
const app = applications.find(item => item.url === link.href);
if (app && card) trackAndOpen(app);
});
applyTheme(preferredTheme);
render();
showView(location.hash === '#stats' ? 'stats' : 'home', false);

14
frontend/src/stats.css Normal file
View File

@ -0,0 +1,14 @@
.nav-link{background:transparent}
.stats-view{padding:42px 0 0}
.stats-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:20px}
.stats-heading h1{margin:0;color:var(--text);font-size:32px}
.refresh-button{padding:10px 16px;border:1px solid var(--line);border-radius:9px;background:var(--card);color:var(--text);font-size:12px}
.refresh-button:hover{border-color:#8b9dff;background:var(--soft)}
.stats-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin-top:30px}
.stat-card{display:flex;flex-direction:column;gap:11px;padding:22px;border:1px solid var(--line);border-radius:14px;background:var(--card);box-shadow:var(--shadow)}
.stat-card span{color:var(--muted);font-size:12px}.stat-card strong{color:var(--text);font-size:24px}
.stats-table-wrap{margin-top:20px;overflow:hidden;border:1px solid var(--line);border-radius:14px;background:var(--card);box-shadow:var(--shadow)}
.stats-table{width:100%;border-collapse:collapse;text-align:left}.stats-table th,.stats-table td{padding:16px 20px;border-bottom:1px solid var(--line);font-size:12px}.stats-table th{color:var(--muted);font-weight:500;background:var(--soft)}.stats-table tr:last-child td{border-bottom:0}
.stats-app-name{display:block;color:var(--text);font-weight:600}.stats-table small{display:block;margin-top:5px;color:var(--muted);font-size:10px}.stats-count{color:var(--text);font-size:16px!important;font-weight:700}.stats-ready{display:inline-flex;padding:5px 8px;border-radius:999px;background:rgba(49,185,122,.12);color:#299c6b;font-size:10px}
.stats-error{margin-top:18px;padding:13px 16px;border:1px solid #efb1b1;border-radius:9px;background:#fff3f3;color:#b34d4d;font-size:12px}:root[data-theme=dark] .stats-error{background:#321d27;color:#f0a4a4;border-color:#73404d}
@media(max-width:700px){.stats-view{padding-top:31px}.stats-heading{align-items:flex-start;flex-direction:column}.stats-heading h1{font-size:27px}.stats-cards{grid-template-columns:1fr;gap:12px;margin-top:24px}.stats-table th,.stats-table td{padding:13px 12px}.stats-table small{max-width:145px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}

10
frontend/vite.config.mjs Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
host: '127.0.0.1',
port: 5173,
proxy: { '/api': 'http://127.0.0.1:8800' }
},
preview: { host: '127.0.0.1', port: 4173 }
});

View File

@ -1,14 +0,0 @@
{
"name": "dieya-product-ai-center",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "蝶雅产品AI中心",
"engines": { "node": ">=18" },
"scripts": {
"dev": "node scripts/serve.mjs",
"start": "node scripts/serve.mjs",
"build": "node scripts/build.mjs",
"test": "node --test tests/*.test.mjs"
}
}