From 905d4ce77ea56b49161bafd27b297a69b3e99375 Mon Sep 17 00:00:00 2001 From: skycurtain Date: Tue, 13 Oct 2026 00:56:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(deploy):=20=E4=BA=A7=E7=89=A9=E5=8C=85?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E9=93=BE=E8=B7=AF=E4=B8=8E=20naku=20?= =?UTF-8?q?=E6=89=93=E5=8C=85=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker Compose 五服务 + Debian slim 单文件运行时;migrate 幂等门闩; naku pack/build(citty + clack);部署方案文档入档。 --- .dockerignore | 19 ++ .env.example | 7 +- .gitignore | 3 + AGENTS.md | 4 + Caddyfile | 17 ++ Dockerfile | 20 ++ README.md | 37 ++++ bun.lock | 16 ++ docker-compose.yml | 98 ++++++++++ docs/design/部署方案.md | 327 +++++++++++++++++++++++++++++++++ entrypoint-app.sh | 12 ++ entrypoint-pg.sh | 19 ++ package.json | 16 +- packages/db/src/migrate-cli.ts | 23 +++ scripts/cli.ts | 255 +++++++++++++++++++++++++ scripts/pack-core.ts | 153 +++++++++++++++ scripts/pack-release.ts | 27 +++ tsconfig.json | 3 +- 18 files changed, 1050 insertions(+), 6 deletions(-) create mode 100644 .dockerignore create mode 100644 Caddyfile create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docs/design/部署方案.md create mode 100644 entrypoint-app.sh create mode 100644 entrypoint-pg.sh create mode 100644 packages/db/src/migrate-cli.ts create mode 100644 scripts/cli.ts create mode 100644 scripts/pack-core.ts create mode 100644 scripts/pack-release.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..60bef47 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example index b7106af..af6c67f 100644 --- a/.env.example +++ b/.env.example @@ -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 输出) diff --git a/.gitignore b/.gitignore index 56fa4f1..75c5c2a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,8 +17,11 @@ # 依赖与构建产物 node_modules/ dist/ +bin/ build/ .turbo/ +release/ +.bun-cache/ # 测试与工具 coverage/ diff --git a/AGENTS.md b/AGENTS.md index 8c547d9..2ebba9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 后填写)。 diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..4c46baa --- /dev/null +++ b/Caddyfile @@ -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 + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a347b7a --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 906da87..accd19f 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/bun.lock b/bun.lock index eedd500..e6608d3 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c85ef73 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/design/部署方案.md b/docs/design/部署方案.md new file mode 100644 index 0000000..e5d4a79 --- /dev/null +++ b/docs/design/部署方案.md @@ -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` diff --git a/entrypoint-app.sh b/entrypoint-app.sh new file mode 100644 index 0000000..57cd077 --- /dev/null +++ b/entrypoint-app.sh @@ -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 "$@" diff --git a/entrypoint-pg.sh b/entrypoint-pg.sh new file mode 100644 index 0000000..a1fcb27 --- /dev/null +++ b/entrypoint-pg.sh @@ -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 diff --git a/package.json b/package.json index 5120e18..380e0fd 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/packages/db/src/migrate-cli.ts b/packages/db/src/migrate-cli.ts new file mode 100644 index 0000000..7aa7c7c --- /dev/null +++ b/packages/db/src/migrate-cli.ts @@ -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); +} diff --git a/scripts/cli.ts b/scripts/cli.ts new file mode 100644 index 0000000..29f5ca4 --- /dev/null +++ b/scripts/cli.ts @@ -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(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); diff --git a/scripts/pack-core.ts b/scripts/pack-core.ts new file mode 100644 index 0000000..79b4bd9 --- /dev/null +++ b/scripts/pack-core.ts @@ -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:前端放到 /dist/(部署包);false:直接铺在 /(本地 dist) */ + nestDist?: boolean; + onLog?: (msg: string) => void; +} + +function fail(msg: string): never { + throw new Error(msg); +} + +async function mustExist(path: string, label: string): Promise { + if (!existsSync(path)) fail(`missing ${label}: ${path}`); +} + +async function nonEmptyDir(path: string, label: string): Promise { + 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 { + 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; +} diff --git a/scripts/pack-release.ts b/scripts/pack-release.ts new file mode 100644 index 0000000..e26a2a3 --- /dev/null +++ b/scripts/pack-release.ts @@ -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); +} diff --git a/tsconfig.json b/tsconfig.json index 0509e5a..1d665e5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,7 @@ "apps/server/test/**/*.ts", "packages/*/src/**/*.ts", "packages/*/test/**/*.ts", - "packages/*/drizzle.config.ts" + "packages/*/drizzle.config.ts", + "scripts/**/*.ts" ] }