@wdyy/skills 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. package/.well-known/skills/index.json +2 -2
  2. package/.well-known/skills/wdyy-deployment-standard/SKILL.md +37 -47
  3. package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
  4. package/.well-known/skills/wdyy-deployment-standard/reference/docker-delivery-rules.md +110 -0
  5. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +161 -155
  6. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +133 -95
  7. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +109 -116
  8. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +309 -298
  9. package/.well-known/skills/wdyy-deployment-standard/templates/backend.Dockerfile.template +17 -0
  10. package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +231 -252
  11. package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +18 -13
  12. package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +1 -1
  13. package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +3 -19
  14. package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +7 -4
  15. package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +12 -4
  16. package/README.md +2 -2
  17. package/lib/wdyy-cli.js +2 -1
  18. package/package.json +1 -1
  19. package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +0 -99
  20. package/.well-known/skills/wdyy-deployment-standard/templates/Dockerfile.template +0 -22
@@ -7,8 +7,8 @@
7
7
  },
8
8
  {
9
9
  "name": "wdyy-deployment-standard",
10
- "description": "基于标准五段根 .env,为无 CI/CD 项目生成确定性的前后端带版本双 Docker 镜像、本地 deploy.sh build 导出包、人工上传,以及服务器直接宿主机端口的交互式运行、替换和停止;不使用蓝绿部署、宿主机 Nginx 或数据库迁移。Use when 规划、实现、审查或执行手工生产部署时。",
11
- "files": ["SKILL.md", "agents/openai.yaml", "reference/linux-deployment-rules.md", "scripts/generate-deployment-files.mjs", "scripts/generate-deployment-files.test.mjs", "scripts/validate-deployment-package.mjs", "scripts/validate-deployment-package.test.mjs", "templates/Dockerfile.template", "templates/frontend.Dockerfile.template", "templates/frontend-container.conf.template", "templates/env.example.template", "templates/deploy.sh.template", "templates/docker-compose.yml", "templates/dockerignore.template"]
10
+ "description": "为无 CI/CD 的前后端项目生成并验证目标平台确定的统一 Docker 交付:在任意构建机上使用本地已有的目标架构基础镜像,构建 linux/amd64 linux/arm64 前后端镜像,分别导出后封装为一个压缩包,并在平台匹配的 Linux Docker Server 上无参数部署整个项目栈。Use when 规划、实现、审查或执行跨构建机平台的人工 Docker 生产交付时。",
11
+ "files": ["SKILL.md", "agents/openai.yaml", "reference/docker-delivery-rules.md", "scripts/generate-deployment-files.mjs", "scripts/generate-deployment-files.test.mjs", "scripts/validate-deployment-package.mjs", "scripts/validate-deployment-package.test.mjs", "templates/backend.Dockerfile.template", "templates/frontend.Dockerfile.template", "templates/frontend-container.conf.template", "templates/env.example.template", "templates/deploy.sh.template", "templates/docker-compose.yml", "templates/dockerignore.template"]
12
12
  },
