feat(deploy): 产物包部署链路与 naku 打包 CLI
Docker Compose 五服务 + Debian slim 单文件运行时;migrate 幂等门闩; naku pack/build(citty + clack);部署方案文档入档。
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# 构建上下文只保留 release/ 与 Caddyfile(Dockerfile 仅 COPY 这两处)
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules
|
||||
**/node_modules
|
||||
apps
|
||||
packages
|
||||
scripts
|
||||
docs
|
||||
*.log
|
||||
api*.json
|
||||
session-archive
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
.claude
|
||||
.agents
|
||||
agent
|
||||
+5
-2
@@ -1,12 +1,14 @@
|
||||
# ── new-api-key-usage · 环境变量示例 ──────────────────────────
|
||||
# 复制为 .env 后填写(.env 已被 .gitignore,勿提交)
|
||||
# 开发:任意可用的 PostgreSQL,DATABASE_URL 指向即可
|
||||
# 部署:放在服务器约定目录(不进产物包);chmod 600;与库同等备份
|
||||
|
||||
# 本服务端口(避开 3000/3001 等常见占用)
|
||||
# 本服务端口(避开 3000/3001 等常见占用;compose 内 server 固定 18456)
|
||||
PORT=18456
|
||||
|
||||
# PostgreSQL 连接串——数据库配置的唯一来源
|
||||
# 指向你本机/已有的库(主机 127.0.0.1 或 localhost)
|
||||
# 开发:指向你本机/已有的库(主机 127.0.0.1 或 localhost)
|
||||
# 部署:同一份即可;compose 会把主机改写为容器服务名 postgres
|
||||
# 密码含 @ : / 等字符时请 URL 编码
|
||||
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/new_api_key_usage
|
||||
|
||||
@@ -22,6 +24,7 @@ NEWAPI_BASE=http://127.0.0.1:8080
|
||||
# 会话 cookie 是否带 Secure(默认 true)。
|
||||
# 本地 http://localhost 或 127.0.0.1 调试:现代浏览器可保留 true;
|
||||
# 局域网 IP / 自定义域名 / 出现「登录后立刻 401」时改为 false。
|
||||
# HTTPS + 域名部署务必 true。
|
||||
COOKIE_SECURE=false
|
||||
|
||||
# 业务日志级别:debug | info | warning | error | fatal(访问日志另由 hono/logger 输出)
|
||||
|
||||
@@ -17,8 +17,11 @@
|
||||
# 依赖与构建产物
|
||||
node_modules/
|
||||
dist/
|
||||
bin/
|
||||
build/
|
||||
.turbo/
|
||||
release/
|
||||
.bun-cache/
|
||||
|
||||
# 测试与工具
|
||||
coverage/
|
||||
|
||||
@@ -33,6 +33,10 @@ Bun 工具链(选型见 `docs/design/技术选型.md`)。**Windows 上 `bun`
|
||||
| `bun run dev:engine` | 开发跑同步引擎(独立进程,读 `SYNC_*` env) |
|
||||
| `bun run dev:web` | 开发跑前端 Vite |
|
||||
| `bun run build:web` | 前端类型检查 + 产物构建 |
|
||||
| `bun run build:server` | 编译后端单文件 → `bin/naku-server`(当前平台) |
|
||||
| `bun run build:engine` | 编译同步引擎单文件 → `bin/naku-engine`(当前平台) |
|
||||
| `bun run naku` | 打包/构建 CLI(citty + clack):`naku pack` 交互打部署包 → `release/`;`naku build` 本地构建 → `bin/`;支持 `--yes` / `--target` / `--only` |
|
||||
| `bun run pack:release` | 非交互打部署包(= `naku pack --yes`,目标默认 linux-x64) |
|
||||
|
||||
环境变量见 `.env.example`(复制为 `.env` 后填写)。
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# 站点地址按部署环境改:
|
||||
# 本地/内网 HTTP → :80
|
||||
# 公网 HTTPS → your.domain.example(Caddy 自动签证书)
|
||||
:80 {
|
||||
root * /srv
|
||||
|
||||
# 接口反代到 server 容器
|
||||
handle /api/* {
|
||||
reverse_proxy server:18456
|
||||
}
|
||||
|
||||
# SPA:非 /api 的路径回退 index.html
|
||||
handle {
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
# 语法:docker/dockerfile:1
|
||||
# 只装配 release/ 产物,构建期不编译业务代码
|
||||
|
||||
# ---------- 应用运行时 ----------
|
||||
# 用 Debian slim:与 Bun --compile(glibc)同源,兼容面更稳;
|
||||
# CA 与 libstdc++ 显式安装,不依赖 slim 是否自带
|
||||
FROM debian:bookworm-slim AS app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libstdc++6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY release/naku-server release/naku-engine release/naku-migrate /app/
|
||||
COPY release/drizzle /app/drizzle
|
||||
ENV MIGRATIONS_FOLDER=/app/drizzle
|
||||
CMD ["/app/naku-server"]
|
||||
|
||||
# ---------- 网关(Caddy + 前端静态) ----------
|
||||
FROM caddy:2-alpine AS proxy
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY release/dist /srv
|
||||
@@ -79,12 +79,49 @@ bun run dev:web # 前端 Vite(5175)
|
||||
|
||||
更多命令与协作约定见 [`AGENTS.md`](AGENTS.md)。
|
||||
|
||||
## 部署
|
||||
|
||||
方案详文见 [`docs/design/部署方案.md`](docs/design/部署方案.md)。形态一句话:**本地打产物包 → 覆盖服务器 `release/` → 一条 compose 命令 → 看容器**。
|
||||
|
||||
### 本地打包
|
||||
|
||||
```bash
|
||||
bun run naku pack
|
||||
# 交互选:目标平台 · 打包内容 · 是否 tar.gz
|
||||
# 产出 release/:dist · naku-server · naku-engine · naku-migrate · drizzle/
|
||||
|
||||
# 非交互(CI / 一键):
|
||||
bun run pack:release
|
||||
# 或 bun run naku pack --yes --target=bun-linux-arm64 --only=dist,naku-server
|
||||
```
|
||||
|
||||
### 服务器
|
||||
|
||||
首次安装:把 `docker-compose.yml` · `Dockerfile` · `.dockerignore` · `Caddyfile` · `entrypoint-*.sh` 放到约定目录(如 `/opt/naku/`),再放 `release/`,填写 `.env`(`chmod 600`)。日常更新只覆盖 `release/`。
|
||||
|
||||
```bash
|
||||
cd /opt/naku
|
||||
docker compose up -d --build
|
||||
docker compose ps # migrate 应为 exited(0),其余 running/healthy
|
||||
```
|
||||
|
||||
- 页面:`http://<服务器>/`;接口文档:`http://<服务器>/api/docs`
|
||||
- 迁移:由 `migrate` 一次性服务在启动时幂等执行(journal),重复执行不会多灌
|
||||
- HTTPS:`Caddyfile` 站点行改成域名,`.env` 设 `COOKIE_SECURE=true`,再执行固定命令
|
||||
|
||||
### 注意
|
||||
|
||||
1. `.env` 不进产物包、不进 git;含 `SK_KEY_ENCRYPTION_SECRET`,须与数据库同等备份
|
||||
2. `COOKIE_SECURE` 必须与访问方式匹配:HTTP + `true` 会「登录后立刻 401」
|
||||
3. `DATABASE_URL` 密码含 `@` `:` `/` 等字符时做 URL 编码
|
||||
|
||||
## 文档地图
|
||||
|
||||
| 文档 | 什么时候读 | 状态 |
|
||||
|------|-----------|------|
|
||||
| [`docs/design/需求清单.md`](docs/design/需求清单.md) | **需求权威索引与裁决档案**——R1–R16 定案;含支付拆表与成员档案/空壳删除裁决 | 定稿(2026-10-10) |
|
||||
| [`docs/design/技术选型.md`](docs/design/技术选型.md) | 技术栈裁决:全栈 TS;Bun + Hono + Drizzle/pg;前端 Vue 3 + antdv-next;分层落地与回退 | 定稿(2026-10-02) |
|
||||
| [`docs/design/部署方案.md`](docs/design/部署方案.md) | 母体交付与更新:产物包、服务器目录、容器编排、迁移时机、安全约定 | 已实现(定稿 2026-10-12) |
|
||||
| [`docs/design/api_keys表字段设计.md`](docs/design/api_keys表字段设计.md) | key 登记、生命周期与成员归属 | 定稿(2026-10-01) |
|
||||
| [`docs/design/usage_logs表字段设计.md`](docs/design/usage_logs表字段设计.md) | 日志明细、来源提列、去重与写入策略 | 同上 |
|
||||
| [`docs/design/payments建模评估.md`](docs/design/payments建模评估.md) | 支付建模复盘:订阅权益 vs 充值入账两分;拆表已采纳 | 定稿(2026-10-09) |
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
"": {
|
||||
"name": "new-api-key-usage",
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^1.8.1",
|
||||
"@types/bun": "^1.4.2",
|
||||
"citty": "^0.2.2",
|
||||
"typescript": "^7.0.2",
|
||||
},
|
||||
},
|
||||
@@ -180,6 +182,10 @@
|
||||
|
||||
"@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="],
|
||||
|
||||
"@clack/core": ["@clack/core@1.5.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ=="],
|
||||
|
||||
"@clack/prompts": ["@clack/prompts@1.8.1", "", { "dependencies": { "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ=="],
|
||||
|
||||
"@csstools/color-helpers": ["@csstools/color-helpers@6.1.2", "", {}, "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow=="],
|
||||
|
||||
"@csstools/css-calc": ["@csstools/css-calc@3.4.3", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2" } }, "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA=="],
|
||||
@@ -714,6 +720,8 @@
|
||||
|
||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
"citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
@@ -822,6 +830,12 @@
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
|
||||
|
||||
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
|
||||
|
||||
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
|
||||
|
||||
"fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
@@ -1120,6 +1134,8 @@
|
||||
|
||||
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||
|
||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# 一键部署:docker compose up -d --build
|
||||
# 验收:docker compose ps(migrate 应为 exited 0)
|
||||
# 环境变量写在 .env(不进产物包;生产务必改密钥与 COOKIE_SECURE)
|
||||
# 注意:DATABASE_URL 密码含 @ : / 等字符时需 URL 编码
|
||||
|
||||
services:
|
||||
postgres:
|
||||
container_name: naku-postgres
|
||||
image: postgres:17-alpine
|
||||
entrypoint: ["/bin/sh", "/entrypoint-pg.sh"]
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL in .env}
|
||||
volumes:
|
||||
- ./entrypoint-pg.sh:/entrypoint-pg.sh:ro
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$(echo $$DATABASE_URL | sed -E 's#^[^:]+://([^:]+):.*#\\1#')"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
restart: unless-stopped
|
||||
|
||||
migrate:
|
||||
container_name: naku-migrate
|
||||
build:
|
||||
context: .
|
||||
target: app
|
||||
entrypoint: ["/bin/sh", "/entrypoint-app.sh"]
|
||||
command: ["/app/naku-migrate"]
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
MIGRATIONS_FOLDER: /app/drizzle
|
||||
volumes:
|
||||
- ./entrypoint-app.sh:/entrypoint-app.sh:ro
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
server:
|
||||
container_name: naku-server
|
||||
build:
|
||||
context: .
|
||||
target: app
|
||||
entrypoint: ["/bin/sh", "/entrypoint-app.sh"]
|
||||
command: ["/app/naku-server"]
|
||||
environment:
|
||||
PORT: "18456"
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
SK_KEY_ENCRYPTION_SECRET: ${SK_KEY_ENCRYPTION_SECRET:?set SK_KEY_ENCRYPTION_SECRET in .env}
|
||||
NEWAPI_BASE: ${NEWAPI_BASE:?set NEWAPI_BASE in .env}
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-true}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
ADMIN_INIT_USERNAME: ${ADMIN_INIT_USERNAME:-}
|
||||
ADMIN_INIT_PASSWORD: ${ADMIN_INIT_PASSWORD:-}
|
||||
MIGRATIONS_FOLDER: /app/drizzle
|
||||
volumes:
|
||||
- ./entrypoint-app.sh:/entrypoint-app.sh:ro
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
restart: unless-stopped
|
||||
|
||||
engine:
|
||||
container_name: naku-engine
|
||||
build:
|
||||
context: .
|
||||
target: app
|
||||
entrypoint: ["/bin/sh", "/entrypoint-app.sh"]
|
||||
command: ["/app/naku-engine"]
|
||||
environment:
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
SK_KEY_ENCRYPTION_SECRET: ${SK_KEY_ENCRYPTION_SECRET}
|
||||
NEWAPI_BASE: ${NEWAPI_BASE}
|
||||
SYNC_INTERVAL_MS: ${SYNC_INTERVAL_MS:-300000}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
MIGRATIONS_FOLDER: /app/drizzle
|
||||
volumes:
|
||||
- ./entrypoint-app.sh:/entrypoint-app.sh:ro
|
||||
depends_on:
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
restart: unless-stopped
|
||||
|
||||
caddy:
|
||||
container_name: naku-caddy
|
||||
build:
|
||||
context: .
|
||||
target: proxy
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
depends_on:
|
||||
- server
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
@@ -0,0 +1,327 @@
|
||||
# 部署方案
|
||||
|
||||
> 母体(完整前后端 + PostgreSQL)的交付与更新方案:产物包形态、服务器目录约定、容器编排、迁移时机、安全边界与固定操作流程。**范围**:只定「东西怎么装到运营方服务器、怎么更新」;不改业务语义,不覆盖 R13 的 CFW 抽取版部署面。设计对齐作者现场运维心智——**本地产物 → 传到服务器约定目录 → 一条固定命令 → 看容器状态**。
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 状态 | 已实现(定稿 2026-10-12) |
|
||||
| 关联 | `技术选型.md`(Bun / Hono / Drizzle / Vue)· `同步引擎设计.md`(独立 `apps/engine` 进程)· `需求清单.md` R13/R14 · `users表字段设计.md`(部署期初始化)· `README.md` |
|
||||
| 产出 | 产物包布局 · 服务器目录 · Dockerfile / Compose / Caddy 要点 · 打包与更新流程 · 迁移与安全约定 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计约束(来自现场经验与项目事实)
|
||||
|
||||
1. **操作者心智**(作者工作流,优先对齐):
|
||||
- 前端:本地打包 `dist/` → 放到服务器指定目录 → 执行固定命令 → `docker ps` 看容器全绿;
|
||||
- 后端:拿到 `jar` 类产物 → 上传指定目录 → 同一固定命令 → 看容器。
|
||||
2. **仓库事实**:`apps/server` 与 `apps/engine` 两个独立进程;前端为静态 `dist`(server 不托管静态);数据在 PostgreSQL;配置全走 env;`packages/db` 已有按 journal 的幂等 `runMigrations`。
|
||||
3. **能力边界**:作者是前端,Docker 不娴熟;服务器侧只需 Docker,不要求 Bun / Node / 源码构建能力。
|
||||
4. **协作约定**:交付物保证「一条命令链」可部署(AGENTS);密钥 `SK_KEY_ENCRYPTION_SECRET` 丢失 = 已登记 key 全部无法解密。
|
||||
|
||||
由此收敛出的形态一句话:**常驻 Docker 编排 + 每次更新只替换产物包 + 一条 compose 命令完成重建/重启/迁表。**
|
||||
|
||||
---
|
||||
|
||||
## 2. 总体结构
|
||||
|
||||
```text
|
||||
本地(打包) 服务器(运营方)
|
||||
────────── ────────────────
|
||||
bun run pack:release /opt/naku/ ← 约定目录
|
||||
├─ 前端 dist/ ├─ docker-compose.yml
|
||||
├─ naku-server(linux 单文件) ├─ Dockerfile
|
||||
├─ naku-engine(linux 单文件) ├─ .dockerignore
|
||||
├─ naku-migrate(linux 单文件) ├─ Caddyfile
|
||||
└─ drizzle/(迁移 SQL + journal) ├─ entrypoint-pg.sh
|
||||
│ ├─ entrypoint-app.sh
|
||||
▼ ├─ .env ← 密钥在此,不进包
|
||||
release/ ──── 上传覆盖 ──────────► └─ release/ ← 每次更新整目录覆盖
|
||||
|
||||
固定命令: docker compose up -d --build
|
||||
验收: docker compose ps
|
||||
```
|
||||
|
||||
| 角色 | 职责 | 谁改 |
|
||||
|------|------|------|
|
||||
| 常驻编排(compose / Dockerfile / Caddyfile / entrypoint-*.sh / .env) | 描述系统怎么跑 | 装机时放好;改架构时才动 |
|
||||
| 产物包 `release/` | 当前要跑的版本 | 每次发版整目录替换 |
|
||||
|
||||
首次安装与日常更新**同一套命令**;差别只是首次多一步「把常驻编排放到服务器」。
|
||||
|
||||
---
|
||||
|
||||
## 3. 产物包 `release/`
|
||||
|
||||
本地 `bun run pack:release` 产出,内容固定为:
|
||||
|
||||
```text
|
||||
release/
|
||||
dist/ # apps/web 构建产物(index.html · assets/…)
|
||||
naku-server # Bun --compile,目标平台 linux-x64
|
||||
naku-engine # 同上
|
||||
naku-migrate # 同上;只跑迁移后退出
|
||||
drizzle/ # packages/db/drizzle/ 的 SQL + meta/_journal.json
|
||||
```
|
||||
|
||||
### 3.1 打包约定
|
||||
|
||||
| 项 | 约定 |
|
||||
|----|------|
|
||||
| 命令入口 | 优先 `bun run naku pack`(交互:平台 / 内容 / tar.gz);一键等价 `bun run pack:release` 或 `naku pack --yes` |
|
||||
| 脚本落点 | `scripts/cli.ts`(citty + clack)· `scripts/pack-core.ts`(纯打包逻辑)· `scripts/pack-release.ts`(非交互兼容入口) |
|
||||
| 目标平台 | 默认 `bun-linux-x64`;`--target=` 可切 `bun-linux-arm64` / `bun-darwin-*` / `bun-windows-*`(含 arm64) |
|
||||
| 构建顺序 | 清空输出目录 → `build:web` → 三次 `--compile` → 拷 `drizzle/` →(可选)tar.gz |
|
||||
| 禁止入包 | `.env` · 源码 · `node_modules` · 任何密钥 |
|
||||
| 校验 | 打包后检查五条目都在且非空(Windows 本机编译允许 `.exe` 后缀);缺一即失败退出 |
|
||||
| 本地试跑 | `naku build` / `build:server` / `build:engine` → `bin/`(当前平台),与部署包 `release/` 分离 |
|
||||
|
||||
### 3.2 为何是单文件而不是「服务器上构建」或「上传源码」
|
||||
|
||||
- 对齐 `jar` 心智:产物即交付物,服务器零构建依赖;
|
||||
- 更新体积小、失败面小(不跑 `bun install` / `vite build`);
|
||||
- 运行时镜像只需 Debian slim + `ca-certificates` + `libstdc++6`,不需要 Bun。
|
||||
|
||||
代价:目标平台写在打包脚本里;换 CPU 架构需重新打包(在脚本参数上留出口即可)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 服务器目录(`/opt/naku/`)
|
||||
|
||||
路径可改,下文以 `/opt/naku` 指代「约定目录」。
|
||||
|
||||
```text
|
||||
/opt/naku/
|
||||
docker-compose.yml
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
Caddyfile
|
||||
entrypoint-pg.sh
|
||||
entrypoint-app.sh
|
||||
.env
|
||||
release/ ← 上传覆盖,不入库
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 配置文件设计
|
||||
|
||||
### 5.1 `Dockerfile`(两阶段,只吃 `release/`)
|
||||
|
||||
```dockerfile
|
||||
# ---------- 应用运行时 ----------
|
||||
# 用 Debian slim:与 Bun --compile(glibc)同源,兼容面更稳;
|
||||
# CA 与 libstdc++ 显式安装,不依赖 slim 是否自带
|
||||
FROM debian:bookworm-slim AS app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libstdc++6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY release/naku-server release/naku-engine release/naku-migrate /app/
|
||||
COPY release/drizzle /app/drizzle
|
||||
ENV MIGRATIONS_FOLDER=/app/drizzle
|
||||
CMD ["/app/naku-server"]
|
||||
|
||||
# ---------- 网关(Caddy + 前端静态) ----------
|
||||
FROM caddy:2-alpine AS proxy
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY release/dist /srv
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **构建期不编译业务代码**,只装配产物;`docker compose up -d --build` 的语义是「用当前 `release/` 重建镜像并重启」;
|
||||
- `app` 阶段一个镜像供 `server` / `engine` / `migrate` 三用,靠 compose 的 `command` 区分;
|
||||
- 应用运行时选 **`debian:bookworm-slim`**(glibc,对齐 Bun `--compile` 目标);`ca-certificates`(HTTPS 证书校验)与 `libstdc++6`(单文件 C++ 运行时)显式安装——两个都不指望镜像自带;
|
||||
- 网关仍用官方 `caddy:2-alpine`(与应用基底无关,体积友好);
|
||||
- PostgreSQL 使用官方镜像,不进本 Dockerfile。
|
||||
|
||||
### 5.2 `docker-compose.yml`(五服务)
|
||||
|
||||
| 服务 | 来源 | command | 说明 |
|
||||
|------|------|---------|------|
|
||||
| `postgres` | `postgres:17-alpine` | 官方入口 | 卷 `pgdata`;`healthcheck` 用 `pg_isready` |
|
||||
| `migrate` | `target: app` | `/app/naku-migrate` | **一次性**:`restart: "no"`;迁完退出 |
|
||||
| `server` | `target: app` | `/app/naku-server` | 管理面 / 成员视图 API |
|
||||
| `engine` | `target: app` | `/app/naku-engine` | 轮询同步 |
|
||||
| `caddy` | `target: proxy` | 官方入口 | **唯一对外**:`80` / `443` |
|
||||
|
||||
依赖关系:
|
||||
|
||||
```text
|
||||
postgres (healthy) ──► migrate (completed successfully) ──► server ──► caddy
|
||||
└──────► engine
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
1. **`migrate` 独立服务**,而不是塞进 server 启动:语义是「部署时迁表」,避免 server/engine 双进程抢迁;`depends_on: service_completed_successfully` 保证先迁后跑。
|
||||
2. **环境变量**:`DATABASE_URL` · `SK_KEY_ENCRYPTION_SECRET` · `NEWAPI_BASE` · `COOKIE_SECURE` · `LOG_LEVEL` · `SYNC_INTERVAL_MS` · 可选 `ADMIN_INIT_*`——均从 `.env` 注入 `server` / `engine`(`migrate` 只需要 `DATABASE_URL`)。
|
||||
3. **`DATABASE_URL` 单源**:用户 / 密码 / 库名都写在这一串里;`entrypoint-pg.sh` 解析出 `POSTGRES_USER/PASSWORD/DB` 喂给官方库镜像,`entrypoint-app.sh` 把主机改写为 compose 服务名 `postgres`。开发与部署共用同一份 `.env` 语义。
|
||||
4. 端口:仅 `caddy` 映射 `80:80`、`443:443`;`server` / `engine` / `postgres` 均不对外。
|
||||
5. **容器名固定**(`container_name`):`naku-postgres` / `naku-migrate` / `naku-server` / `naku-engine` / `naku-caddy`——单副本约定下使用;服务间 DNS 仍用服务名(如 `server:18456`)。
|
||||
|
||||
### 5.3 `Caddyfile`
|
||||
|
||||
```caddyfile
|
||||
# 站点地址按部署环境改:
|
||||
# 本地/内网 HTTP → :80
|
||||
# 公网 HTTPS → your.domain.example(Caddy 自动签证书)
|
||||
:80 {
|
||||
root * /srv
|
||||
|
||||
handle /api/* {
|
||||
reverse_proxy server:18456
|
||||
}
|
||||
|
||||
handle {
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
与 `API契约-公共约定` 的命名空间约定一致:`/api/*` 归接口,其余归 SPA(非 `/api` 回退 `index.html`)。
|
||||
|
||||
### 5.4 `entrypoint-pg.sh` / `entrypoint-app.sh`
|
||||
|
||||
| 脚本 | 职责 |
|
||||
|------|------|
|
||||
| `entrypoint-pg.sh` | 从 `DATABASE_URL` 拆出 `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB`,`exec` 官方 `docker-entrypoint.sh postgres` |
|
||||
| `entrypoint-app.sh` | 将 `DATABASE_URL` 的主机段改写为 `postgres`(保留端口与路径),再 `exec "$@"` 启动传入的二进制 |
|
||||
|
||||
二者属**常驻编排**,打进镜像或只读挂载皆可(建议只读挂载,改脚本不用重建镜像)。
|
||||
|
||||
### 5.5 `packages/db/src/migrate-cli.ts`(新增入口)
|
||||
|
||||
```text
|
||||
loadEnv → createDb(DATABASE_URL) → runMigrations(db, MIGRATIONS_FOLDER) → 退出码
|
||||
```
|
||||
|
||||
- 复用已有 `runMigrations`(Drizzle migrator,**按 journal 幂等**:已应用的迁移号跳过);
|
||||
- 失败打日志并以非 0 退出,使 `migrate` 服务失败 → `server`/`engine` 不会启动(`service_completed_successfully` 门闩)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 操作流程
|
||||
|
||||
### 6.1 首次安装
|
||||
|
||||
```bash
|
||||
# 本地
|
||||
bun run pack:release
|
||||
|
||||
# 服务器
|
||||
# 将 docker-compose.yml · Dockerfile · .dockerignore · Caddyfile · entrypoint-*.sh 放入 /opt/naku/
|
||||
# 将 release/ 上传到 /opt/naku/release/
|
||||
cp .env.example /opt/naku/.env # 再编辑填写
|
||||
chmod 600 /opt/naku/.env
|
||||
|
||||
cd /opt/naku
|
||||
docker compose up -d --build
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
### 6.2 日常更新(与首次同一命令)
|
||||
|
||||
```bash
|
||||
# 本地
|
||||
bun run pack:release
|
||||
# 上传,整目录覆盖 /opt/naku/release/
|
||||
|
||||
# 服务器(固定命令)
|
||||
cd /opt/naku
|
||||
docker compose up -d --build
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
- 有表变更时,新 `release/drizzle/` 自动带新 SQL,`migrate` 启动时补跑——无需手工;
|
||||
- 观察构建/迁移日志:`docker compose logs migrate`;同步:`docker compose logs -f engine`。
|
||||
|
||||
### 6.3 HTTPS(可选)
|
||||
|
||||
1. `Caddyfile` 站点行 `:80` → 域名;
|
||||
2. `.env` 中 `COOKIE_SECURE=true`;
|
||||
3. 重新执行固定命令。
|
||||
|
||||
### 6.4 验收标准
|
||||
|
||||
- `docker compose ps`:`postgres` healthy,`server` / `engine` / `caddy` running,`migrate` exited(0);
|
||||
- 浏览器打开站点可见页面;`/api/docs` 可访问;
|
||||
- `docker compose logs engine` 出现就绪/轮次日志(已配置 key 时)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 迁移策略(裁决)
|
||||
|
||||
| 问题 | 裁决 |
|
||||
|------|------|
|
||||
| 会不会「多灌几次」? | **不会。** 使用 Drizzle `migrate()`(journal 表),重复执行只应用未跑过的迁移号 |
|
||||
| 谁来跑? | compose 一次性服务 `migrate`(`naku-migrate` 单文件),不随 server/engine 长驻 |
|
||||
| 何时跑? | 每次固定命令(含首次)启动/更新时 |
|
||||
| 不用什么? | **不用 `db:push` 进部署链**——它是 schema diff 硬推,不适合生产更新命令 |
|
||||
| 失败怎么办? | `migrate` 非 0 退出 → `server`/`engine` 不启动;修复 SQL 或数据后重跑固定命令 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 安全约定
|
||||
|
||||
| 点 | 约定 |
|
||||
|----|------|
|
||||
| `.env` 位置 | 只在服务器约定目录;**不进产物包、不进 git** |
|
||||
| 权限 | `chmod 600 .env`,仅部署用户可读 |
|
||||
| `SK_KEY_ENCRYPTION_SECRET` | 生成一次、入库后不可更换;**与数据库同等备份**(`.env` 必须备份) |
|
||||
| 对外暴露 | 仅 Caddy 的 80/443;库与应用进程不映射公网端口 |
|
||||
| `COOKIE_SECURE` | 必须与访问方式匹配:HTTP + `true` 会「登录后立刻 401」;HTTPS/域名用 `true` |
|
||||
| 日志 | 不打印密钥与完整 `DATABASE_URL`(含密码) |
|
||||
| 范围说明 | 单运营方内部服务器场景;不上 vault/KMS。若未来有多租户或公有多环境,再升档 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 与开发方式的关系
|
||||
|
||||
| | 开发 | 部署 |
|
||||
|--|------|------|
|
||||
| 进程 | `bun run dev:server` / `dev:engine` / `dev:web` | compose:`server` / `engine` / `caddy` |
|
||||
| 库 | 任意 PostgreSQL,`DATABASE_URL` 指向即可 | compose 内 `postgres`,同一 `DATABASE_URL` 语义(主机由 entrypoint 改写) |
|
||||
| 建表 | `bun run db:push`(开发期) | `migrate` 服务(生产幂等迁移) |
|
||||
| 前端 | Vite dev server | `release/dist` 由 Caddy 托管 |
|
||||
| 配置 | `.env` | 服务器 `.env` |
|
||||
|
||||
开发**不依赖**本方案的 Docker 配置;部署也**不依赖**本机 Bun。
|
||||
|
||||
---
|
||||
|
||||
## 10. 文件改动清单
|
||||
|
||||
| 文件 | 动作 |
|
||||
|------|------|
|
||||
| `scripts/cli.ts` · `scripts/pack-core.ts` · `scripts/pack-release.ts` | 打包/构建 CLI 与核心逻辑 |
|
||||
| `packages/db/src/migrate-cli.ts` | 迁移 CLI 入口 |
|
||||
| `package.json` | `naku` · `pack:release` · `build:server` · `build:engine` |
|
||||
| `Dockerfile` · `docker-compose.yml` · `.dockerignore` | 两阶段镜像 · 五服务(含 `container_name`) |
|
||||
| `Caddyfile` · `entrypoint-pg.sh` · `entrypoint-app.sh` | 与 compose 同级平铺 |
|
||||
| `README.md` | 「部署」章与文档地图 |
|
||||
| `AGENTS.md` | 命令表(pack / build / naku) |
|
||||
| `.env.example` | 部署语义注释 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 已知取舍(否决时改这里)
|
||||
|
||||
1. **`migrate` 独立单文件**:多一个产物,换来「先迁后跑、失败不启」的清晰门闩。若嫌多,可改为 server 启动时迁(仍幂等),但失去 migrate 失败阻断 engine 的不对称控制。
|
||||
2. **目标平台写死 linux-x64**:匹配常见服务器;ARM 用打包参数切换,不为此引入交叉编译矩阵。
|
||||
3. **`DATABASE_URL` 单源 + 两个 entrypoint**:少一处密码不一致;多两个常驻小 shell。若偏好 compose 原生 `POSTGRES_*`,需接受密码在 `.env` 写两处(或 `DATABASE_URL` 仅应用用)。
|
||||
4. **产物包而非源码包**:服务器零构建;代价是发版必须在本地(或 CI)跑 `pack:release`。
|
||||
|
||||
---
|
||||
|
||||
## 12. 验收清单(实现后)
|
||||
|
||||
- [ ] `bun run naku pack --yes`(或 `pack:release`)在 Windows 产出完整 `release/`(五条目齐全)
|
||||
- [ ] 首次安装按 §6.1 一次命令链可起,`docker compose ps` 形态符合 §6.4
|
||||
- [ ] 仅替换 `release/` 后重跑固定命令,版本切换成功且数据保留(卷不重建)
|
||||
- [ ] 连续两次固定命令:第二次 `migrate` 不重复应用已存在迁移(日志可证)
|
||||
- [ ] 故意放一份坏 SQL:`migrate` 失败,`server`/`engine` 未启动
|
||||
- [ ] `COOKIE_SECURE` 与 HTTP/HTTPS 交叉场景符合 §8
|
||||
- [ ] 产物包内无 `.env`、无源码、无 `node_modules`
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
# 把 DATABASE_URL 的主机改写为 compose 服务名 postgres,再启动应用
|
||||
set -e
|
||||
|
||||
if [ -z "$DATABASE_URL" ]; then
|
||||
echo "DATABASE_URL is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export DATABASE_URL="$(echo "$DATABASE_URL" | sed -E 's#(@)[^:/]+(:[0-9]+)?(/)#\1postgres\2\3#')"
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/bin/sh
|
||||
# 从 DATABASE_URL 拆出官方镜像建库所需的 POSTGRES_*,再交回原入口
|
||||
set -e
|
||||
|
||||
if [ -z "$DATABASE_URL" ]; then
|
||||
echo "DATABASE_URL is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
u="${DATABASE_URL#*://}"
|
||||
auth="${u%%@*}"
|
||||
rest="${u#*@}"
|
||||
|
||||
export POSTGRES_USER="${auth%%:*}"
|
||||
export POSTGRES_PASSWORD="${auth#*:}"
|
||||
export POSTGRES_DB="${rest#*/}"
|
||||
export POSTGRES_DB="${POSTGRES_DB%%\?*}"
|
||||
|
||||
exec docker-entrypoint.sh postgres
|
||||
+13
-3
@@ -2,7 +2,10 @@
|
||||
"name": "new-api-key-usage",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": ["apps/*", "packages/*"],
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"dev:server": "bun --hot --env-file=.env apps/server/src/index.ts",
|
||||
@@ -11,10 +14,17 @@
|
||||
"db:generate": "bun run --cwd packages/db db:generate",
|
||||
"db:push": "bun run --cwd packages/db db:push",
|
||||
"db:studio": "bun run --cwd packages/db db:studio",
|
||||
"build:web": "bun run --cwd apps/web build"
|
||||
"build:web": "bun run --cwd apps/web build",
|
||||
"build:server": "bun build --compile --outfile=bin/naku-server apps/server/src/index.ts",
|
||||
"build:engine": "bun build --compile --outfile=bin/naku-engine apps/engine/src/index.ts",
|
||||
"pack:release": "bun run scripts/pack-release.ts",
|
||||
"naku": "bun run scripts/cli.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^1.8.1",
|
||||
"@types/bun": "^1.4.2",
|
||||
"citty": "^0.2.2",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
},
|
||||
"packageManager": "bun@1.4.2"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createDb, runMigrations } from "./index.ts";
|
||||
|
||||
/**
|
||||
* 部署用迁移入口:按 journal 幂等应用 packages/db/drizzle,成功 exit 0。
|
||||
* 只依赖 DATABASE_URL;MIGRATIONS_FOLDER 缺省为同目录 ./drizzle。
|
||||
*/
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) {
|
||||
console.error("[migrate] DATABASE_URL is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const migrationsFolder = process.env.MIGRATIONS_FOLDER ?? "./drizzle";
|
||||
|
||||
const db = createDb(databaseUrl);
|
||||
try {
|
||||
await runMigrations(db, migrationsFolder);
|
||||
console.log(`[migrate] ok (folder=${migrationsFolder})`);
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error("[migrate] failed:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* naku 打包/构建 CLI:citty 定义命令,@clack/prompts 负责交互。
|
||||
* 有 TTY 且未给全 flag 时进交互;--yes / 无 TTY 则纯 flag。
|
||||
*/
|
||||
import { defineCommand, runMain } from "citty";
|
||||
import * as p from "@clack/prompts";
|
||||
import {
|
||||
PACK_ITEMS,
|
||||
PACK_TARGETS,
|
||||
currentPlatformTarget,
|
||||
packRelease,
|
||||
type PackItem,
|
||||
} from "./pack-core.ts";
|
||||
|
||||
function isInteractive(yes: boolean | undefined): boolean {
|
||||
if (yes) return false;
|
||||
return Boolean(process.stdin.isTTY);
|
||||
}
|
||||
|
||||
function cancel(): never {
|
||||
p.cancel("Operation cancelled");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
function assertNotCancel<T>(value: T | symbol): T {
|
||||
if (p.isCancel(value)) cancel();
|
||||
return value as T;
|
||||
}
|
||||
|
||||
const targetOption = {
|
||||
target: {
|
||||
type: "string",
|
||||
description: `编译目标,如 ${PACK_TARGETS[0]};默认交互选择或 ${PACK_TARGETS[0]}`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const pack = defineCommand({
|
||||
meta: {
|
||||
name: "pack",
|
||||
description: "打部署产物包(dist + 单文件 + drizzle)",
|
||||
},
|
||||
args: {
|
||||
...targetOption,
|
||||
only: {
|
||||
type: "string",
|
||||
description: `产物范围,逗号分隔;可选 ${PACK_ITEMS.join("|")}`,
|
||||
},
|
||||
out: {
|
||||
type: "string",
|
||||
default: "release",
|
||||
description: "输出目录",
|
||||
},
|
||||
tarball: {
|
||||
type: "boolean",
|
||||
description: "额外打 tar.gz(默认交互询问,否则否)",
|
||||
},
|
||||
yes: {
|
||||
type: "boolean",
|
||||
alias: "y",
|
||||
description: "全部默认,不进交互",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const interactive = isInteractive(args.yes as boolean | undefined);
|
||||
p.intro("NAKU 打包");
|
||||
|
||||
// --- target ---
|
||||
let target = args.target as string | undefined;
|
||||
if (!target) {
|
||||
if (interactive) {
|
||||
const picked = assertNotCancel(
|
||||
await p.select({
|
||||
message: "目标平台",
|
||||
options: (() => {
|
||||
const current = currentPlatformTarget();
|
||||
const base = PACK_TARGETS.map((t) => ({
|
||||
label: t === "bun-linux-x64" ? `${t}(服务器常用)` : t,
|
||||
value: t,
|
||||
}));
|
||||
// 当前平台已在列表中则不再追加,避免重复
|
||||
return PACK_TARGETS.includes(current as (typeof PACK_TARGETS)[number])
|
||||
? base
|
||||
: [
|
||||
...base,
|
||||
{
|
||||
label: `${current}(当前平台,仅本地)`,
|
||||
value: current,
|
||||
},
|
||||
];
|
||||
})(),
|
||||
initialValue: "bun-linux-x64",
|
||||
}),
|
||||
);
|
||||
target = picked as string;
|
||||
} else {
|
||||
target = "bun-linux-x64";
|
||||
}
|
||||
}
|
||||
|
||||
// --- only ---
|
||||
let only: PackItem[];
|
||||
const onlyArg = args.only as string | undefined;
|
||||
if (onlyArg) {
|
||||
only = onlyArg
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean) as PackItem[];
|
||||
for (const item of only) {
|
||||
if (!PACK_ITEMS.includes(item)) {
|
||||
p.cancel(`未知产物: ${item}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} else if (interactive) {
|
||||
const picked = assertNotCancel(
|
||||
await p.multiselect({
|
||||
message: "打包内容",
|
||||
options: PACK_ITEMS.map((item) => ({ label: item, value: item })),
|
||||
initialValues: [...PACK_ITEMS],
|
||||
required: true,
|
||||
}),
|
||||
);
|
||||
only = picked as PackItem[];
|
||||
} else {
|
||||
only = [...PACK_ITEMS];
|
||||
}
|
||||
|
||||
// --- out ---
|
||||
const outDir = (args.out as string) || "release";
|
||||
|
||||
// --- tarball ---
|
||||
let tarball = args.tarball as boolean | undefined;
|
||||
if (tarball === undefined) {
|
||||
if (interactive) {
|
||||
tarball = assertNotCancel(
|
||||
await p.confirm({ message: "是否额外打 tar.gz?", initialValue: false }),
|
||||
) as boolean;
|
||||
} else {
|
||||
tarball = false;
|
||||
}
|
||||
}
|
||||
|
||||
const s = p.spinner();
|
||||
s.start(`编译 ${target} · ${only.join(" + ")}`);
|
||||
try {
|
||||
const out = await packRelease({
|
||||
target,
|
||||
only,
|
||||
outDir,
|
||||
tarball,
|
||||
onLog: (msg) => {
|
||||
// spinner 期间不逐条刷屏,保留关键节点可在结束后看
|
||||
if (msg.startsWith("tar ") || msg.startsWith("target=")) {
|
||||
s.message(msg);
|
||||
}
|
||||
},
|
||||
});
|
||||
s.stop(`完成 → ${out}`);
|
||||
p.outro(
|
||||
[
|
||||
`产物:${only.join(" · ")}`,
|
||||
`目标:${target}`,
|
||||
tarball ? "已附 tar.gz(仓库根目录)" : "未打 tar.gz",
|
||||
"",
|
||||
"服务器:覆盖 /opt/naku/release/ 后执行 docker compose up -d --build",
|
||||
].join("\n"),
|
||||
);
|
||||
} catch (err) {
|
||||
s.stop("失败");
|
||||
p.cancel(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const build = defineCommand({
|
||||
meta: {
|
||||
name: "build",
|
||||
description: "构建产物到 bin/(当前平台,适合本地试跑)",
|
||||
},
|
||||
args: {
|
||||
item: {
|
||||
type: "positional",
|
||||
required: false,
|
||||
description: `可选:${PACK_ITEMS.join("|")},不填则交互多选`,
|
||||
},
|
||||
...targetOption,
|
||||
yes: {
|
||||
type: "boolean",
|
||||
alias: "y",
|
||||
description: "全部默认,不进交互",
|
||||
},
|
||||
},
|
||||
async run({ args }) {
|
||||
const interactive = isInteractive(args.yes as boolean | undefined);
|
||||
const target =
|
||||
(args.target as string | undefined) ?? currentPlatformTarget();
|
||||
|
||||
let only: PackItem[];
|
||||
const positional = args.item as string | undefined;
|
||||
if (positional) {
|
||||
if (!PACK_ITEMS.includes(positional as PackItem)) {
|
||||
p.cancel(`未知产物: ${positional}`);
|
||||
process.exit(1);
|
||||
}
|
||||
only = [positional as PackItem];
|
||||
} else if (interactive) {
|
||||
p.intro("NAKU 本地构建");
|
||||
only = assertNotCancel(
|
||||
await p.multiselect({
|
||||
message: "构建什么",
|
||||
options: PACK_ITEMS.map((item) => ({ label: item, value: item })),
|
||||
// 默认不选,由用户自选
|
||||
initialValues: [],
|
||||
required: true,
|
||||
}),
|
||||
) as PackItem[];
|
||||
} else {
|
||||
only = ["naku-server", "naku-engine"];
|
||||
}
|
||||
|
||||
const s = p.spinner();
|
||||
s.start(`构建 ${only.join(" + ")} → bin/`);
|
||||
try {
|
||||
await packRelease({
|
||||
target,
|
||||
only,
|
||||
outDir: "bin",
|
||||
tarball: false,
|
||||
// 前端进 bin/dist/,避免与单文件混放
|
||||
nestDist: true,
|
||||
onLog: (msg) => {
|
||||
if (msg.startsWith("compile") || msg.startsWith("build")) s.message(msg);
|
||||
},
|
||||
});
|
||||
s.stop("完成");
|
||||
p.outro(`已写入 bin/(${target})`);
|
||||
} catch (err) {
|
||||
s.stop("失败");
|
||||
p.cancel(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const main = defineCommand({
|
||||
meta: {
|
||||
name: "naku",
|
||||
version: "0.0.0",
|
||||
description: "NAKU 打包 / 本地构建",
|
||||
},
|
||||
subCommands: { pack, build },
|
||||
});
|
||||
|
||||
runMain(main);
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 打包核心:供 CLI / 脚本共用,不带交互。
|
||||
* 产物:dist/ · naku-server · naku-engine · naku-migrate · drizzle/
|
||||
*/
|
||||
import { cp, mkdir, rm, stat, readdir } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
export const PACK_ITEMS = [
|
||||
"dist",
|
||||
"naku-server",
|
||||
"naku-engine",
|
||||
"naku-migrate",
|
||||
"drizzle",
|
||||
] as const;
|
||||
export type PackItem = (typeof PACK_ITEMS)[number];
|
||||
|
||||
export const PACK_TARGETS = [
|
||||
"bun-linux-x64",
|
||||
"bun-linux-arm64",
|
||||
"bun-darwin-arm64",
|
||||
"bun-darwin-x64",
|
||||
"bun-windows-x64",
|
||||
"bun-windows-arm64",
|
||||
] as const;
|
||||
|
||||
export function currentPlatformTarget(): string {
|
||||
const arch = process.arch === "arm64" ? "arm64" : "x64";
|
||||
if (process.platform === "win32") return `bun-windows-${arch}`;
|
||||
if (process.platform === "darwin") return `bun-darwin-${arch}`;
|
||||
return `bun-linux-${arch}`;
|
||||
}
|
||||
|
||||
export interface PackOptions {
|
||||
target: string;
|
||||
only: PackItem[];
|
||||
outDir: string;
|
||||
tarball?: boolean;
|
||||
root?: string;
|
||||
/** true:前端放到 <out>/dist/(部署包);false:直接铺在 <out>/(本地 dist) */
|
||||
nestDist?: boolean;
|
||||
onLog?: (msg: string) => void;
|
||||
}
|
||||
|
||||
function fail(msg: string): never {
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
async function mustExist(path: string, label: string): Promise<void> {
|
||||
if (!existsSync(path)) fail(`missing ${label}: ${path}`);
|
||||
}
|
||||
|
||||
async function nonEmptyDir(path: string, label: string): Promise<void> {
|
||||
const entries = await readdir(path);
|
||||
if (entries.length === 0) fail(`empty ${label}: ${path}`);
|
||||
}
|
||||
|
||||
async function run(cmd: string[], cwd: string, onLog?: (m: string) => void) {
|
||||
onLog?.(`$ ${cmd.join(" ")}`);
|
||||
const proc = Bun.spawn(cmd, { cwd, stdout: "inherit", stderr: "inherit" });
|
||||
const code = await proc.exited;
|
||||
if (code !== 0) fail(`command failed (${code}): ${cmd.join(" ")}`);
|
||||
}
|
||||
|
||||
export async function packRelease(opts: PackOptions): Promise<string> {
|
||||
const root = opts.root ?? resolve(import.meta.dir, "..");
|
||||
const releaseDir = resolve(root, opts.outDir);
|
||||
const log = opts.onLog ?? (() => {});
|
||||
const only = new Set(opts.only);
|
||||
const needBin = (name: PackItem) => only.has(name);
|
||||
|
||||
log(`target=${opts.target}`);
|
||||
log(`out=${releaseDir}`);
|
||||
|
||||
// 输出目录:只删本次会写入的条目,避免误删未选产物的旧文件?——部署包语义为整包覆盖,仍清空
|
||||
await rm(releaseDir, { recursive: true, force: true });
|
||||
await mkdir(releaseDir, { recursive: true });
|
||||
|
||||
if (needBin("dist")) {
|
||||
log("build web…");
|
||||
await run(["bun", "run", "build:web"], root, log);
|
||||
const webDist = join(root, "apps", "web", "dist");
|
||||
await mustExist(webDist, "web dist");
|
||||
const dest = opts.nestDist === false ? releaseDir : join(releaseDir, "dist");
|
||||
await cp(webDist, dest, { recursive: true });
|
||||
}
|
||||
|
||||
const compileOne = async (entry: string, name: PackItem) => {
|
||||
if (!needBin(name)) return;
|
||||
log(`compile ${name}…`);
|
||||
await run(
|
||||
[
|
||||
"bun",
|
||||
"build",
|
||||
"--compile",
|
||||
`--target=${opts.target}`,
|
||||
`--outfile=${join(releaseDir, name)}`,
|
||||
entry,
|
||||
],
|
||||
root,
|
||||
log,
|
||||
);
|
||||
};
|
||||
await compileOne("apps/server/src/index.ts", "naku-server");
|
||||
await compileOne("apps/engine/src/index.ts", "naku-engine");
|
||||
await compileOne("packages/db/src/migrate-cli.ts", "naku-migrate");
|
||||
|
||||
if (needBin("drizzle")) {
|
||||
const drizzleSrc = join(root, "packages", "db", "drizzle");
|
||||
await mustExist(drizzleSrc, "drizzle folder");
|
||||
await cp(drizzleSrc, join(releaseDir, "drizzle"), { recursive: true });
|
||||
}
|
||||
|
||||
for (const item of opts.only) {
|
||||
if (item === "dist" && opts.nestDist === false) {
|
||||
// web 铺在 outDir 根:检查 outDir 非空即可
|
||||
await nonEmptyDir(releaseDir, "outDir");
|
||||
continue;
|
||||
}
|
||||
// Windows 本机编译可能带 .exe
|
||||
const candidates = [
|
||||
join(releaseDir, item),
|
||||
join(releaseDir, `${item}.exe`),
|
||||
];
|
||||
const found = candidates.find((c) => existsSync(c));
|
||||
if (!found) fail(`missing ${item}: ${candidates[0]}`);
|
||||
if (item === "dist" || item === "drizzle") {
|
||||
await nonEmptyDir(found, `release/${item}`);
|
||||
} else {
|
||||
const s = await stat(found);
|
||||
if (s.size <= 0) fail(`empty file: ${item}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.tarball) {
|
||||
const date = new Date();
|
||||
const ymd = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, "0")}${String(date.getDate()).padStart(2, "0")}`;
|
||||
const tarName = `naku-release-${ymd}.tar.gz`;
|
||||
const tarPath = join(root, tarName);
|
||||
log(`tar ${tarName}…`);
|
||||
// 在 outDir 的父目录打包,使 tar 内为 release/ 根
|
||||
const parent = resolve(releaseDir, "..");
|
||||
const folderName = releaseDir.split(/[\\/]/).pop() ?? "release";
|
||||
await run(
|
||||
["tar", "-czf", tarPath, "-C", parent, folderName],
|
||||
root,
|
||||
log,
|
||||
);
|
||||
log(`tarball → ${tarPath}`);
|
||||
}
|
||||
|
||||
return releaseDir;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 兼容入口:等价于 `naku pack --yes --target=bun-linux-x64`。
|
||||
* 交互请用 `bun run naku pack`。
|
||||
*/
|
||||
import { packRelease } from "./pack-core.ts";
|
||||
|
||||
function readTarget(): string {
|
||||
const arg = process.argv.find((a) => a.startsWith("--target="));
|
||||
return arg?.slice("--target=".length) || process.env.PACK_TARGET || "bun-linux-x64";
|
||||
}
|
||||
|
||||
const target = readTarget();
|
||||
console.log(`[pack-release] non-interactive pack (target=${target})`);
|
||||
|
||||
try {
|
||||
const out = await packRelease({
|
||||
target,
|
||||
only: ["dist", "naku-server", "naku-engine", "naku-migrate", "drizzle"],
|
||||
outDir: "release",
|
||||
tarball: false,
|
||||
onLog: (m) => console.log("[pack-release]", m),
|
||||
});
|
||||
console.log("[pack-release] done →", out);
|
||||
} catch (err) {
|
||||
console.error("[pack-release]", err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
}
|
||||
+2
-1
@@ -26,6 +26,7 @@
|
||||
"apps/server/test/**/*.ts",
|
||||
"packages/*/src/**/*.ts",
|
||||
"packages/*/test/**/*.ts",
|
||||
"packages/*/drizzle.config.ts"
|
||||
"packages/*/drizzle.config.ts",
|
||||
"scripts/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user