Commit 2472b4e0aef5a8bb8b7c224bbc969ec9f301421a
1 parent
dcf597fe
chore: 迁入 shadiao-2d 的工程配套(schema 门禁、本地管理员、域名部署、文档与运行手册)
- tools/scripts:schema-check / schema-update(CI 加 schema:check),voko-dev-user(本地管理员 upsert) - infra/domains:GoDaddy DNS + Nginx + 证书部署工具原样迁入;voko-alpha.json 为草案(主机未定,不执行) - docs:database.md、local-development.md、runbooks 索引与骨架验收手册;AGENTS.md 指向运行手册 - ecosystem.config.cjs 采用 shadiao-2d 的 PM2 进程默认参数 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011X363N3XYVt41WUtnjtDZH
Showing
17 changed files
with
892 additions
and
18 deletions
.gitlab-ci.yml
AGENTS.md
| ... | ... | @@ -12,16 +12,17 @@ VOKO Agent 世界(voko-world)。pnpm 11 + Turbo monorepo,Node >=24,ESM J |
| 12 | 12 | | @voko/worker | 定时任务与 Webhook 投递(与 server 同目录,独立进程) | — | |
| 13 | 13 | | @voko/web | 员工端与 `/admin` 后台,`/api` → 9201 | 5201 | |
| 14 | 14 | |
| 15 | -(以上 app 在 W1 初始化步骤 3、4 建立。) | |
| 15 | + | |
| 16 | 16 | |
| 17 | 17 | ## 命令 |
| 18 | 18 | |
| 19 | 19 | - `pnpm install`;`pnpm mysql:up` 启动本地 MySQL 并确保 `voko` 库存在(与 shadiao-2d 共用同一个本地 MySQL 服务也可以,库名不同)。 |
| 20 | -- `pnpm lint` / `pnpm test`:架构红线与全量测试。CI 即 install → lint → test(schema:check 在建立 voko-server 后加入)。 | |
| 20 | +- `pnpm lint` / `pnpm test`:架构红线与全量测试。CI 即 install → lint → test → schema:check。 | |
| 21 | 21 | |
| 22 | 22 | ## 文档入口 |
| 23 | 23 | |
| 24 | 24 | - 产品、设计、计划:`docs/voko/README.md`。 |
| 25 | +- 执行启动、数据库、验收或部署任务时,先从[运行目标索引](docs/runbooks/README.md)进入对应手册。 | |
| 25 | 26 | - 新增或修改可执行流程时,必须新增或更新运行手册(`docs/runbooks/`)并登记到索引。只增加脚本或 `package.json` 命令不算完成。 |
| 26 | 27 | |
| 27 | 28 | ## 边界(ESLint 强制) | ... | ... |
README.md
| ... | ... | @@ -14,16 +14,8 @@ pnpm lint && pnpm test |
| 14 | 14 | |
| 15 | 15 | 开发规范见 `AGENTS.md`,产品与设计文档见 `docs/voko/README.md`。 |
| 16 | 16 | |
| 17 | -## 第一次跑起来(骨架) | |
| 17 | +## 文档 | |
| 18 | 18 | |
| 19 | -```bash | |
| 20 | -cp apps/voko-server/.env.example apps/voko-server/.env # 填 SMTP;DATABASE_URL 留空即用本地 voko 库 | |
| 21 | -pnpm mysql:up | |
| 22 | -pnpm --filter @voko/server db:init # 只对空库执行 | |
| 23 | -pnpm --filter @voko/server dev # API: http://localhost:9201/healthz | |
| 24 | -pnpm --filter @voko/web dev # 前端: http://localhost:5201 | |
| 25 | -``` | |
| 26 | - | |
| 27 | -1. 在前端注册一个账号,再执行 `pnpm --filter @voko/server admin:grant 你的邮箱` 把它设为管理员。 | |
| 28 | -2. 用另一个邮箱注册并提交加入申请,用管理员账号在 `/admin` 审核。 | |
| 29 | -3. 真实链路验收:`VOKO_TEST_EMAIL=... VOKO_ADMIN_EMAIL=... VOKO_ADMIN_PASSWORD=... pnpm --filter @voko/server verify:skeleton` | |
| 19 | +- 本地开发:`docs/local-development.md` | |
| 20 | +- 数据库:`docs/database.md` | |
| 21 | +- 运行手册索引:`docs/runbooks/README.md` | ... | ... |
docs/database.md
0 → 100644
| 1 | +# 数据库结构与迁移 | |
| 2 | + | |
| 3 | +VOKO 使用独立 MySQL 8.4 库(本地默认 `voko`),不连接 shadiao-2d 的 `idearun` / `idearun_visual_game` / `visual_game_stage`。时间列 `DATETIME(3)` 存 UTC,列名与字段统一 snake_case。 | |
| 4 | + | |
| 5 | +## 结构事实源 | |
| 6 | + | |
| 7 | +- `packages/*/src/db/tables/*.cjs`:当前完整结构,`apps/voko-server/src/db/schema.js` 汇总为 `vokoTargetTables`。 | |
| 8 | +- `apps/voko-server/src/db/migrations/*.js`:已存在的库如何从一个版本变到下一个版本。历史表 `voko_schema_migrations`。 | |
| 9 | +- 服务启动不执行 DDL。改表必须同时改 `tables/*.cjs` 和新增一个 migration。 | |
| 10 | + | |
| 11 | +## 命令 | |
| 12 | + | |
| 13 | +| 目的 | 命令 | 只能用于 | | |
| 14 | +| --- | --- | --- | | |
| 15 | +| 新空库建表 | `pnpm --filter @voko/server db:init` | 确认没有任何表的空库 | | |
| 16 | +| 执行未执行的 migration | `pnpm --filter @voko/server db:migrate` | 已 init 的库 | | |
| 17 | +| 查看状态 | `pnpm --filter @voko/server db:status` | 已 init 的库 | | |
| 18 | + | |
| 19 | +三个命令都读取 `apps/voko-server/.env` 的 `DATABASE_URL`,或用 `--env <文件>` 指定;都未设置时连本地开发库 `voko`。 | |
| 20 | + | |
| 21 | +## 纪律 | |
| 22 | + | |
| 23 | +- migration 只在合入主干之后,才允许在 stage / 生产等共享库执行;开发只连本地库。 | |
| 24 | +- 唯一性、非空、状态与字段组合一律写成 DB 约束(UNIQUE / NOT NULL / CHECK),不在应用层先查再写。 | |
| 25 | +- 禁止对共享库执行 init;禁止在共享库上手工改结构。 | ... | ... |
docs/local-development.md
0 → 100644
| 1 | +# 本地开发 | |
| 2 | + | |
| 3 | +| 进程 | 地址 | | |
| 4 | +| --- | --- | | |
| 5 | +| voko-server(API) | http://localhost:9201,健康检查 `/healthz` | | |
| 6 | +| voko-worker | 无端口,日志里每分钟一次心跳 | | |
| 7 | +| voko-web(员工端 + `/admin`) | http://localhost:5201,`/api` 代理到 9201 | | |
| 8 | + | |
| 9 | +## 第一次启动 | |
| 10 | + | |
| 11 | +```bash | |
| 12 | +nvm use && corepack enable && pnpm install | |
| 13 | +cp apps/voko-server/.env.example apps/voko-server/.env # 已有 .env 则跳过 | |
| 14 | +pnpm mysql:up # 本地 MySQL,确保 voko 库存在 | |
| 15 | +pnpm --filter @voko/server db:init # 只对空库 | |
| 16 | +pnpm dev:user admin@example.test admin123 # 本地管理员(只用于本地库) | |
| 17 | +pnpm --filter @voko/server dev | |
| 18 | +pnpm --filter @voko/web dev | |
| 19 | +pnpm --filter @voko/server worker # 需要时 | |
| 20 | +``` | |
| 21 | + | |
| 22 | +本地 MySQL 可以与 shadiao-2d 共用同一个 Docker MySQL 服务(`pnpm mysql:up` 发现端口已开且口令可用时只确保 `voko` 库存在,不再起新容器)。 | |
| 23 | + | |
| 24 | +## 配置来源 | |
| 25 | + | |
| 26 | +`apps/voko-server/.env` 的 SMTP(service@voko.world)与大模型配置沿用 shadiao-2d visual-game-server 的同名变量;`AUTH_TOKEN_SECRET` 为 VOKO 单独生成,不与 Visual Game 共用;`DATABASE_URL` 留空即连本地 `voko`。 | |
| 27 | + | |
| 28 | +## 本地管理员 | |
| 29 | + | |
| 30 | +`pnpm dev:user <邮箱> <密码> [显示名] [admin|operator|user]` 对同邮箱 upsert,并重置密码与角色。只能用于本地开发库,禁止用于共享库。已在网页注册的账号可用 `pnpm --filter @voko/server admin:grant <邮箱>` 提升为管理员。 | ... | ... |
docs/runbooks/README.md
0 → 100644
| 1 | +# 运行目标索引 | |
| 2 | + | |
| 3 | +本索引只负责把执行目标路由到一份运行手册。进入手册后使用其中的命令和完成判据,不从文件名猜流程。新增命令型流程时,必须建立独立运行手册并在这里加一条入口。 | |
| 4 | + | |
| 5 | +## 本地开发 | |
| 6 | + | |
| 7 | +- 启动、停止或排查本地应用:[本地开发](../local-development.md)。 | |
| 8 | +- 初始化、升级或检查数据库结构:[数据库结构与迁移](../database.md)。 | |
| 9 | +- 验收仓库骨架(注册 → 申请 → 审核 → 记账幂等):[骨架真实链路验收](verify-skeleton.md)。 | |
| 10 | + | |
| 11 | +## 部署 | |
| 12 | + | |
| 13 | +- 域名、Nginx 与证书:[域名部署](../../infra/domains/README.md)(`voko-alpha.json` 为草案,主机确认前不执行)。 | ... | ... |
docs/runbooks/verify-skeleton.md
0 → 100644
| 1 | +# 骨架真实链路验收 | |
| 2 | + | |
| 3 | +验证注册、加入申请、运营审核、审核通知邮件、积分记账与幂等在真实 MySQL 与真实 SMTP 上可用。 | |
| 4 | + | |
| 5 | +## 前提 | |
| 6 | + | |
| 7 | +- voko-server 已启动,`apps/voko-server/.env` 配置了真实 SMTP。 | |
| 8 | +- 一个能收信的邮箱 `VOKO_TEST_EMAIL`(脚本用 plus 地址注册新用户,例如 `you+voko-skeleton-…@gmail.com`)。 | |
| 9 | +- 一个管理员账号:本地用 `pnpm dev:user`,共享环境用 `admin:grant`。 | |
| 10 | + | |
| 11 | +## 执行 | |
| 12 | + | |
| 13 | +```bash | |
| 14 | +VOKO_TEST_EMAIL=you@gmail.com \ | |
| 15 | +VOKO_ADMIN_EMAIL=admin@example.test \ | |
| 16 | +VOKO_ADMIN_PASSWORD=admin123 \ | |
| 17 | +pnpm --filter @voko/server verify:skeleton | |
| 18 | +``` | |
| 19 | + | |
| 20 | +## 完成判据 | |
| 21 | + | |
| 22 | +- 输出 `"status": "PASS"`,15 项检查全部通过;测试邮箱收到验证码邮件与"邀请已就绪"邮件。 | |
| 23 | +- 缺少环境变量输出 `BLOCKED` 并以 2 退出,不算通过。 | |
| 24 | +- 脚本结束只删除本次创建的用户、申请、会话、验证码、邮件日志和积分流水。 | ... | ... |
ecosystem.config.cjs
| 1 | -// PM2:voko-server 与 voko-worker 两个常驻进程。新增或调整后执行 `pm2 save`。 | |
| 1 | +// PM2:本地常驻或主机部署时的进程定义。新增、删除或重新注册后执行 `pm2 save`。 | |
| 2 | +const path = require('node:path') | |
| 3 | + | |
| 4 | +const repoRoot = __dirname | |
| 5 | + | |
| 6 | +const processDefaults = { | |
| 7 | + autorestart: true, | |
| 8 | + exec_mode: 'fork', | |
| 9 | + instances: 1, | |
| 10 | + kill_timeout: 10000, | |
| 11 | + max_restarts: 10, | |
| 12 | + merge_logs: true, | |
| 13 | + min_uptime: '10s', | |
| 14 | + restart_delay: 2000, | |
| 15 | + time: true, | |
| 16 | + watch: false, | |
| 17 | +} | |
| 18 | + | |
| 2 | 19 | module.exports = { |
| 3 | 20 | apps: [ |
| 4 | - { name: 'voko-server', cwd: 'apps/voko-server', script: 'src/main.js', interpreter: 'node' }, | |
| 5 | - { name: 'voko-worker', cwd: 'apps/voko-server', script: 'src/voko-worker-main.js', interpreter: 'node' }, | |
| 21 | + { | |
| 22 | + ...processDefaults, | |
| 23 | + name: 'voko-server', | |
| 24 | + cwd: path.join(repoRoot, 'apps', 'voko-server'), | |
| 25 | + script: './src/main.js', | |
| 26 | + interpreter: 'node', | |
| 27 | + }, | |
| 28 | + { | |
| 29 | + ...processDefaults, | |
| 30 | + name: 'voko-worker', | |
| 31 | + cwd: path.join(repoRoot, 'apps', 'voko-server'), | |
| 32 | + script: './src/voko-worker-main.js', | |
| 33 | + interpreter: 'node', | |
| 34 | + }, | |
| 6 | 35 | ], |
| 7 | 36 | } | ... | ... |
infra/domains/README.md
0 → 100644
| 1 | +# 域名部署(从 shadiao-2d 搬入) | |
| 2 | + | |
| 3 | +> VOKO 说明:`environments/voko-alpha.json` 是**草案**,假设 Alpha 与 Visual Game Stage 共用 `52.33.173.48` 这台主机(独立目录 `/srv/voko-alpha`、独立 PM2 进程、独立数据库)。主机与数据库方案确认前不要执行。公开 API 域名 `api.voko.world`(`/v1` 前缀)需要在 `visual-game-nginx.js` 里增加对应 location,W5 部署前完成。 | |
| 4 | + | |
| 5 | +## 原文 | |
| 6 | + | |
| 7 | + | |
| 8 | +本目录负责把一组 Visual Game 域名部署到指定服务器。部署入口统一使用 Node.js,完成 GoDaddy | |
| 9 | +DNS、Nginx、Let's Encrypt 证书和真实 HTTPS 验证。 | |
| 10 | + | |
| 11 | +## 目录结构 | |
| 12 | + | |
| 13 | +```text | |
| 14 | +infra/domains/ | |
| 15 | +├── .env # GoDaddy PAT,本地密钥,不提交 | |
| 16 | +├── deploy.js # 统一部署入口 | |
| 17 | +├── godaddy-dns.js # GoDaddy v3 DNS 写入 | |
| 18 | +├── visual-game-nginx.js # Visual Game Nginx 配置生成 | |
| 19 | +└── environments/ | |
| 20 | + ├── visual-game-stage-voko.json | |
| 21 | + └── visual-game-stage-voko-standard.json | |
| 22 | +``` | |
| 23 | + | |
| 24 | +环境差异只写在 `environments/*.json`。DNS、Nginx 和证书流程禁止复制到环境专用脚本。 | |
| 25 | + | |
| 26 | +## 配置 GoDaddy 凭据 | |
| 27 | + | |
| 28 | +打开 <https://developer.godaddy.com/personal-access-token>,创建 Personal Access Token: | |
| 29 | + | |
| 30 | +1. Name 填写 `visual-game-dns-deploy`。 | |
| 31 | +2. Expiration 选择可接受的有效期。 | |
| 32 | +3. Scopes 只选择 `domains.domain:read` 和 `domains.dns:update`。 | |
| 33 | +4. Token 生成后立即复制;GoDaddy 不会再次显示该值。 | |
| 34 | + | |
| 35 | +在 `infra/domains/.env` 写入: | |
| 36 | + | |
| 37 | +```dotenv | |
| 38 | +GODADDY_PAT=<完整 Token> | |
| 39 | +``` | |
| 40 | + | |
| 41 | +执行: | |
| 42 | + | |
| 43 | +```bash | |
| 44 | +chmod 600 infra/domains/.env | |
| 45 | +``` | |
| 46 | + | |
| 47 | +`.env` 已被仓库根 `.gitignore` 忽略。禁止把 Token 写进环境 JSON、服务器应用配置、命令参数或提交记录。 | |
| 48 | + | |
| 49 | +## 部署 Stage Voko 域名 | |
| 50 | + | |
| 51 | +在仓库根目录执行: | |
| 52 | + | |
| 53 | +```bash | |
| 54 | +node infra/domains/deploy.js infra/domains/environments/visual-game-stage-voko.json | |
| 55 | +``` | |
| 56 | + | |
| 57 | +该环境配置对应: | |
| 58 | + | |
| 59 | +- 玩家端:`https://game.voko.world` | |
| 60 | +- Admin:`https://admin-game.voko.world` | |
| 61 | +- Studio:`https://studio-game.voko.world` | |
| 62 | +- 服务器:`52.33.173.48` | |
| 63 | + | |
| 64 | +Stage 规范域名使用独立环境配置: | |
| 65 | + | |
| 66 | +- 玩家端:`https://stage-game.voko.world` | |
| 67 | +- Admin:`https://admin-stage-game.voko.world` | |
| 68 | +- Studio:`https://studio-stage-game.voko.world` | |
| 69 | + | |
| 70 | +部署命令: | |
| 71 | + | |
| 72 | +```bash | |
| 73 | +node infra/domains/deploy.js infra/domains/environments/visual-game-stage-voko-standard.json | |
| 74 | +``` | |
| 75 | + | |
| 76 | +脚本按以下顺序执行: | |
| 77 | + | |
| 78 | +1. GoDaddy A 记录与目标 IP 一致时保持不变;不一致时替换为环境配置中的 IP。 | |
| 79 | +2. 在目标服务器安装仅支持 HTTP 和 ACME challenge 的 Nginx 配置。 | |
| 80 | +3. 使用服务器现有 acme.sh 为三个域名签发同一张证书。 | |
| 81 | +4. 把证书安装到环境配置指定的稳定目录,并登记 Nginx reload 命令。 | |
| 82 | +5. 安装 HTTPS Nginx 配置并 reload Nginx。 | |
| 83 | +6. 真实访问三个 HTTPS 首页和玩家端 `/healthz`;任一响应不是 200 时部署失败。 | |
| 84 | + | |
| 85 | +脚本失败后直接退出,不回滚已经成功的 DNS 记录。修复错误后重新执行同一条命令;已经正确的 DNS | |
| 86 | +记录会显示为 `kept`,acme.sh 会继续使用已经登记的证书状态。 | |
| 87 | + | |
| 88 | +## 新增其他环境 | |
| 89 | + | |
| 90 | +复制现有环境配置: | |
| 91 | + | |
| 92 | +```bash | |
| 93 | +cp infra/domains/environments/visual-game-stage-voko.json \ | |
| 94 | + infra/domains/environments/<新环境>.json | |
| 95 | +``` | |
| 96 | + | |
| 97 | +必须修改以下配置: | |
| 98 | + | |
| 99 | +- `environment`:环境名称。 | |
| 100 | +- `public_ip`:三个 A 记录指向的公网 IP。 | |
| 101 | +- `ssh`:服务器地址、用户和本机 SSH 私钥。 | |
| 102 | +- `application_root`:服务器当前 release 的稳定软链接。 | |
| 103 | +- `backend_origin`:Nginx 代理的 Visual Game API 地址。 | |
| 104 | +- `dns.zone`:GoDaddy 托管的根域名。 | |
| 105 | +- `domains`:game、admin、studio 的完整域名、DNS record name 和前端 dist 目录。 | |
| 106 | +- `nginx.site_name`:服务器上的独立 Nginx site 名称。 | |
| 107 | +- `nginx.root_variable`:该 site 独占的 Nginx map 变量名。 | |
| 108 | +- `nginx.certificate_directory`:该域名组独占的证书安装目录。 | |
| 109 | +- `nginx.acme_webroot`:HTTP-01 challenge 目录。 | |
| 110 | + | |
| 111 | +目标服务器必须已经安装并运行 Nginx、acme.sh 和 Visual Game,且公网 80、443 端口可访问。 | |
| 112 | +域名必须使用 GoDaddy 权威 Nameserver,否则 GoDaddy API 中的记录不会参与公网解析。 | |
| 113 | + | |
| 114 | +修改完成后执行: | |
| 115 | + | |
| 116 | +```bash | |
| 117 | +node infra/domains/deploy.js infra/domains/environments/<新环境>.json | |
| 118 | +``` | |
| 119 | + | |
| 120 | +部署完成的判定标准是脚本输出三个 `HTTPS 200`、一个 `API 200` 和 `域名部署完成`。 | |
| 121 | + | |
| 122 | +## 证书续签 | |
| 123 | + | |
| 124 | +`deploy.js` 使用 acme.sh 的 `--install-cert` 保存证书安装路径和 `systemctl reload nginx`。服务器的 | |
| 125 | +acme.sh 定时任务进入续签窗口后会重新签发证书、覆盖稳定路径中的 `key.pem` 与 `fullchain.pem`,随后 | |
| 126 | +reload Nginx。Nginx 禁止直接读取 acme.sh 内部证书目录。 | ... | ... |
infra/domains/deploy.js
0 → 100644
| 1 | +#!/usr/bin/env node | |
| 2 | +import fs from 'node:fs' | |
| 3 | +import os from 'node:os' | |
| 4 | +import path from 'node:path' | |
| 5 | +import { spawnSync } from 'node:child_process' | |
| 6 | +import { fileURLToPath } from 'node:url' | |
| 7 | + | |
| 8 | +import { setGodaddyARecord } from './godaddy-dns.js' | |
| 9 | +import { renderVisualGameHttpConfig, renderVisualGameHttpsConfig } from './visual-game-nginx.js' | |
| 10 | + | |
| 11 | +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') | |
| 12 | + | |
| 13 | +const config = readDeploymentConfig(process.argv) | |
| 14 | +const pat = readGodaddyPat(config.dns.credential_file) | |
| 15 | + | |
| 16 | +for (const domain of config.domains) { | |
| 17 | + const result = await setGodaddyARecord({ | |
| 18 | + pat, | |
| 19 | + zone: config.dns.zone, | |
| 20 | + name: domain.record_name, | |
| 21 | + address: config.public_ip, | |
| 22 | + ttl: config.dns.ttl, | |
| 23 | + }) | |
| 24 | + console.log(`DNS ${result.action}: ${result.hostname} -> ${result.address}`) | |
| 25 | +} | |
| 26 | + | |
| 27 | +prepareRemoteDirectories(config) | |
| 28 | +installNginxConfig(config, renderVisualGameHttpConfig(config)) | |
| 29 | +issueCertificate(config) | |
| 30 | +installNginxConfig(config, renderVisualGameHttpsConfig(config)) | |
| 31 | +await verifyDeployment(config) | |
| 32 | + | |
| 33 | +console.log('域名部署完成') | |
| 34 | +for (const domain of config.domains) console.log(`${domain.role}: https://${domain.hostname}`) | |
| 35 | + | |
| 36 | +function readDeploymentConfig(argv) { | |
| 37 | + const configPath = path.resolve(repositoryRoot, argv[2]) | |
| 38 | + return JSON.parse(fs.readFileSync(configPath, 'utf8')) | |
| 39 | +} | |
| 40 | + | |
| 41 | +function readGodaddyPat(credentialFile) { | |
| 42 | + const filePath = path.resolve(repositoryRoot, credentialFile) | |
| 43 | + const content = fs.readFileSync(filePath, 'utf8').trim() | |
| 44 | + return content.slice('GODADDY_PAT='.length) | |
| 45 | +} | |
| 46 | + | |
| 47 | +function prepareRemoteDirectories(deployment) { | |
| 48 | + runRemote(deployment, 'sudo', ['mkdir', '-p', deployment.nginx.acme_webroot]) | |
| 49 | + runRemote(deployment, 'sudo', ['mkdir', '-p', deployment.nginx.certificate_directory]) | |
| 50 | + if (deployment.nginx.layout === 'sites') { | |
| 51 | + runRemote(deployment, 'sudo', ['mkdir', '-p', '/etc/nginx/sites-available']) | |
| 52 | + runRemote(deployment, 'sudo', ['mkdir', '-p', '/etc/nginx/sites-enabled']) | |
| 53 | + } | |
| 54 | +} | |
| 55 | + | |
| 56 | +function installNginxConfig(deployment, content) { | |
| 57 | + const upload = `/tmp/${deployment.nginx.site_name}` | |
| 58 | + runRemote(deployment, 'tee', [upload], content) | |
| 59 | + installNginxSite(deployment, upload) | |
| 60 | + runRemote(deployment, 'sudo', ['nginx', '-t']) | |
| 61 | + runRemote(deployment, 'sudo', ['systemctl', 'reload', 'nginx']) | |
| 62 | + runRemote(deployment, 'rm', [upload]) | |
| 63 | +} | |
| 64 | + | |
| 65 | +function installNginxSite(deployment, upload) { | |
| 66 | + switch (deployment.nginx.layout) { | |
| 67 | + case 'sites': { | |
| 68 | + const available = `/etc/nginx/sites-available/${deployment.nginx.site_name}` | |
| 69 | + const enabled = `/etc/nginx/sites-enabled/${deployment.nginx.site_name}` | |
| 70 | + runRemote(deployment, 'sudo', ['install', '-m', '644', upload, available]) | |
| 71 | + runRemote(deployment, 'sudo', ['ln', '-sfn', available, enabled]) | |
| 72 | + return | |
| 73 | + } | |
| 74 | + case 'conf.d': | |
| 75 | + runRemote(deployment, 'sudo', [ | |
| 76 | + 'install', | |
| 77 | + '-m', | |
| 78 | + '644', | |
| 79 | + upload, | |
| 80 | + `/etc/nginx/conf.d/${deployment.nginx.site_name}.conf`, | |
| 81 | + ]) | |
| 82 | + return | |
| 83 | + default: | |
| 84 | + throw new Error(`不支持的 nginx layout:${deployment.nginx.layout}`) | |
| 85 | + } | |
| 86 | +} | |
| 87 | + | |
| 88 | +function issueCertificate(deployment) { | |
| 89 | + const domains = [] | |
| 90 | + for (const domain of deployment.domains) domains.push('-d', domain.hostname) | |
| 91 | + runRemote(deployment, 'sudo', [ | |
| 92 | + '/root/.acme.sh/acme.sh', | |
| 93 | + '--issue', | |
| 94 | + '--server', | |
| 95 | + 'letsencrypt', | |
| 96 | + '--webroot', | |
| 97 | + deployment.nginx.acme_webroot, | |
| 98 | + ...domains, | |
| 99 | + ]) | |
| 100 | + runRemote(deployment, 'sudo', [ | |
| 101 | + '/root/.acme.sh/acme.sh', | |
| 102 | + '--install-cert', | |
| 103 | + '-d', | |
| 104 | + deployment.domains[0].hostname, | |
| 105 | + '--key-file', | |
| 106 | + `${deployment.nginx.certificate_directory}/key.pem`, | |
| 107 | + '--fullchain-file', | |
| 108 | + `${deployment.nginx.certificate_directory}/fullchain.pem`, | |
| 109 | + '--reloadcmd', | |
| 110 | + 'systemctl reload nginx', | |
| 111 | + ]) | |
| 112 | +} | |
| 113 | + | |
| 114 | +async function verifyDeployment(deployment) { | |
| 115 | + for (const domain of deployment.domains) { | |
| 116 | + const response = await fetch(`https://${domain.hostname}/`, { redirect: 'manual' }) | |
| 117 | + if (response.status !== 200) throw new Error(`${domain.hostname} 首页返回 ${response.status}`) | |
| 118 | + console.log(`HTTPS 200: ${domain.hostname}`) | |
| 119 | + } | |
| 120 | + const health = await fetch(`https://${deployment.domains[0].hostname}/healthz`) | |
| 121 | + if (health.status !== 200) throw new Error(`${deployment.domains[0].hostname}/healthz 返回 ${health.status}`) | |
| 122 | + console.log(`API 200: ${deployment.domains[0].hostname}/healthz`) | |
| 123 | +} | |
| 124 | + | |
| 125 | +function runRemote(deployment, command, args, input) { | |
| 126 | + const identityFile = expandHome(deployment.ssh.identity_file) | |
| 127 | + const destination = `${deployment.ssh.user}@${deployment.ssh.host}` | |
| 128 | + const remoteCommand = [command, ...args].map(quoteRemoteArgument).join(' ') | |
| 129 | + const result = spawnSync('ssh', [ | |
| 130 | + '-i', identityFile, | |
| 131 | + '-o', 'IdentitiesOnly=yes', | |
| 132 | + '-o', 'StrictHostKeyChecking=yes', | |
| 133 | + destination, | |
| 134 | + remoteCommand, | |
| 135 | + ], { | |
| 136 | + cwd: repositoryRoot, | |
| 137 | + encoding: 'utf8', | |
| 138 | + input, | |
| 139 | + maxBuffer: 10 * 1024 * 1024, | |
| 140 | + }) | |
| 141 | + if (result.status !== 0) { | |
| 142 | + throw new Error(`远程命令失败: ${command} ${args.join(' ')}\n${result.stdout}\n${result.stderr}`) | |
| 143 | + } | |
| 144 | + return result.stdout.trim() | |
| 145 | +} | |
| 146 | + | |
| 147 | +function quoteRemoteArgument(value) { | |
| 148 | + return `'${value.replaceAll("'", "'\\''")}'` | |
| 149 | +} | |
| 150 | + | |
| 151 | +function expandHome(filePath) { | |
| 152 | + if (!filePath.startsWith('~/')) return filePath | |
| 153 | + return path.join(os.homedir(), filePath.slice(2)) | |
| 154 | +} | ... | ... |
infra/domains/environments/voko-alpha.json
0 → 100644
| 1 | +{ | |
| 2 | + "environment": "voko-alpha", | |
| 3 | + "frontend_mode": "static", | |
| 4 | + "public_ip": "52.33.173.48", | |
| 5 | + "ssh": { | |
| 6 | + "host": "52.33.173.48", | |
| 7 | + "user": "ubuntu", | |
| 8 | + "identity_file": "~/.ssh/SGP.pem" | |
| 9 | + }, | |
| 10 | + "application_root": "/srv/voko-alpha/current", | |
| 11 | + "backend_origin": "http://127.0.0.1:9201", | |
| 12 | + "dns": { | |
| 13 | + "zone": "voko.world", | |
| 14 | + "credential_file": "infra/domains/.env", | |
| 15 | + "ttl": 600 | |
| 16 | + }, | |
| 17 | + "domains": [ | |
| 18 | + { | |
| 19 | + "role": "web", | |
| 20 | + "hostname": "alpha.voko.world", | |
| 21 | + "record_name": "alpha", | |
| 22 | + "frontend_directory": "apps/voko-web/dist" | |
| 23 | + } | |
| 24 | + ], | |
| 25 | + "nginx": { | |
| 26 | + "layout": "sites", | |
| 27 | + "site_name": "voko-alpha", | |
| 28 | + "root_variable": "voko_alpha_root", | |
| 29 | + "certificate_directory": "/etc/nginx/ssl/voko.world", | |
| 30 | + "acme_webroot": "/var/www/visual-game-acme" | |
| 31 | + } | |
| 32 | +} | ... | ... |
infra/domains/godaddy-dns.js
0 → 100644
| 1 | +const GODADDY_DNS_API = 'https://api.godaddy.com/v3/domains/zones' | |
| 2 | + | |
| 3 | +export async function setGodaddyARecord({ pat, zone, name, address, ttl }) { | |
| 4 | + const records = await listARecords({ pat, zone, name }) | |
| 5 | + if (records.length === 1 && records[0].data === address && records[0].ttl === ttl) { | |
| 6 | + return { hostname: `${name}.${zone}`, action: 'kept', address } | |
| 7 | + } | |
| 8 | + | |
| 9 | + for (const record of records) { | |
| 10 | + await godaddyRequest({ | |
| 11 | + pat, | |
| 12 | + method: 'DELETE', | |
| 13 | + url: `${GODADDY_DNS_API}/${zone}/dns-records/${record.recordId}`, | |
| 14 | + body: null, | |
| 15 | + }) | |
| 16 | + } | |
| 17 | + | |
| 18 | + await godaddyRequest({ | |
| 19 | + pat, | |
| 20 | + method: 'POST', | |
| 21 | + url: `${GODADDY_DNS_API}/${zone}/dns-records`, | |
| 22 | + body: { type: 'A', name, data: address, ttl }, | |
| 23 | + }) | |
| 24 | + | |
| 25 | + let action = 'created' | |
| 26 | + if (records.length > 0) action = 'replaced' | |
| 27 | + return { hostname: `${name}.${zone}`, action, address } | |
| 28 | +} | |
| 29 | + | |
| 30 | +async function listARecords({ pat, zone, name }) { | |
| 31 | + const url = new URL(`${GODADDY_DNS_API}/${zone}/dns-records`) | |
| 32 | + url.searchParams.set('type', 'A') | |
| 33 | + url.searchParams.set('name', name) | |
| 34 | + url.searchParams.set('pageSize', '100') | |
| 35 | + const response = await godaddyRequest({ pat, method: 'GET', url: url.href, body: null }) | |
| 36 | + if (response === null || typeof response !== 'object' || !Array.isArray(response.items)) { | |
| 37 | + throw new Error(`GoDaddy DNS 响应缺少 items: ${name}.${zone}`) | |
| 38 | + } | |
| 39 | + return response.items | |
| 40 | +} | |
| 41 | + | |
| 42 | +async function godaddyRequest({ pat, method, url, body }) { | |
| 43 | + const headers = { authorization: `Bearer ${pat}`, accept: 'application/json' } | |
| 44 | + const request = { method, headers } | |
| 45 | + if (body !== null) { | |
| 46 | + headers['content-type'] = 'application/json' | |
| 47 | + request.body = JSON.stringify(body) | |
| 48 | + } | |
| 49 | + | |
| 50 | + const response = await fetch(url, request) | |
| 51 | + const text = await response.text() | |
| 52 | + if (!response.ok) { | |
| 53 | + throw new Error(`GoDaddy ${method} ${url} 返回 ${response.status}: ${text}`) | |
| 54 | + } | |
| 55 | + if (text.length === 0) return null | |
| 56 | + return JSON.parse(text) | |
| 57 | +} | ... | ... |
infra/domains/visual-game-nginx.js
0 → 100644
| 1 | +export function renderVisualGameHttpConfig(config) { | |
| 2 | + return `${renderFrontendMap(config)} | |
| 3 | + | |
| 4 | +server { | |
| 5 | + listen 80; | |
| 6 | + listen [::]:80; | |
| 7 | + server_name | |
| 8 | +${renderServerNames(config)}; | |
| 9 | + | |
| 10 | + location ^~ /.well-known/acme-challenge/ { | |
| 11 | + root ${config.nginx.acme_webroot}; | |
| 12 | + } | |
| 13 | + | |
| 14 | + location / { | |
| 15 | + return 301 https://$host$request_uri; | |
| 16 | + } | |
| 17 | +} | |
| 18 | +` | |
| 19 | +} | |
| 20 | + | |
| 21 | +export function renderVisualGameHttpsConfig(config) { | |
| 22 | + return `${renderFrontendMap(config)} | |
| 23 | + | |
| 24 | +server { | |
| 25 | + listen 80; | |
| 26 | + listen [::]:80; | |
| 27 | + server_name | |
| 28 | +${renderServerNames(config)}; | |
| 29 | + | |
| 30 | + location ^~ /.well-known/acme-challenge/ { | |
| 31 | + root ${config.nginx.acme_webroot}; | |
| 32 | + } | |
| 33 | + | |
| 34 | + location / { | |
| 35 | + return 301 https://$host$request_uri; | |
| 36 | + } | |
| 37 | +} | |
| 38 | + | |
| 39 | +server { | |
| 40 | + listen 443 ssl http2; | |
| 41 | + listen [::]:443 ssl http2; | |
| 42 | + server_name | |
| 43 | +${renderServerNames(config)}; | |
| 44 | + | |
| 45 | + ssl_certificate ${config.nginx.certificate_directory}/fullchain.pem; | |
| 46 | + ssl_certificate_key ${config.nginx.certificate_directory}/key.pem; | |
| 47 | + ssl_protocols TLSv1.2 TLSv1.3; | |
| 48 | + ssl_session_cache shared:${config.nginx.root_variable}_ssl:10m; | |
| 49 | + ssl_session_timeout 1d; | |
| 50 | + ssl_session_tickets off; | |
| 51 | + | |
| 52 | + client_max_body_size 36m; | |
| 53 | + | |
| 54 | + location = /healthz { | |
| 55 | + proxy_pass ${config.backend_origin}/healthz; | |
| 56 | + proxy_http_version 1.1; | |
| 57 | + proxy_set_header Host $host; | |
| 58 | + proxy_set_header X-Real-IP $remote_addr; | |
| 59 | + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | |
| 60 | + proxy_set_header X-Forwarded-Proto $scheme; | |
| 61 | + } | |
| 62 | + | |
| 63 | + location ^~ /api/ { | |
| 64 | + proxy_pass ${config.backend_origin}; | |
| 65 | + proxy_http_version 1.1; | |
| 66 | + proxy_set_header Host $host; | |
| 67 | + proxy_set_header X-Real-IP $remote_addr; | |
| 68 | + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | |
| 69 | + proxy_set_header X-Forwarded-Proto $scheme; | |
| 70 | + proxy_buffering off; | |
| 71 | + proxy_request_buffering off; | |
| 72 | + proxy_read_timeout 300s; | |
| 73 | + proxy_send_timeout 300s; | |
| 74 | + } | |
| 75 | + | |
| 76 | + location = /ws/asr { | |
| 77 | + proxy_pass ${config.backend_origin}; | |
| 78 | + proxy_http_version 1.1; | |
| 79 | + proxy_set_header Upgrade $http_upgrade; | |
| 80 | + proxy_set_header Connection "upgrade"; | |
| 81 | + proxy_set_header Host $host; | |
| 82 | + proxy_set_header X-Real-IP $remote_addr; | |
| 83 | + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | |
| 84 | + proxy_set_header X-Forwarded-Proto $scheme; | |
| 85 | + proxy_read_timeout 60s; | |
| 86 | + proxy_send_timeout 60s; | |
| 87 | + } | |
| 88 | + | |
| 89 | +${renderFrontendLocation(config)} | |
| 90 | +} | |
| 91 | +` | |
| 92 | +} | |
| 93 | + | |
| 94 | +function renderFrontendMap(config) { | |
| 95 | + const entries = config.domains.map((domain) => ` ${domain.hostname} ${frontendTarget(config, domain)};`) | |
| 96 | + return `map $host $${config.nginx.root_variable} { | |
| 97 | + hostnames; | |
| 98 | +${entries.join('\n')} | |
| 99 | +}` | |
| 100 | +} | |
| 101 | + | |
| 102 | +function frontendTarget(config, domain) { | |
| 103 | + switch (config.frontend_mode) { | |
| 104 | + case 'static': | |
| 105 | + return `${config.application_root}/${domain.frontend_directory}` | |
| 106 | + case 'proxy': | |
| 107 | + return domain.frontend_origin | |
| 108 | + default: | |
| 109 | + throw new Error(`不支持的 frontend_mode:${config.frontend_mode}`) | |
| 110 | + } | |
| 111 | +} | |
| 112 | + | |
| 113 | +function renderFrontendLocation(config) { | |
| 114 | + switch (config.frontend_mode) { | |
| 115 | + case 'static': | |
| 116 | + return ` root $${config.nginx.root_variable}; | |
| 117 | + index index.html; | |
| 118 | + | |
| 119 | + location / { | |
| 120 | + try_files $uri $uri/ /index.html; | |
| 121 | + }` | |
| 122 | + case 'proxy': | |
| 123 | + return ` location / { | |
| 124 | + proxy_pass $${config.nginx.root_variable}; | |
| 125 | + proxy_http_version 1.1; | |
| 126 | + proxy_set_header Host $host; | |
| 127 | + proxy_set_header X-Real-IP $remote_addr; | |
| 128 | + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | |
| 129 | + proxy_set_header X-Forwarded-Proto $scheme; | |
| 130 | + proxy_set_header Upgrade $http_upgrade; | |
| 131 | + proxy_set_header Connection "upgrade"; | |
| 132 | + }` | |
| 133 | + default: | |
| 134 | + throw new Error(`不支持的 frontend_mode:${config.frontend_mode}`) | |
| 135 | + } | |
| 136 | +} | |
| 137 | + | |
| 138 | +function renderServerNames(config) { | |
| 139 | + return config.domains.map((domain) => ` ${domain.hostname}`).join('\n') | |
| 140 | +} | ... | ... |
package.json
| ... | ... | @@ -16,7 +16,10 @@ |
| 16 | 16 | "build": "turbo run build", |
| 17 | 17 | "test": "turbo run test", |
| 18 | 18 | "test:all": "vitest run", |
| 19 | - "lint": "eslint ." | |
| 19 | + "lint": "eslint .", | |
| 20 | + "schema:check": "node tools/scripts/schema-check.js", | |
| 21 | + "schema:update": "node tools/scripts/schema-update.js", | |
| 22 | + "dev:user": "node tools/scripts/voko-dev-user.js" | |
| 20 | 23 | }, |
| 21 | 24 | "devDependencies": { |
| 22 | 25 | "@drama/db": "workspace:*", | ... | ... |
tools/scripts/schema-check.js
0 → 100644
| 1 | +/** | |
| 2 | + * Valibot schema 快照 CI gate(架构红线 §10.2「锁死数据契约」)。 | |
| 3 | + * | |
| 4 | + * 行为三分支(对应任务 §8 测试要求): | |
| 5 | + * - contracts 缺席(无法 import `@drama/contracts` 或其无 `dumpSchemas`)→ 打印跳过日志,退出 0。 | |
| 6 | + * (contracts 未就绪时不阻塞 Wave A 启动。) | |
| 7 | + * - 已提交快照与当前 `dumpSchemas()` 输出一致 → 退出 0。 | |
| 8 | + * - 漂移 → 打印 diff 摘要,退出非 0。 | |
| 9 | + * | |
| 10 | + * 本脚本不持有任何 schema 定义——快照与 `dumpSchemas` 由 `@drama/contracts` 提供。 | |
| 11 | + * 可被单测复用:导出 `runSchemaCheck({ loadSchemas, readSnapshot, logger })`, | |
| 12 | + * CLI 入口在文件末尾用真实 loader 调用它。 | |
| 13 | + * | |
| 14 | + * @typedef {object} SchemaCheckDeps | |
| 15 | + * @property {() => Promise<Record<string, unknown> | null>} loadSchemas 取当前 schema 快照对象;contracts 缺席返回 null。 | |
| 16 | + * @property {() => string | null} readSnapshot 读已提交快照文本;不存在返回 null。 | |
| 17 | + * @property {{ log: (msg: string) => void, error: (msg: string) => void }} [logger] | |
| 18 | + * | |
| 19 | + * @typedef {object} SchemaCheckResult | |
| 20 | + * @property {0 | 1} code 退出码。 | |
| 21 | + * @property {'skipped' | 'match' | 'drift' | 'no-snapshot'} status | |
| 22 | + */ | |
| 23 | + | |
| 24 | +import path from 'node:path' | |
| 25 | +import fs from 'node:fs' | |
| 26 | +import { fileURLToPath } from 'node:url' | |
| 27 | + | |
| 28 | +const here = path.dirname(fileURLToPath(import.meta.url)) | |
| 29 | +const REPO_ROOT = path.resolve(here, '../..') | |
| 30 | +const SNAPSHOT_PATH = path.join(REPO_ROOT, 'packages/contracts/schema-snapshot.json') | |
| 31 | + | |
| 32 | +/** | |
| 33 | + * 纯函数核心:依赖全注入,便于单测三分支。 | |
| 34 | + * @param {SchemaCheckDeps} deps | |
| 35 | + * @returns {Promise<SchemaCheckResult>} | |
| 36 | + */ | |
| 37 | +export async function runSchemaCheck(deps) { | |
| 38 | + const logger = deps.logger || { log: console.log, error: console.error } | |
| 39 | + | |
| 40 | + const current = await deps.loadSchemas() | |
| 41 | + if (current == null) { | |
| 42 | + logger.log('[schema:check] @drama/contracts 未就位(缺 dumpSchemas),优雅跳过。') | |
| 43 | + return { code: 0, status: 'skipped' } | |
| 44 | + } | |
| 45 | + | |
| 46 | + const currentText = stableStringify(current) | |
| 47 | + const committed = deps.readSnapshot() | |
| 48 | + | |
| 49 | + if (committed == null) { | |
| 50 | + logger.log( | |
| 51 | + '[schema:check] 未找到已提交快照。首次请将 dumpSchemas() 输出写入 packages/contracts/schema-snapshot.json。', | |
| 52 | + ) | |
| 53 | + return { code: 0, status: 'no-snapshot' } | |
| 54 | + } | |
| 55 | + | |
| 56 | + if (normalize(committed) === normalize(currentText)) { | |
| 57 | + logger.log('[schema:check] schema 快照一致,无漂移。') | |
| 58 | + return { code: 0, status: 'match' } | |
| 59 | + } | |
| 60 | + | |
| 61 | + logger.error('[schema:check] 检测到 schema 漂移:当前 dumpSchemas() 与已提交快照不一致。') | |
| 62 | + logger.error('[schema:check] 若为预期变更,请更新 packages/contracts/schema-snapshot.json 并提交。') | |
| 63 | + return { code: 1, status: 'drift' } | |
| 64 | +} | |
| 65 | + | |
| 66 | +/** | |
| 67 | + * 稳定序列化:键排序,保证快照对键顺序不敏感(避免伪漂移)。 | |
| 68 | + * @param {unknown} value | |
| 69 | + * @returns {string} | |
| 70 | + */ | |
| 71 | +export function stableStringify(value) { | |
| 72 | + return JSON.stringify(sortKeys(value), null, 2) | |
| 73 | +} | |
| 74 | + | |
| 75 | +/** @param {unknown} v @returns {unknown} */ | |
| 76 | +function sortKeys(v) { | |
| 77 | + if (Array.isArray(v)) return v.map(sortKeys) | |
| 78 | + if (v && typeof v === 'object') { | |
| 79 | + /** @type {Record<string, unknown>} */ | |
| 80 | + const out = {} | |
| 81 | + for (const k of Object.keys(/** @type {Record<string, unknown>} */ (v)).sort()) { | |
| 82 | + out[k] = sortKeys(/** @type {Record<string, unknown>} */ (v)[k]) | |
| 83 | + } | |
| 84 | + return out | |
| 85 | + } | |
| 86 | + return v | |
| 87 | +} | |
| 88 | + | |
| 89 | +/** @param {string} text @returns {string} */ | |
| 90 | +function normalize(text) { | |
| 91 | + return text.replace(/\r\n/g, '\n').trim() | |
| 92 | +} | |
| 93 | + | |
| 94 | +/** | |
| 95 | + * 真实 loader:尝试动态 import `@drama/contracts` 的 `dumpSchemas`。 | |
| 96 | + * 任何解析失败(包未发布 / 无导出)都视为「缺席」,返回 null。 | |
| 97 | + * @returns {Promise<Record<string, unknown> | null>} | |
| 98 | + */ | |
| 99 | +async function loadContractsSchemas() { | |
| 100 | + try { | |
| 101 | + // @ts-ignore — @drama/contracts 是可选依赖:未就位时 import 抛错并走 catch 返 null(设计如此)。 | |
| 102 | + const mod = await import('@drama/contracts') | |
| 103 | + if (typeof mod.dumpSchemas !== 'function') return null | |
| 104 | + return await mod.dumpSchemas() | |
| 105 | + } catch { | |
| 106 | + try { | |
| 107 | + const mod = await import(pathToFileUrl(path.join(REPO_ROOT, 'packages/contracts/src/index.js'))) | |
| 108 | + if (typeof mod.dumpSchemas !== 'function') return null | |
| 109 | + return await mod.dumpSchemas() | |
| 110 | + } catch { | |
| 111 | + return null | |
| 112 | + } | |
| 113 | + } | |
| 114 | +} | |
| 115 | + | |
| 116 | +/** | |
| 117 | + * @param {string} filePath | |
| 118 | + * @returns {string} | |
| 119 | + */ | |
| 120 | +function pathToFileUrl(filePath) { | |
| 121 | + return new URL(`file://${path.resolve(filePath)}`).href | |
| 122 | +} | |
| 123 | + | |
| 124 | +/** @returns {string | null} */ | |
| 125 | +function readCommittedSnapshot() { | |
| 126 | + try { | |
| 127 | + return fs.readFileSync(SNAPSHOT_PATH, 'utf8') | |
| 128 | + } catch { | |
| 129 | + return null | |
| 130 | + } | |
| 131 | +} | |
| 132 | + | |
| 133 | +// CLI 入口:仅当作为脚本直接运行时执行(被 import 作单测时不触发)。 | |
| 134 | +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { | |
| 135 | + runSchemaCheck({ | |
| 136 | + loadSchemas: loadContractsSchemas, | |
| 137 | + readSnapshot: readCommittedSnapshot, | |
| 138 | + }).then((r) => { | |
| 139 | + process.exit(r.code) | |
| 140 | + }) | |
| 141 | +} | ... | ... |
tools/scripts/schema-update.js
0 → 100644
| 1 | +import fs from 'node:fs' | |
| 2 | +import path from 'node:path' | |
| 3 | +import { fileURLToPath, pathToFileURL } from 'node:url' | |
| 4 | +import { stableStringify } from './schema-check.js' | |
| 5 | + | |
| 6 | +const here = path.dirname(fileURLToPath(import.meta.url)) | |
| 7 | +const repoRoot = path.resolve(here, '../..') | |
| 8 | +const snapshotPath = path.join(repoRoot, 'packages/contracts/schema-snapshot.json') | |
| 9 | + | |
| 10 | +async function main() { | |
| 11 | + const contracts = await import(pathToFileURL(path.join(repoRoot, 'packages/contracts/src/index.js')).href) | |
| 12 | + if (typeof contracts.dumpSchemas !== 'function') { | |
| 13 | + throw new Error('schema-update: packages/contracts does not export dumpSchemas') | |
| 14 | + } | |
| 15 | + fs.writeFileSync(snapshotPath, `${stableStringify(contracts.dumpSchemas())}\n`) | |
| 16 | + console.log(`[schema:update] wrote ${path.relative(repoRoot, snapshotPath)}`) | |
| 17 | +} | |
| 18 | + | |
| 19 | +main().catch((err) => { | |
| 20 | + console.error(err instanceof Error ? err.stack : err) | |
| 21 | + process.exit(1) | |
| 22 | +}) | ... | ... |
tools/scripts/voko-dev-user.js
0 → 100644
| 1 | +#!/usr/bin/env node | |
| 2 | +import fs from 'node:fs' | |
| 3 | +import { randomBytes, randomUUID, scryptSync } from 'node:crypto' | |
| 4 | +import path from 'node:path' | |
| 5 | +import { createRequire } from 'node:module' | |
| 6 | +import { fileURLToPath } from 'node:url' | |
| 7 | +import { devMysqlUrl } from '@drama/db' | |
| 8 | + | |
| 9 | +const DEFAULT_DATABASE_URL = devMysqlUrl('voko') | |
| 10 | +const here = path.dirname(fileURLToPath(import.meta.url)) | |
| 11 | +const repoRoot = path.resolve(here, '../..') | |
| 12 | +const envFile = path.resolve(repoRoot, 'apps/voko-server/.env') | |
| 13 | +const requireFromVokoServer = createRequire(new URL('../../apps/voko-server/package.json', import.meta.url)) | |
| 14 | +const { createDb } = await import(requireFromVokoServer.resolve('@drama/db')) | |
| 15 | + | |
| 16 | +function hashPassword(password) { | |
| 17 | + const salt = randomBytes(16) | |
| 18 | + const derived = scryptSync(String(password), salt, 64) | |
| 19 | + return `scrypt$${salt.toString('hex')}$${derived.toString('hex')}` | |
| 20 | +} | |
| 21 | + | |
| 22 | +async function main() { | |
| 23 | + const email = process.argv[2] || 'admin@example.test' | |
| 24 | + const password = process.argv[3] || 'admin123' | |
| 25 | + const displayName = process.argv[4] || 'VOKO Admin' | |
| 26 | + const role = process.argv[5] || 'admin' | |
| 27 | + if (!['admin', 'operator', 'user'].includes(role)) { | |
| 28 | + throw new Error(`voko dev user role 必须是 admin、operator 或 user: ${role}`) | |
| 29 | + } | |
| 30 | + const databaseUrl = process.env.DATABASE_URL || readDotenv(envFile).DATABASE_URL || DEFAULT_DATABASE_URL | |
| 31 | + const db = createDb({ url: databaseUrl, pool: { min: 0, max: 1 } }) | |
| 32 | + const normalizedEmail = email.trim().toLowerCase() | |
| 33 | + const now = new Date() | |
| 34 | + const user = { | |
| 35 | + id: randomUUID(), | |
| 36 | + email: normalizedEmail, | |
| 37 | + display_name: displayName, | |
| 38 | + status: 'active', | |
| 39 | + password_hash: hashPassword(password), | |
| 40 | + locale: 'zh', | |
| 41 | + email_verified_at: now, | |
| 42 | + role, | |
| 43 | + created_at: now, | |
| 44 | + updated_at: now, | |
| 45 | + } | |
| 46 | + | |
| 47 | + try { | |
| 48 | + await db('users').insert(user).onConflict('email').merge({ | |
| 49 | + display_name: user.display_name, | |
| 50 | + status: user.status, | |
| 51 | + password_hash: user.password_hash, | |
| 52 | + locale: user.locale, | |
| 53 | + email_verified_at: db.raw('COALESCE(email_verified_at, ?)', [now]), | |
| 54 | + role: user.role, | |
| 55 | + updated_at: now, | |
| 56 | + }) | |
| 57 | + console.log(`voko dev user ready: ${normalizedEmail}, role=${role}`) | |
| 58 | + } finally { | |
| 59 | + await db.destroy() | |
| 60 | + } | |
| 61 | +} | |
| 62 | + | |
| 63 | +function readDotenv(file) { | |
| 64 | + if (!fs.existsSync(file)) return {} | |
| 65 | + const env = {} | |
| 66 | + for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) { | |
| 67 | + const line = raw.trim() | |
| 68 | + if (!line || line.startsWith('#')) continue | |
| 69 | + const separator = line.indexOf('=') | |
| 70 | + if (separator < 0) continue | |
| 71 | + const key = line.slice(0, separator).trim() | |
| 72 | + let value = line.slice(separator + 1).trim() | |
| 73 | + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { | |
| 74 | + value = value.slice(1, -1) | |
| 75 | + } | |
| 76 | + env[key] = value | |
| 77 | + } | |
| 78 | + return env | |
| 79 | +} | |
| 80 | + | |
| 81 | +main().catch(error => { | |
| 82 | + console.error(error) | |
| 83 | + process.exit(1) | |
| 84 | +}) | ... | ... |