13
13
  {
14
14
  "name": "wdyy-api-standard",
@@ -1,63 +1,53 @@
1
1
  ---
2
2
  name: wdyy-deployment-standard
3
- description: 基于标准五段根 .env,为无 CI/CD 项目生成确定性的前后端带版本双 Docker 镜像、本地 deploy.sh build 导出包、人工上传,以及服务器直接宿主机端口的交互式运行、替换和停止;不使用蓝绿部署、宿主机 Nginx 或数据库迁移。Use when 规划、实现、审查或执行手工生产部署时。
3
+ description: 为无 CI/CD 的前后端项目生成并验证目标平台确定的统一 Docker 交付:在任意构建机上使用本地已有的目标架构基础镜像,构建 linux/amd64 linux/arm64 前后端镜像,分别导出后封装为一个压缩包,并在平台匹配的 Linux Docker Server 上无参数部署整个项目栈。Use when 规划、实现、审查或执行跨构建机平台的人工 Docker 生产交付时。
4
4
  ---
5
5
 
6
- # 企业部署规范
6
+ # 统一 Docker 交付规范
7
7
 
8
8
  ## Overview
9
9
 
10
- 把开发、构建、上传和服务器操作分开:开发只用 `pnpm dev`;本地项目根目录用 `./deploy.sh build` 生成带版本号的前后端镜像和完整 `deploy/`;工程师人工上传;服务器项目根目录无参数运行 `./deploy.sh`,按菜单直接运行或替换固定服务。
10
+ 把一个项目作为一个整体交付:在任意支持目标架构的 Docker 构建机上执行 `./deploy.sh build`,按 `DOCKER_PLATFORM` 生成前端、后端两个单架构镜像并分别导出,最终只产出一个压缩包;工程师人工上传并解压;平台匹配的 Linux Docker Server 无参数执行 `./deploy.sh`,一次性加载并部署整个前后端项目栈。
11
11
 
12
- ## 使用边界
12
+ ## Workflow
13
13
 
14
- 1. 先读取目标项目真实的 pnpm scripts、前后端构建输出、健康/版本接口、Dockerfile `.env`。
15
- 2. 完整读取 [Linux 部署规则](reference/linux-deployment-rules.md),运行 `node scripts/generate-deployment-files.mjs --target <目标项目根目录> --write` 确定性生成受版本控制文件,并向 `.env` 与 `.env.example` 的唯一“部署”段追加同名配置;既有文件不一致时先审查差异,只有工程师确认后才使用 `--force`。
16
- 3. 应用监听和宿主机直接暴露端口只使用 `FRONTEND_URL`、`FRONTEND_PORT`、`BACKEND_URL`、`BACKEND_PORT`;不得增加重复容器端口或健康 URL 配置。
17
- 4. 工程师人工上传 `deploy/` 全部内容作为服务器项目根目录;脚本不得上传、拉取代码、安装依赖或修改宿主机 Nginx。
18
- 5. 生成后先运行生成器的 `--check`,再运行 `scripts/validate-deployment-package.mjs` 和全部行为测试。
14
+ 1. 读取目标项目真实的 pnpm scripts、前后端构建输出、Dockerfile、健康检查、根 `.env` 和远程数据库连接方式。
15
+ 2. 完整读取 [统一 Docker 交付规则](reference/docker-delivery-rules.md)
16
+ 3. 运行 `node scripts/generate-deployment-files.mjs --target <目标项目根目录> --write` 生成受版本控制文件并扩充 `.env`、`.env.example` 的部署段。既有生成文件不一致时先展示差异并等待工程师确认,确认后才使用 `--force`。
17
+ 4. 在目标项目根执行生成器 `--check`,再运行 `scripts/validate-deployment-package.mjs` 和行为测试。
18
+ 5. 本地执行 `./deploy.sh build`;只在全部校验成功后原子替换 `deploy/<PROJECT_NAME>-docker.tar.gz`。
19
+ 6. 人工上传并解压压缩包,在服务器解压目录无参数执行 `./deploy.sh`。
19
20
 
20
- ## 固定契约
21
+ ## Fixed Contract
21
22
 
22
- - 项目名只允许小写字母、数字和下划线;服务器部署根由上传后的 `deploy.sh` 物理路径确定。
23
- - 镜像必须精确命名为 `<项目名称>_frontend:<YYYYMMDD-NNN>` `<项目名称>_backend:<YYYYMMDD-NNN>`。
24
- - Compose 只允许 frontendbackend 两个服务,不设置 `container_name`,不包含颜色、切流或回滚状态。
25
- - frontend 直接映射 `${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}`;backend 直接映射 `${DOCKER_BIND_IP}:${BACKEND_PORT}:${BACKEND_PORT}`。
26
- - 两个容器只把服务器项目根 `./logs` 挂载到 `/app/logs`;日志文件直接位于 `logs/`,不得创建服务、实例、颜色或日期子目录。
27
- - 服务器部署操作追加写入 `./logs/deploy.log`;前端 Nginx 写入 `frontend-access.log` `frontend-error.log`;后端遵循 `wdyy-logging-standard`。
28
- - `.env` 原样复制到 `deploy/.env` 并人工上传,但必须被 `.dockerignore` 排除,且不得进入版本目录或任一镜像。
29
- - `.env.example` 内置 `FRONTEND_BASE_IMAGE=nginx:stable` `BACKEND_BASE_IMAGE=node:24-alpine3.24`;`.env` 可显式覆盖。
30
- - 环境文件固定按“前端、后端、数据库、API、部署”排列;部署段只包含 `PROJECT_NAME`、`DOCKER_BIND_IP`、`FRONTEND_BASE_IMAGE`、`BACKEND_BASE_IMAGE`。
31
- - 发布链路不接受数据库迁移参数,也不打包、校验或执行迁移文件。
23
+ - 构建 `<PROJECT_NAME>_frontend:latest` 与 `<PROJECT_NAME>_backend:latest` 两个独立镜像。
24
+ - 每个交付包只对应 `DOCKER_PLATFORM` 指定的 `linux/amd64` 或 `linux/arm64`;构建机自身平台可以不同,不得回退到构建机原生架构。
25
+ - 分别导出 `frontend-image.tar`、`backend-image.tar`,但只交付一个 `<PROJECT_NAME>-docker.tar.gz`。
26
+ - 构建前检查 `FRONTEND_BASE_IMAGE`、`BACKEND_BASE_IMAGE` 已存在于本地;缺失即失败,不执行 `docker pull`。
27
+ - 服务器加载镜像前读取 Docker Server OS/架构;无法读取、不是 Linux 或不等于 `DOCKER_PLATFORM` 时立即失败。
28
+ - Compose 只定义 frontend、backend;数据库通过 `.env` 直连远程实例,不生成数据库容器、数据卷或迁移路径。
29
+ - 服务器无参数命令整体部署;只额外允许 `stop` `status`,不得独立操作单一服务。
30
+ - 前后端直接暴露 `.env` 指定的宿主机端口;两个容器共同挂载项目根 `./logs` `/app/logs`。
31
+ - frontend Nginx 使用 `.env` 的 `VITE_API_BASE_URL` 与 `BACKEND_PORT`,把同源 API 请求代理到 Compose `backend` 服务;`VITE_API_BASE_URL` 必须等于 `/${API_PREFIX}`。
32
+ - `.env` 可以进入人工上传的压缩包,但必须被 `.dockerignore` 排除,不得进入镜像。
32
33
 
33
- ## 工作流
34
+ ## Prohibited
34
35
 
35
- 1. 使用 `pnpm dev` 完成开发测试。
36
- 2. 在本地项目根目录执行不带额外参数的 `./deploy.sh build`;从 `AGENTS.md` 唯一“发布记录”表生成 `YYYYMMDD-NNN`。
37
- 3. 依次通过测试、lint、类型检查、构建、双镜像标签检查、包校验和配置确定性检查;成功后才替换 `deploy/` 并写发布记录。
38
- 4. 人工把 `deploy/` 全部内容上传为服务器项目根目录;生产值不得通过命令行传入。
39
- 5. 在服务器项目根目录无参数执行 `./deploy.sh`,选择:运行或替换前端、运行或替换后端、运行或替换前后端、停止。
40
- 6. 运行或替换会载入所选服务的目标版本镜像、强制重建并健康检查;停止会同时停止 frontend 和 backend。
41
-
42
- ## 禁止事项
43
-
44
- - 不得使用蓝绿、滚动切流、活动/历史颜色、宿主机 Nginx、自动回滚或旧版 start/replace/restart/remove/status/rollback 命令。
45
- - 不得接受人工 build 版本参数,不得扫描版本目录替代发布账本或 `release.env`。
46
- - 不得使用 Bash `source`/`eval` 解析 `.env` 或版本指针。
47
- - 不得使用镜像 fallback、固定 `/srv`、固定 URL/IP/端口、PM2、systemd 或自动安装。
48
- - 不得重新引入 `PROJECT_HTTP_PORT`、`FRONTEND_BLUE_PORT`、`FRONTEND_GREEN_PORT`、`BACKEND_BLUE_PORT`、`BACKEND_GREEN_PORT`、`FRONTEND_CONTAINER_PORT`、`BACKEND_CONTAINER_PORT`、`BACKEND_LISTEN_HOST` 或任何 `*_HEALTH_URL`、`*_VERSION_URL`。
49
- - 不得自动执行 psql、迁移脚本或数据库回滚。
50
- - 不得以字符串关键字存在代替命令行为、失败传播和资源边界测试。
36
+ - 不得使用镜像仓库 pull、镜像 fallback、数据库容器、数据库迁移、宿主机 Nginx、蓝绿、滚动切流或自动回滚。
37
+ - 不得保留发布账本、版本目录、`release.env`、交互菜单或前后端独立替换命令。
38
+ - 不得上传文件、拉取代码、安装 Docker、固定服务器目录或使用 `source`、`eval` 执行 `.env`。
39
+ - 不得跳过清单、镜像标签、Compose 服务范围、远程数据库配置或健康状态验证。
51
40
 
52
41
  ## Verification
53
42
 
54
- - [ ] 两个镜像仓库名、版本标签和 OCI version label 精确匹配发布版本。
55
- - [ ] Compose 只含 frontend、backend,使用根 `.env`、直接宿主机端口和 `../../logs:/app/logs`。
56
- - [ ] 服务器无参数菜单只有四项;独立和同时替换不会操作未选择服务;停止同时停止两服务。
57
- - [ ] `.env` `.env.example` 五段、变量顺序一致,无蓝绿、Nginx、重复端口或日志目录配置。
58
- - [ ] 部署、前端和后端日志直接位于项目根 `logs/`,容器替换或停止后仍保留。
59
- - [ ] 不存在数据库迁移参数、迁移包内容、上传命令或数据库执行路径。
60
- - [ ] 连续两次构建的 deploy.sh Compose 一致,失败不写发布记录、不暴露半成品。
61
- - [ ] `deploy/` 含根 `.env`,但版本目录、Docker 上下文和镜像不含秘密文件。
62
-
63
- 任一步失败必须明确报告具体阶段并返回非零;不得静默跳过、伪造成功或删除失败证据。
43
+ - [ ] deploy 目录只有一个 `<PROJECT_NAME>-docker.tar.gz`。
44
+ - [ ] 压缩包解压后只含 `.env`、deploy.sh、docker-compose.yml、两个镜像 tar 和 manifest.sha256。
45
+ - [ ] 两个镜像归档各自只含预期的 `latest` 标签。
46
+ - [ ] 单一压缩包不含 macOS AppleDouble、资源叉或扩展属性元数据。
47
+ - [ ] 构建与服务器 Compose 均禁止 pull,服务器禁止 build;基础镜像、构建参数、产出镜像和 Compose 平台均与 `DOCKER_PLATFORM` 一致。
48
+ - [ ] 服务器 Docker Engine 平台在加载镜像前通过校验,并严格等于 `DOCKER_PLATFORM`。
49
+ - [ ] Compose 只含 frontend、backend,并使用远程数据库环境变量和根 logs。
50
+ - [ ] frontend 镜像不包含 `.env`,同源 API 代理能够把真实页面查询转发到 backend。
51
+ - [ ] 无参数部署、stop、status 的实际行为及失败传播通过测试。
52
+
53
+ 任一步失败必须指出具体阶段并返回非零;不得静默跳过、伪造成功或删除上一份成功交付包。
@@ -1,4 +1,4 @@
1
1
  interface:
2
2
  display_name: "wdyy-deployment-standard"
3
- short_description: "Build and operate direct dual-image deployments"
4
- default_prompt: "Use $wdyy-deployment-standard to generate and validate a versioned dual-image manual deployment with direct host ports and an interactive server menu."
3
+ short_description: "跨构建机生成指定 Linux 架构的双镜像并一键整栈部署"
4
+ default_prompt: "Use $wdyy-deployment-standard to build a single-target linux/amd64 or linux/arm64 dual-image package and verify the Docker Server platform before deploying the full stack."
@@ -0,0 +1,110 @@
1
+ # 统一 Docker 交付规则
2
+
3
+ ## 1. 生成文件
4
+
5
+ 目标项目维护以下受版本控制文件:
6
+
7
+ ```text
8
+ deploy.sh
9
+ .dockerignore
10
+ src/frontend/Dockerfile
11
+ src/backend/Dockerfile
12
+ scripts/deployment/docker-compose.yml
13
+ scripts/deployment/frontend-container.conf.template
14
+ .env.example
15
+ ```
16
+
17
+ 使用 `scripts/generate-deployment-files.mjs --target <project-root> --write` 从 Skill templates 原子生成。现有文件与模板不一致时默认失败;工程师确认差异后才使用 `--force`。生成后必须执行 `--check`。
18
+
19
+ ## 2. 环境配置
20
+
21
+ 根 `.env` 是构建、压缩包和服务器 Compose 的唯一配置源,固定按“前端、后端、数据库、API、部署”排列。部署必须具有非空值:
22
+
23
+ ```dotenv
24
+ FRONTEND_URL=0.0.0.0
25
+ FRONTEND_PORT=5173
26
+ BACKEND_URL=0.0.0.0
27
+ BACKEND_PORT=3000
28
+ DB_URL=database.example.internal
29
+ DB_PORT=5432
30
+ DB_USER=app_user
31
+ DB_PASSWORD=change_me
32
+ DB_NAME=app
33
+ DB_SCHEMA=public
34
+ API_PREFIX=api
35
+ VITE_API_BASE_URL=/api
36
+ PROJECT_NAME=example_project
37
+ DOCKER_BIND_IP=0.0.0.0
38
+ DOCKER_PLATFORM=linux/amd64
39
+ FRONTEND_BASE_IMAGE=nginx:stable
40
+ BACKEND_BASE_IMAGE=node:24-alpine3.24
41
+ ```
42
+
43
+ - `PROJECT_NAME` 只允许小写字母开头,后续使用小写字母、数字或下划线。
44
+ - 前后端端口必须不同且位于 1–65535;`DB_PORT` 必须位于 1–65535。
45
+ - `.env` 权限必须为 `0600` 或只读 `0400`。
46
+ - 按数据解析 `.env`;不得 `source`、`eval` 或执行其中内容。
47
+ - `DB_URL` 指向远程数据库主机或地址;部署资产不得把它解释为 Docker 服务名并创建数据库容器。
48
+ - `DOCKER_PLATFORM` 必须为 `linux/amd64` 或 `linux/arm64`,表示当前单一交付包的目标平台,并与目标 Docker Server 平台一致;不得根据构建机平台自动改写。
49
+ - `API_PREFIX` 只允许一个安全路径段,`VITE_API_BASE_URL` 必须严格等于 `/${API_PREFIX}`;生成器在两个键都缺失时加入标准值,存在缺失或冲突时明确失败。
50
+ - `.env.example` 可保留空数据库凭据,但真实 `.env` 构建时必须完整。
51
+
52
+ ## 3. 本地镜像与构建
53
+
54
+ 基础镜像引用允许使用标签,但目标架构版本必须在构建机本地预先存在。构建机可以是 macOS、Windows 或 Linux,也可以与目标架构不同;相同标签被人工替换后可能对应不同内容,构建过程不得访问镜像仓库或回退到构建机原生架构。
55
+
56
+ `./deploy.sh build` 必须按顺序:
57
+
58
+ 1. 验证根 `.env`、Dockerfile、Compose 和 `.dockerignore`。
59
+ 2. 使用平台感知的 `docker image inspect --platform "$DOCKER_PLATFORM"` 确认两个基础镜像的目标架构版本存在。
60
+ 3. 校验本地基础镜像平台与 `DOCKER_PLATFORM` 一致,使用 `docker build --pull=false --platform "$DOCKER_PLATFORM"` 构建 `<PROJECT_NAME>_frontend:latest` 与 `<PROJECT_NAME>_backend:latest`,并校验产出镜像平台。
61
+ 4. 分别执行 Docker save,生成 `frontend-image.tar`、`backend-image.tar`。
62
+ 5. 复制根 `.env`、deploy.sh、docker-compose.yml,生成覆盖全部包内文件的 `manifest.sha256`。
63
+ 6. 验证镜像归档各自只含预期标签,验证完整包结构。
64
+ 7. 使用 `COPYFILE_DISABLE=1` 和 `tar --no-xattrs` 排除 macOS AppleDouble、资源叉和扩展属性,生成 `deploy/<PROJECT_NAME>-docker.tar.gz`,成功后原子替换同名旧包。
65
+
66
+ 构建失败必须保留上一份成功压缩包,并清理本次临时目录。不得维护发布账本、版本号、版本目录、`release.env` 或半成品目录。
67
+
68
+ ## 4. 压缩包契约
69
+
70
+ 压缩包解压根固定为:
71
+
72
+ ```text
73
+ .env
74
+ deploy.sh
75
+ docker-compose.yml
76
+ frontend-image.tar
77
+ backend-image.tar
78
+ manifest.sha256
79
+ ```
80
+
81
+ 不得包含源代码、Git、依赖缓存、数据库目录、迁移脚本、秘密副本、符号链接、macOS AppleDouble/扩展属性或额外嵌套目录。`.env` 必须保持 owner-only 权限并纳入清单;`.dockerignore` 必须确保 `.env`、秘密、deploy、logs、Git、依赖缓存和虚拟环境不进入镜像构建上下文。
82
+
83
+ ## 5. 服务器一键操作
84
+
85
+ 工程师人工上传并解压单一压缩包。服务器部署根由解压后的 deploy.sh 物理路径确定。上传用的 `.tar.gz` 必须放在部署根外,或在解压后移出部署根;执行 `./deploy.sh` 时,部署根只允许保留压缩包契约中的 6 个文件和运行后生成的 `logs/` 目录。
86
+
87
+ - `./deploy.sh`:校验 `.env` 权限和内容、清单、两个镜像 tar 及预期标签;通过 `docker version` 读取 Docker Server OS/架构并要求严格等于 `DOCKER_PLATFORM`;校验通过后才加载两个镜像,再使用一次 `docker compose up -d --force-recreate --no-build --pull never --wait frontend backend` 整体部署并等待健康。
88
+ - 加载后必须再次校验两个镜像与 `DOCKER_PLATFORM` 一致;Compose 的两个服务也必须显式声明同一平台。
89
+ - `./deploy.sh stop`:同时停止 frontend、backend,保留容器、镜像、交付文件和日志。
90
+ - `./deploy.sh status`:输出当前 Compose 项目的服务状态,不改变 Docker 状态。
91
+
92
+ 不得显示交互菜单,不得接收服务名,不得提供单独运行或替换 frontend/backend 的入口。非法参数必须在 Docker 状态变更前失败。
93
+
94
+ ## 6. Compose、数据库和日志
95
+
96
+ - Compose 只允许 frontend、backend 两个服务,不设置 `container_name`。
97
+ - 镜像固定为 `${PROJECT_NAME}_frontend:latest`、`${PROJECT_NAME}_backend:latest`,不得提供 fallback。
98
+ - frontend 映射 `${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}`;backend 同理。
99
+ - frontend Nginx 把 `${VITE_API_BASE_URL}/...` 原样代理到 `http://backend:${BACKEND_PORT}`;不得硬编码服务器地址或后端端口。
100
+ - 两个服务均使用根 `.env`,并挂载 `./logs:/app/logs`。
101
+ - backend 直接读取 DB_URL、DB_PORT、DB_USER、DB_PASSWORD、DB_NAME、DB_SCHEMA。
102
+ - Compose 不得定义数据库服务、数据库镜像、数据库 volume、数据库端口映射或迁移任务。
103
+ - 前端 Nginx 日志写入 `/app/logs/frontend-access.log`、`/app/logs/frontend-error.log`;后端遵循 wdyy-logging-standard;部署操作写入 `./logs/deploy.log`。
104
+
105
+ ## 7. 失败边界
106
+
107
+ - 目标架构基础镜像缺失、Docker Server 平台无法读取或不匹配、清单错误、镜像标签错误、远程数据库配置不完整或任一服务不健康时明确失败。
108
+ - 禁止 `docker pull`、服务器 `docker build`、镜像 fallback、自动回滚、自动安装和静默跳过。
109
+ - 脚本只操作当前 `PROJECT_NAME` 的 Compose 项目和 frontend、backend 服务,不清理其他项目资源。
110
+ - 旧部署资产由工程师根据实际状态单独迁移;新脚本不得猜测并删除。
@@ -9,215 +9,221 @@ const mappings = [
9
9
  ['deploy.sh.template', 'deploy.sh', 0o755],
10
10
  ['dockerignore.template', '.dockerignore', 0o644],
11
11
  ['frontend.Dockerfile.template', 'src/frontend/Dockerfile', 0o644],
12
- ['Dockerfile.template', 'src/backend/Dockerfile', 0o644],
12
+ ['backend.Dockerfile.template', 'src/backend/Dockerfile', 0o644],
13
13
  ['docker-compose.yml', 'scripts/deployment/docker-compose.yml', 0o644],
14
14
  ['frontend-container.conf.template', 'scripts/deployment/frontend-container.conf.template', 0o644],
15
15
  ];
16
-
17
16
  const sectionNames = ['前端', '后端', '数据库', 'API', '部署'];
18
- const baseKeys = new Map([
17
+ const fixedSectionKeys = new Map([
19
18
  ['前端', ['FRONTEND_URL', 'FRONTEND_PORT']],
20
19
  ['后端', ['BACKEND_URL', 'BACKEND_PORT']],
21
20
  ['数据库', ['DB_URL', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME', 'DB_SCHEMA']],
22
21
  ]);
23
- const deploymentKeys = [
24
- 'PROJECT_NAME', 'DOCKER_BIND_IP',
25
- 'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE',
26
- ];
27
-
28
- const removedDeploymentKeys = new Set([
29
- 'PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT',
30
- 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT', 'SERVER_PROJECTS_ROOT',
31
- 'NGINX_SOURCE', 'LOG_DIR', 'HOST_LOG_DIR',
22
+ const exampleDefaults = new Map([
23
+ ['FRONTEND_URL', '0.0.0.0'], ['FRONTEND_PORT', '5173'],
24
+ ['BACKEND_URL', '0.0.0.0'], ['BACKEND_PORT', '3000'],
25
+ ['DB_URL', ''], ['DB_PORT', '5432'], ['DB_USER', ''], ['DB_PASSWORD', ''], ['DB_NAME', ''], ['DB_SCHEMA', ''],
26
+ ]);
27
+ const apiKeys = ['API_PREFIX', 'VITE_API_BASE_URL'];
28
+ const deploymentKeys = ['PROJECT_NAME', 'DOCKER_BIND_IP', 'DOCKER_PLATFORM', 'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE'];
29
+ const removedKeys = new Set([
30
+ 'PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT',
31
+ 'SERVER_PROJECTS_ROOT', 'NGINX_SOURCE', 'LOG_DIR', 'HOST_LOG_DIR', 'FRONTEND_CONTAINER_PORT',
32
+ 'BACKEND_CONTAINER_PORT', 'BACKEND_LISTEN_HOST', 'DATABASE_MIGRATION_MODE', 'MIGRATIONS_SOURCE',
33
+ 'MIGRATION_RUNNER_SOURCE', 'RELEASE_VERSION',
32
34
  ]);
33
35
 
34
- function parseEnvironmentStructure(text, relativeTarget, requireExampleDefaults, exampleValues) {
35
- const normalized = text.replaceAll('\r\n', '\n');
36
- const sections = new Map(sectionNames.map((name) => [name, []]));
37
- const encounteredSections = [];
38
- const seenKeys = new Set();
39
- let currentSection;
36
+ function parseArgs(argv) {
37
+ let mode;
38
+ let force = false;
39
+ let target;
40
+ for (let index = 0; index < argv.length; index += 1) {
41
+ const argument = argv[index];
42
+ if (argument === '--target') {
43
+ if (target !== undefined || !argv[index + 1] || argv[index + 1].startsWith('--')) throw new Error('Duplicate or missing --target argument');
44
+ target = argv[++index];
45
+ } else if (argument === '--write' || argument === '--check') {
46
+ if (mode !== undefined) throw new Error('Exactly one of --write or --check is required');
47
+ mode = argument.slice(2);
48
+ } else if (argument === '--force') {
49
+ if (force) throw new Error('Duplicate --force argument');
50
+ force = true;
51
+ } else {
52
+ throw new Error(`Unknown argument: ${argument}`);
53
+ }
54
+ }
55
+ if (!mode || target === undefined || (force && mode !== 'write')) {
56
+ throw new Error('Usage: generate-deployment-files.mjs --target <project-root> (--write [--force] | --check)');
57
+ }
58
+ return { mode, force, target: resolve(target) };
59
+ }
40
60
 
41
- for (const [index, line] of normalized.split('\n').entries()) {
42
- if (!line) continue;
43
- const heading = line.match(/^# (前端|后端|数据库|API|部署)$/);
61
+ function parseEnvironment(text, relativePath, requireExampleDefaults, deploymentDefaults) {
62
+ const sections = new Map(sectionNames.map((name) => [name, []]));
63
+ const headings = [];
64
+ const seen = new Set();
65
+ let current;
66
+ for (const [index, raw] of text.replaceAll('\r\n', '\n').split('\n').entries()) {
67
+ if (!raw) continue;
68
+ const heading = raw.match(/^# (前端|后端|数据库|API|部署)$/);
44
69
  if (heading) {
45
- currentSection = heading[1];
46
- encounteredSections.push(currentSection);
70
+ current = heading[1];
71
+ headings.push(current);
47
72
  continue;
48
73
  }
49
- if (line.startsWith('#')) continue;
50
- if (!currentSection) throw new Error(`${relativeTarget}:${index + 1} must be inside a standard section`);
51
- const entry = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
52
- if (!entry) throw new Error(`${relativeTarget}:${index + 1} must use KEY=VALUE syntax`);
53
- if (seenKeys.has(entry[1])) throw new Error(`${relativeTarget} contains duplicate key: ${entry[1]}`);
54
- if (removedDeploymentKeys.has(entry[1]) || ['FRONTEND_CONTAINER_PORT', 'BACKEND_CONTAINER_PORT', 'BACKEND_LISTEN_HOST'].includes(entry[1]) || /_(?:HEALTH|VERSION)_URL$/.test(entry[1])) {
55
- throw new Error(`${relativeTarget} contains legacy duplicate configuration: ${entry[1]}`);
56
- }
57
- seenKeys.add(entry[1]);
58
- sections.get(currentSection).push({ key: entry[1], value: entry[2] });
74
+ if (raw.startsWith('#')) continue;
75
+ if (!current) throw new Error(`${relativePath}:${index + 1} must be inside a standard section`);
76
+ const match = raw.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
77
+ if (!match) throw new Error(`${relativePath}:${index + 1} must use KEY=VALUE syntax`);
78
+ const [, key, value] = match;
79
+ if (seen.has(key)) throw new Error(`${relativePath} contains duplicate key: ${key}`);
80
+ if (removedKeys.has(key) || /_(?:HEALTH|VERSION)_URL$/.test(key)) throw new Error(`${relativePath} contains removed deployment configuration: ${key}`);
81
+ seen.add(key);
82
+ sections.get(current).push({ key, value });
59
83
  }
60
-
61
- if (encounteredSections.join('|') !== sectionNames.join('|')) {
62
- throw new Error(`${relativeTarget} section order must be: ${sectionNames.map((name) => `# ${name}`).join(', ')}`);
84
+ if (headings.join('|') !== sectionNames.join('|')) {
85
+ throw new Error(`${relativePath} section order must be: ${sectionNames.map((name) => `# ${name}`).join(', ')}`);
63
86
  }
64
- for (const [section, expectedKeys] of baseKeys) {
87
+ for (const [section, expectedKeys] of fixedSectionKeys) {
65
88
  const entries = sections.get(section);
66
- const actualKeys = entries.map(({ key }) => key);
67
- if (actualKeys.join('|') !== expectedKeys.join('|')) {
68
- throw new Error(`${relativeTarget} # ${section} keys must be: ${expectedKeys.join(', ')}`);
89
+ if (entries.map(({ key }) => key).join('|') !== expectedKeys.join('|')) {
90
+ throw new Error(`${relativePath} # ${section} keys must be: ${expectedKeys.join(', ')}`);
69
91
  }
70
92
  if (requireExampleDefaults) {
71
93
  for (const { key, value } of entries) {
72
- if (value !== exampleValues.get(key)) throw new Error(`${relativeTarget} must keep the standard default for ${key}`);
94
+ if (value !== exampleDefaults.get(key)) throw new Error(`${relativePath} must keep the standard default for ${key}`);
73
95
  }
74
96
  }
75
97
  }
76
- const actualDeploymentKeys = sections.get('部署').map(({ key }) => key);
77
- if (actualDeploymentKeys.length !== 0 && actualDeploymentKeys.join('|') !== deploymentKeys.join('|')) {
78
- throw new Error(`${relativeTarget} # 部署 keys must be empty or exactly: ${deploymentKeys.join(', ')}`);
98
+ const apiEntries = sections.get('API');
99
+ const apiValues = new Map(apiEntries.map(({ key, value }) => [key, value]));
100
+ const presentApiKeys = apiKeys.filter((key) => apiValues.has(key));
101
+ if (presentApiKeys.length !== 0 && presentApiKeys.length !== apiKeys.length) {
102
+ throw new Error(`${relativePath} # API must contain both: ${apiKeys.join(', ')}`);
103
+ }
104
+ if (presentApiKeys.length === apiKeys.length) {
105
+ const prefix = apiValues.get('API_PREFIX');
106
+ const baseUrl = apiValues.get('VITE_API_BASE_URL');
107
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(prefix)) throw new Error(`${relativePath} API_PREFIX must be one safe path segment`);
108
+ if (baseUrl !== `/${prefix}`) throw new Error(`${relativePath} VITE_API_BASE_URL must equal /${prefix}`);
79
109
  }
80
- if (requireExampleDefaults && sections.get('部署').some(({ key, value }) => !['FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE'].includes(key) && value !== '')) {
81
- throw new Error(`${relativeTarget} deployment example values must be empty except base-image defaults`);
110
+ const deploymentEntries = sections.get('部署');
111
+ const actualDeploymentKeys = deploymentEntries.map(({ key }) => key);
112
+ if (actualDeploymentKeys.length !== 0 && actualDeploymentKeys.join('|') !== deploymentKeys.join('|')) {
113
+ throw new Error(`${relativePath} # 部署 keys must be empty or exactly: ${deploymentKeys.join(', ')}`);
82
114
  }
83
115
  if (requireExampleDefaults) {
84
- for (const key of ['FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE']) {
85
- const entry = sections.get('部署').find((item) => item.key === key);
86
- if (entry && entry.value !== exampleValues.get(key)) throw new Error(`${relativeTarget} must keep the standard default for ${key}`);
116
+ for (const { key, value } of deploymentEntries) {
117
+ if (value !== deploymentDefaults.get(key)) throw new Error(`${relativePath} must keep the standard deployment default for ${key}`);
87
118
  }
88
119
  }
89
120
  return {
90
- extended: actualDeploymentKeys.length === deploymentKeys.length,
91
- nonDeploymentKeys: [...seenKeys].filter((key) => !deploymentKeys.includes(key)),
121
+ apiExtended: presentApiKeys.length === apiKeys.length,
122
+ deploymentExtended: deploymentEntries.length === deploymentKeys.length,
123
+ nonDeploymentKeys: [...seen].filter((key) => !deploymentKeys.includes(key)),
92
124
  };
93
125
  }
94
126
 
95
- function extendDeploymentSection(text) {
96
- return `${text.trimEnd()}\n${deploymentKeys.map((key) => `${key}=${['FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE'].includes(key) ? exampleValues.get(key) : ''}`).join('\n')}\n`;
127
+ function extendApiSection(text, apiDefaults) {
128
+ const additions = apiKeys.map((key) => `${key}=${apiDefaults.get(key)}`).join('\n');
129
+ return `${text.replaceAll('\r\n', '\n').trimEnd().replace(/^# 部署$/m, `${additions}\n\n# 部署`)}\n`;
97
130
  }
98
131
 
99
- const args = process.argv.slice(2);
100
- let mode;
101
- let force = false;
102
- let targetArgument;
103
- for (let index = 0; index < args.length; index += 1) {
104
- const argument = args[index];
105
- if (argument === '--target') {
106
- if (targetArgument !== undefined || !args[index + 1] || args[index + 1].startsWith('--')) {
107
- throw new Error('Duplicate or missing --target argument');
108
- }
109
- targetArgument = args[index + 1];
110
- index += 1;
111
- } else if (argument === '--write' || argument === '--check') {
112
- if (mode !== undefined) throw new Error('Exactly one of --write or --check is required');
113
- mode = argument.slice(2);
114
- } else if (argument === '--force') {
115
- if (force) throw new Error('Duplicate --force argument');
116
- force = true;
117
- } else {
118
- throw new Error(`Unknown argument: ${argument}`);
132
+ function extendDeploymentSection(text, deploymentDefaults) {
133
+ const additions = deploymentKeys.map((key) => `${key}=${deploymentDefaults.get(key)}`).join('\n');
134
+ return `${text.trimEnd()}\n${additions}\n`;
135
+ }
136
+
137
+ async function readRegularFile(path, label, allowMissing = false) {
138
+ try {
139
+ const info = await lstat(path);
140
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error(`${label} must be a regular file`);
141
+ return await readFile(path);
142
+ } catch (error) {
143
+ if (allowMissing && error.code === 'ENOENT') return undefined;
144
+ throw error;
119
145
  }
120
146
  }
121
- if (!mode || targetArgument === undefined || (force && mode !== 'write')) {
122
- throw new Error('Usage: generate-deployment-files.mjs --target <project-root> (--write [--force] | --check)');
147
+
148
+ async function atomicWrite(path, content, mode) {
149
+ await mkdir(dirname(path), { recursive: true });
150
+ const temporary = `${path}.wdyy-${process.pid}.tmp`;
151
+ try {
152
+ await writeFile(temporary, content, { flag: 'wx', mode });
153
+ await chmod(temporary, mode);
154
+ await rename(temporary, path);
155
+ } catch (error) {
156
+ await unlink(temporary).catch(() => {});
157
+ throw error;
158
+ }
123
159
  }
124
160
 
125
- const targetRoot = resolve(targetArgument);
126
- const targetStat = await stat(targetRoot);
127
- if (!targetStat.isDirectory()) throw new Error(`Target project root is not a directory: ${targetRoot}`);
161
+ const { mode, force, target } = parseArgs(process.argv.slice(2));
162
+ const targetInfo = await stat(target);
163
+ if (!targetInfo.isDirectory()) throw new Error(`Target project root is not a directory: ${target}`);
164
+
165
+ const deploymentTemplate = await readFile(join(templatesRoot, 'env.example.template'), 'utf8');
166
+ const deploymentDefaults = new Map([...deploymentTemplate.matchAll(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/gm)].map((match) => [match[1], match[2]]));
167
+ if ([...apiKeys, ...deploymentKeys].some((key) => !deploymentDefaults.has(key))) throw new Error('Deployment environment template is incomplete');
168
+ const apiDefaults = new Map(apiKeys.map((key) => [key, deploymentDefaults.get(key)]));
128
169
 
129
- const environmentTemplate = await readFile(join(templatesRoot, 'env.example.template'), 'utf8');
130
- const exampleValues = new Map(
131
- [...environmentTemplate.matchAll(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/gm)].map((match) => [match[1], match[2]]),
132
- );
133
170
  const environmentPlans = [];
134
- for (const [relativeTarget, expectedMode, requireExampleDefaults] of [['.env', 0o600, false], ['.env.example', 0o644, true]]) {
135
- const target = join(targetRoot, relativeTarget);
136
- const targetInfo = await lstat(target);
137
- if (targetInfo.isSymbolicLink() || !targetInfo.isFile()) throw new Error(`Environment target must be a regular file: ${relativeTarget}`);
138
- const existing = await readFile(target, 'utf8');
139
- const structure = parseEnvironmentStructure(existing, relativeTarget, requireExampleDefaults, exampleValues);
171
+ for (const [relativePath, expectedMode, requireDefaults] of [['.env', 0o600, false], ['.env.example', 0o644, true]]) {
172
+ const path = join(target, relativePath);
173
+ const existingBuffer = await readRegularFile(path, relativePath);
174
+ const existing = existingBuffer.toString('utf8');
175
+ const parsed = parseEnvironment(existing, relativePath, requireDefaults, deploymentDefaults);
176
+ let expected = existing;
177
+ if (!parsed.apiExtended) expected = extendApiSection(expected, apiDefaults);
178
+ if (!parsed.deploymentExtended) expected = extendDeploymentSection(expected, deploymentDefaults);
140
179
  environmentPlans.push({
141
- target,
142
- relativeTarget,
143
- expectedMode,
144
- existing,
145
- nonDeploymentKeys: structure.nonDeploymentKeys,
146
- expected: structure.extended ? existing : extendDeploymentSection(existing),
180
+ path, relativePath, expectedMode,
181
+ existing: existingBuffer,
182
+ expected: Buffer.from(expected),
183
+ nonDeploymentKeys: parsed.nonDeploymentKeys,
147
184
  });
148
185
  }
149
186
  if (environmentPlans[0].nonDeploymentKeys.join('|') !== environmentPlans[1].nonDeploymentKeys.join('|')) {
150
187
  throw new Error('.env and .env.example variable names must remain consistent');
151
188
  }
152
189
 
153
- const differences = [];
154
- for (const [templateName, relativeTarget, expectedMode] of mappings) {
155
- const source = join(templatesRoot, templateName);
156
- const target = join(targetRoot, relativeTarget);
157
- const expected = await readFile(source);
158
- let existing;
159
- try {
160
- const targetInfo = await lstat(target);
161
- if (targetInfo.isSymbolicLink() || !targetInfo.isFile()) throw new Error(`Generated target must be a regular file: ${relativeTarget}`);
162
- existing = await readFile(target);
163
- } catch (error) {
164
- if (error.code !== 'ENOENT') throw error;
165
- }
190
+ const generatedPlans = [];
191
+ for (const [templateName, relativePath, expectedMode] of mappings) {
192
+ const expected = await readFile(join(templatesRoot, templateName));
193
+ const path = join(target, relativePath);
194
+ const existing = await readRegularFile(path, relativePath, true);
195
+ generatedPlans.push({ path, relativePath, expectedMode, expected, existing });
196
+ }
166
197
 
167
- const differs = !existing || !existing.equals(expected);
168
- if (mode === 'check') {
169
- if (differs) differences.push(relativeTarget);
170
- if (!differs && relativeTarget === 'deploy.sh') {
171
- const info = await stat(target);
172
- if ((info.mode & 0o111) === 0) differences.push(`${relativeTarget} (not executable)`);
173
- }
174
- continue;
175
- }
176
- if (differs && existing && !force) {
177
- differences.push(relativeTarget);
178
- continue;
179
- }
180
- if (differs) {
181
- await mkdir(dirname(target), { recursive: true });
182
- const temporary = `${target}.wdyy-${process.pid}.tmp`;
183
- try {
184
- await writeFile(temporary, expected, { flag: 'wx', mode: expectedMode });
185
- await chmod(temporary, expectedMode);
186
- await rename(temporary, target);
187
- } catch (error) {
188
- await unlink(temporary).catch(() => {});
189
- throw error;
190
- }
191
- } else {
192
- await chmod(target, expectedMode);
193
- }
198
+ const differingGenerated = generatedPlans.filter(({ existing, expected }) => existing && !existing.equals(expected)).map(({ relativePath }) => relativePath);
199
+ if (mode === 'write' && differingGenerated.length && !force) {
200
+ throw new Error(`Refusing to overwrite differing deployment files without --force: ${differingGenerated.join(', ')}`);
194
201
  }
195
202
 
196
- if (mode === 'write' && differences.length) {
197
- throw new Error(`Refusing to overwrite differing deployment files without --force: ${differences.join(', ')}`);
203
+ const differences = [];
204
+ for (const plan of generatedPlans) {
205
+ const differs = !plan.existing || !plan.existing.equals(plan.expected);
206
+ if (mode === 'check') {
207
+ if (differs) differences.push(plan.relativePath);
208
+ else {
209
+ const info = await stat(plan.path);
210
+ if ((info.mode & 0o777) !== plan.expectedMode) differences.push(`${plan.relativePath} (mode)`);
211
+ }
212
+ } else if (differs) await atomicWrite(plan.path, plan.expected, plan.expectedMode);
213
+ else await chmod(plan.path, plan.expectedMode);
198
214
  }
199
215
 
200
216
  for (const plan of environmentPlans) {
201
- if (plan.existing !== plan.expected) {
202
- if (mode === 'check') differences.push(plan.relativeTarget);
217
+ const differs = !plan.existing.equals(plan.expected);
218
+ if (mode === 'check') {
219
+ if (differs) differences.push(plan.relativePath);
203
220
  else {
204
- const temporary = `${plan.target}.wdyy-${process.pid}.tmp`;
205
- try {
206
- await writeFile(temporary, plan.expected, { flag: 'wx', mode: plan.expectedMode });
207
- await chmod(temporary, plan.expectedMode);
208
- await rename(temporary, plan.target);
209
- } catch (error) {
210
- await unlink(temporary).catch(() => {});
211
- throw error;
212
- }
221
+ const info = await stat(plan.path);
222
+ if ((info.mode & 0o777) !== plan.expectedMode) differences.push(`${plan.relativePath} (mode)`);
213
223
  }
214
- } else if (mode === 'write') {
215
- await chmod(plan.target, plan.expectedMode);
216
- }
224
+ } else if (differs) await atomicWrite(plan.path, plan.expected, plan.expectedMode);
225
+ else await chmod(plan.path, plan.expectedMode);
217
226
  }
218
227
 
219
- if (differences.length) {
220
- const action = mode === 'check' ? 'Generated deployment files differ' : 'Refusing to overwrite differing deployment files without --force';
221
- throw new Error(`${action}: ${differences.join(', ')}`);
222
- }
223
- process.stdout.write(`${mode === 'write' ? 'generated' : 'verified'} deterministic deployment files in ${targetRoot}\n`);
228
+ if (differences.length) throw new Error(`Generated deployment files differ: ${differences.join(', ')}`);
229
+ process.stdout.write(`${mode === 'write' ? 'generated' : 'verified'} unified Docker delivery files in ${target}\n`);