@wdyy/skills 0.1.11 → 0.1.12

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 (21) hide show
  1. package/.well-known/skills/index.json +2 -2
  2. package/.well-known/skills/wdyy-deployment-standard/SKILL.md +31 -40
  3. package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
  4. package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +27 -55
  5. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +9 -5
  6. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +14 -2
  7. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +60 -109
  8. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +151 -277
  9. package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +182 -576
  10. package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +35 -0
  11. package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +0 -5
  12. package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +2 -2
  13. package/.well-known/skills/wdyy-logging-standard/SKILL.md +2 -2
  14. package/.well-known/skills/wdyy-logging-standard/agents/openai.yaml +1 -1
  15. package/.well-known/skills/wdyy-logging-standard/reference/logging-rules.md +2 -2
  16. package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.test.mjs +1 -1
  17. package/README.md +4 -2
  18. package/lib/wdyy-cli.js +3 -2
  19. package/package.json +1 -1
  20. package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +0 -70
  21. package/.well-known/skills/wdyy-deployment-standard/templates/nginx-upstream.template.conf +0 -25
@@ -7,8 +7,8 @@
7
7
  },
8
8
  {
9
9
  "name": "wdyy-deployment-standard",
10
- "description": "基于标准五段根 .env,为无 CI/CD 项目生成确定性的前后端双 Docker 发布配置、本地 deploy.sh build 完整打包、人工上传及按独立端口执行蓝绿启动、替换、重启、停止、删除和回滚;部署根由脚本位置确定且不处理数据库迁移。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.blue-green.yml", "templates/dockerignore.template", "templates/nginx-upstream.template.conf"]
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"]
12
12
  },
13
13
  {
14
14
  "name": "wdyy-api-standard",
@@ -1,72 +1,63 @@
1
1
  ---
2
2
  name: wdyy-deployment-standard
3
- description: 基于标准五段根 .env,为无 CI/CD 项目生成确定性的前后端双 Docker 发布配置、本地 deploy.sh build 完整打包、人工上传及按独立端口执行蓝绿启动、替换、重启、停止、删除和回滚;部署根由脚本位置确定且不处理数据库迁移。Use when 规划、实现、审查或执行手工生产部署时。
3
+ description: 基于标准五段根 .env,为无 CI/CD 项目生成确定性的前后端带版本双 Docker 镜像、本地 deploy.sh build 导出包、人工上传,以及服务器直接宿主机端口的交互式运行、替换和停止;不使用蓝绿部署、宿主机 Nginx 或数据库迁移。Use when 规划、实现、审查或执行手工生产部署时。
4
4
  ---
5
5
 
6
6
  # 企业部署规范
7
7
 
8
8
  ## Overview
9
9
 
10
- 把开发、构建、上传和服务器操作分开:开发只用 `pnpm dev`;本地用 `./deploy.sh build` 生成前后端双镜像和完整 `deploy/`;工程师人工上传;服务器按根 `.env` 的项目端口执行成对蓝绿切流。
10
+ 把开发、构建、上传和服务器操作分开:开发只用 `pnpm dev`;本地项目根目录用 `./deploy.sh build` 生成带版本号的前后端镜像和完整 `deploy/`;工程师人工上传;服务器项目根目录无参数运行 `./deploy.sh`,按菜单直接运行或替换固定服务。
11
11
 
12
12
  ## 使用边界
13
13
 
14
- 1. 先读取目标项目真实的 pnpm scripts、前后端构建输出、健康/版本接口、Dockerfile、`.env`Nginx include 方式。
15
- 2. 完整读取 [Linux 部署规则](reference/linux-deployment-rules.md),运行 `node scripts/generate-deployment-files.mjs --target <目标项目根目录> --write` 确定性生成受版本控制文件,并向 `.env` 与 `.env.example` 的唯一“部署”段追加同名空配置;既有文件不一致时先审查差异,只有工程师确认后才使用 `--force`。
16
- 3. 应用 URL 与端口只使用 `FRONTEND_URL`、`FRONTEND_PORT`、`BACKEND_URL`、`BACKEND_PORT`;部署端点使用各自唯一变量。健康和版本 URL 在脚本中由主机、端口及固定路径构造,不在 `.env` 中重复配置。
17
- 4. 工程师人工上传 `deploy/` 全部内容;脚本不得上传、拉取代码、安装依赖、修改共享 Nginx 主配置或替代 GO/NO-GO
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
18
  5. 生成后先运行生成器的 `--check`,再运行 `scripts/validate-deployment-package.mjs` 和全部行为测试。
19
19
 
20
20
  ## 固定契约
21
21
 
22
22
  - 项目名只允许小写字母、数字和下划线;服务器部署根由上传后的 `deploy.sh` 物理路径确定。
23
- - 两个镜像必须精确命名为 `<项目名称>_frontend:<YYYYMMDD-NNN>` 和 `<项目名称>_backend:<YYYYMMDD-NNN>`。
24
- - Compose 必须包含 frontend-blue、backend-blue、frontend-green、backend-green;同一颜色的前后端作为一个切流单元。
25
- - 宿主机 Nginx 只监听根 `.env` 的 `PROJECT_HTTP_PORT`,`/api/` 代理活动后端,其他请求代理活动前端。
26
- - 所有容器只把项目根 `./logs` 挂载到 `/app/logs`;日志文件直接位于 `logs/`,不得创建服务、颜色或日期子目录。
23
+ - 镜像必须精确命名为 `<项目名称>_frontend:<YYYYMMDD-NNN>` 和 `<项目名称>_backend:<YYYYMMDD-NNN>`。
24
+ - Compose 只允许 frontend、backend 两个服务,不设置 `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`。
27
28
  - 根 `.env` 原样复制到 `deploy/.env` 并人工上传,但必须被 `.dockerignore` 排除,且不得进入版本目录或任一镜像。
28
- - `.env.example` 是环境主模板,内置 `FRONTEND_BASE_IMAGE=nginx:stable` 与 `BACKEND_BASE_IMAGE=node:24-alpine3.24`;`.env` 可在生成后显式覆盖。相同标签在不同时间重新构建时可能对应不同基础镜像内容,但已导出的项目成品镜像仍按版本确定部署。
29
- - 环境文件固定按“前端、后端、数据库、API、部署”排列;部署生成器保留前四段和已有值,只扩充部署段。
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`。
30
31
  - 发布链路不接受数据库迁移参数,也不打包、校验或执行迁移文件。
31
- - 相同输入生成的 deploy.sh、Compose、Nginx 配置必须字节一致;版本指针、镜像归档和 manifest 可随发布变化。
32
32
 
33
33
  ## 工作流
34
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 start`;后续新版本执行 `./deploy.sh replace`。
40
- 6. 通过 `restart`、`stop`、`status`、`rollback <version>` 管理当前项目;`remove` 只删除本项目容器、网络及两个精确镜像仓库。
41
-
42
- ## Nginx 当前项目边界
43
-
44
- - 每个项目只写自身 `nginx/site.conf` 和 `nginx/active-upstreams.conf`。
45
- - upstream 名称由严格项目名确定,不接受自由文本配置。
46
- - 安装候选配置后运行 `nginx -t` 和 reload;失败时恢复本项目旧文件。
47
- - 不扫描、校验或修改其他项目配置、端口或 Nginx include。
36
+ 2. 在本地项目根目录执行不带额外参数的 `./deploy.sh build`;从 `AGENTS.md` 唯一“发布记录”表生成 `YYYYMMDD-NNN`。
37
+ 3. 依次通过测试、lint、类型检查、构建、双镜像标签检查、包校验和配置确定性检查;成功后才替换 `deploy/` 并写发布记录。
38
+ 4. 人工把 `deploy/` 全部内容上传为服务器项目根目录;生产值不得通过命令行传入。
39
+ 5. 在服务器项目根目录无参数执行 `./deploy.sh`,选择:运行或替换前端、运行或替换后端、运行或替换前后端、停止。
40
+ 6. 运行或替换会载入所选服务的目标版本镜像、强制重建并健康检查;停止会同时停止 frontend backend。
48
41
 
49
42
  ## 禁止事项
50
43
 
51
- - 不得接受人工 build/start/replace 版本参数,不得扫描目录替代发布账本或 `release.env`。
52
- - 不得使用 Bash `source`/`eval` 解析 `.env` 或状态文件。
44
+ - 不得使用蓝绿、滚动切流、活动/历史颜色、宿主机 Nginx、自动回滚或旧版 start/replace/restart/remove/status/rollback 命令。
45
+ - 不得接受人工 build 版本参数,不得扫描版本目录替代发布账本或 `release.env`。
46
+ - 不得使用 Bash `source`/`eval` 解析 `.env` 或版本指针。
53
47
  - 不得使用镜像 fallback、固定 `/srv`、固定 URL/IP/端口、PM2、systemd 或自动安装。
54
- - 不得重新引入 `FRONTEND_CONTAINER_PORT`、`BACKEND_CONTAINER_PORT`、`BACKEND_LISTEN_HOST` 或任何可推导的 `*_HEALTH_URL`、`*_VERSION_URL`。
55
- - 不得先停止活动颜色再启动新颜色,不得分别切换前后端,不得在验证前 reload。
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`。
56
49
  - 不得自动执行 psql、迁移脚本或数据库回滚。
57
- - 不得让 `remove` 删除其他项目资源、日志、发布文件、状态或 Nginx 配置。
58
- - 不得以字符串关键字存在代替命令行为、失败恢复和资源边界测试。
50
+ - 不得以字符串关键字存在代替命令行为、失败传播和资源边界测试。
59
51
 
60
52
  ## Verification
61
53
 
62
- - [ ] 两个镜像名称、标签和版本 label 精确匹配发布版本。
63
- - [ ] 四个服务均使用根 `.env`,且只挂载 `../../logs:/app/logs`。
64
- - [ ] `.env` 与 `.env.example` 的五段、变量顺序一致,部署段键相同且无重复 URL/端口配置。
65
- - [ ] 外部项目端口唯一;Nginx 一次同时切换前后端,失败恢复旧流量和状态。
66
- - [ ] start、replace、restart、stop、remove、status、rollback 行为与资源范围均通过测试。
67
- - [ ] 不存在数据库迁移参数、迁移包内容或数据库执行路径。
68
- - [ ] 连续两次构建的配置文件一致,失败不写发布记录、不暴露半成品。
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 一致,失败不写发布记录、不暴露半成品。
69
61
  - [ ] `deploy/` 含根 `.env`,但版本目录、Docker 上下文和镜像不含秘密文件。
70
- - [ ] 替换或删除容器后,项目根 `logs/` 内容仍保留。
71
62
 
72
- 任一步失败必须明确报告具体阶段并保持或恢复本项目原流量;不得以兜底、静默跳过、强制切流或删除失败证据规避问题。
63
+ 任一步失败必须明确报告具体阶段并返回非零;不得静默跳过、伪造成功或删除失败证据。
@@ -1,4 +1,4 @@
1
1
  interface:
2
2
  display_name: "wdyy-deployment-standard"
3
- short_description: "Build isolated dual-image manual deployment packages"
4
- default_prompt: "Use $wdyy-deployment-standard to generate and validate an isolated dual-image manual deployment workflow."
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."
@@ -9,17 +9,16 @@ deploy.sh
9
9
  .dockerignore
10
10
  src/frontend/Dockerfile
11
11
  src/backend/Dockerfile
12
- scripts/deployment/docker-compose.blue-green.yml
12
+ scripts/deployment/docker-compose.yml
13
13
  scripts/deployment/frontend-container.conf.template
14
- scripts/deployment/nginx-site.conf
15
14
  .env.example
16
15
  ```
17
16
 
18
- 这些文件必须由 `scripts/generate-deployment-files.mjs --target <project-root> --write` 按固定映射原子生成,templates 是唯一源。既有差异默认失败,不得静默覆盖;工程师审查并确认后才允许 `--force`。生成后执行 `--check`。build 必须原样复制 deploy.sh、Compose Nginx 模板,不得根据运行顺序产生随机内容;相同输入连续构建时这些配置的 SHA-256 必须一致。
17
+ 这些文件必须由 `scripts/generate-deployment-files.mjs --target <project-root> --write` 按固定映射原子生成,templates 是唯一源。既有差异默认失败,工程师审查确认后才允许 `--force`。生成后执行 `--check`。相同输入连续构建时 deploy.sh Compose 必须字节一致。
19
18
 
20
19
  ## 根 .env
21
20
 
22
- 根 `.env` 是本地 build、上传包和服务器 Compose 的唯一配置文件。`.env` 与 `.env.example` 固定按“前端、后端、数据库、API、部署”排列;部署生成器保留前四段与已有值,只向两个文件的部署段追加同名空键。`.env.example` 必须直接采用 `templates/env.example.template` 的键、顺序与非敏感默认值。脚本必须按数据解析,不得 `source` 或 `eval`。部署前根 `.env` 至少要求:
21
+ 根 `.env` 是本地 build、上传包和服务器 Compose 的唯一配置文件。`.env` 与 `.env.example` 固定按“前端、后端、数据库、API、部署”排列。部署前至少要求:
23
22
 
24
23
  ```dotenv
25
24
  FRONTEND_URL=0.0.0.0
@@ -33,33 +32,25 @@ DB_PASSWORD=
33
32
  DB_NAME=
34
33
  DB_SCHEMA=
35
34
  PROJECT_NAME=example_project
36
- PROJECT_HTTP_PORT=9099
37
- DOCKER_BIND_IP=127.0.0.1
38
- FRONTEND_BLUE_PORT=19091
39
- FRONTEND_GREEN_PORT=19092
40
- BACKEND_BLUE_PORT=19093
41
- BACKEND_GREEN_PORT=19094
35
+ DOCKER_BIND_IP=0.0.0.0
42
36
  FRONTEND_BASE_IMAGE=nginx:stable
43
37
  BACKEND_BASE_IMAGE=node:24-alpine3.24
44
38
  ```
45
39
 
46
- `FRONTEND_URL`、`FRONTEND_PORT`、`BACKEND_URL`、`BACKEND_PORT` 是应用监听的唯一来源;容器、蓝绿和项目入口的健康/版本 URL 必须由对应主机、端口与固定路径确定性构造。不得增加 `FRONTEND_CONTAINER_PORT`、`BACKEND_CONTAINER_PORT`、`BACKEND_LISTEN_HOST`、`*_HEALTH_URL` `*_VERSION_URL`。通配监听地址 `0.0.0.0` 仅在主动本机探测时转换为 `127.0.0.1`。
40
+ `FRONTEND_PORT`、`BACKEND_PORT` 同时是容器监听端口与宿主机直接暴露端口,二者必须不同且位于 1–65535。`PROJECT_NAME` 只允许 `^[a-z][a-z0-9_]*$`。`.env` 权限必须为 `0600` 或只读的 `0400`。脚本按数据解析环境文件,不得 `source` `eval`。
47
41
 
48
- 部署示例值只说明字段,生成目标项目后必须由工程师确认 `.env` 实际值,不得保留占位符或示例部署端口。`PROJECT_NAME` 只允许 `^[a-z][a-z0-9_]*$`。所有宿主机端口必须互不相同且在 1–65535;两个基础镜像必须是非空 Docker 镜像引用,可使用本地带标签镜像。相同标签在不同时间重新构建时可能解析为不同基础镜像内容;成功构建后导出的项目成品镜像仍用于服务器端确定部署。数据库连接键允许为空,但项目使用数据库时必须填写真实值。
49
-
50
- `.env` 权限必须为 `0600` 或只读的 `0400`。部署脚本只把明确的部署键载入自身环境;数据库密码、JWT 等其他应用键保留在文件中供 Compose 注入容器,不得改变脚本的 `PATH`、Shell 选项或命令行为。
51
-
52
- 可选路径键为 `FRONTEND_DOCKERFILE`、`BACKEND_DOCKERFILE`、`DEPLOY_CONFIG_DIR`;未设置时只能使用与目标项目真实结构一致且已验证的标准路径。
42
+ 可选路径键为 `FRONTEND_DOCKERFILE`、`BACKEND_DOCKERFILE`、`DOCKERIGNORE_FILE`、`DEPLOY_CONFIG_DIR` `COMPOSE_SOURCE`;它们不属于标准 `.env.example`,只在目标项目确有非标准结构且已确认时使用。
53
43
 
54
44
  ## Docker 与日志
55
45
 
56
46
  - 前端镜像:`${PROJECT_NAME}_frontend:${RELEASE_VERSION}`。
57
47
  - 后端镜像:`${PROJECT_NAME}_backend:${RELEASE_VERSION}`。
58
- - 四个服务由同一个 `docker compose --project-name "$PROJECT_NAME"` 管理,不设置 `container_name`。
59
- - 四个服务都使用 `../../.env`,复用 `FRONTEND_PORT`、`BACKEND_PORT`、`FRONTEND_URL`、`BACKEND_URL`,并且只有 `../../logs:/app/logs` 一个日志挂载。
60
- - 前端 Nginx 容器的 access/error 文件直接写入 `/app/logs/frontend-<color>-*.log`;不得创建子目录。
61
- - 后端必须把自身日志直接写入 `/app/logs`,不得依赖容器可写层保存持久日志。
62
- - `.dockerignore` 必须排除 `.env`、`.env.*`、密钥、凭据、deploy、logs、Git、依赖缓存和虚拟环境。
48
+ - Compose project 使用 `$PROJECT_NAME`,只定义 frontend、backend,不设置 `container_name`。
49
+ - frontend 端口映射为 `${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}`;backend 同理使用 `BACKEND_PORT`。
50
+ - 两个服务均使用 `../../.env`,且只挂载 `../../logs:/app/logs`。
51
+ - 前端 Nginx access/error 直接写 `/app/logs/frontend-access.log`、`/app/logs/frontend-error.log`。
52
+ - 后端日志直接写 `/app/logs`;服务器菜单操作追加到部署根 `logs/deploy.log`。
53
+ - `.dockerignore` 必须排除 `.env`、秘密、deploy、logs、Git、依赖缓存和虚拟环境。
63
54
 
64
55
  ## 发布账本与本地包
65
56
 
@@ -73,7 +64,7 @@ BACKEND_BASE_IMAGE=node:24-alpine3.24
73
64
  | 20260808-001 | 2026-08-08 09:30:00 +08:00 |
74
65
  ```
75
66
 
76
- build 使用项目级原子锁,严格验证账本后生成当天下一序号。全部门禁、双镜像、包结构和确定性校验通过后才写账本。失败必须保留原 deploy 和原账本。
67
+ build 使用项目级原子锁,严格验证账本后生成当天下一序号。全部门禁、双镜像、包结构和确定性校验通过后才写账本。
77
68
 
78
69
  ```text
79
70
  deploy/
@@ -84,44 +75,25 @@ deploy/
84
75
  ├── frontend-image.tar
85
76
  ├── backend-image.tar
86
77
  ├── manifest.sha256
87
- ├── docker/docker-compose.blue-green.yml
88
- ├── nginx/site.conf
89
- ```
90
-
91
- `release.env` 只包含当前版本。manifest 覆盖版本目录内除自身外的全部普通文件,禁止符号链接、硬链接秘密、绝对路径和 `..`。根 `.env` 可上传,但版本目录与镜像不得包含它。
92
-
93
- ## 服务器部署和 Nginx
94
-
95
- 上传目标由工程师确定;脚本通过自身物理路径确定部署根,不依赖调用者当前目录。
96
-
97
- 共享 Nginx 主配置由运维预先 include `/xxzx/home/*/nginx/site.conf`。项目脚本不得写 `/etc/nginx` 或其他项目,只维护:
98
-
99
- ```text
100
- <project-root>/nginx/site.conf
101
- <project-root>/nginx/active-upstreams.conf
78
+ └── docker/docker-compose.yml
102
79
  ```
103
80
 
104
- site 监听 `PROJECT_HTTP_PORT`,并带项目名、外部端口及四个 Docker 宿主机端口标记。首次部署和端口变更必须在共享锁内检查展开的 Nginx 配置、其他标准项目 site 以及运行中容器的实际发布端口;任一外部端口或 blue/green 宿主机端口重复、其他项目缺少端口标记或不能证明唯一时失败。候选 site 与前后端 upstream 一起安装,`nginx -t` 或 reload 失败时恢复旧文件。
105
-
106
- ## 命令
81
+ `release.env` 只包含当前版本。manifest 覆盖版本目录内除自身外的全部普通文件。根 `.env` 可上传,但版本目录与镜像不得包含它。
107
82
 
108
- - `./deploy.sh start`:只允许无活动状态的首次 blue 部署。
109
- - `./deploy.sh replace`:只允许已有活动状态且 release.env 指向不同新版本。
110
- - `./deploy.sh restart`:重新创建并验证当前颜色的两个容器,不改变流量颜色和版本。
111
- - `./deploy.sh stop`:停止当前颜色的两个容器,保留所有文件和状态。
112
- - `./deploy.sh remove`:删除 Compose project 下四个容器及网络,并删除两个精确项目镜像仓库的全部标签;保留日志、包、状态和 Nginx 文件。
113
- - `./deploy.sh status`:报告 active/previous 和四服务状态。
114
- - `./deploy.sh rollback <version>`:只允许 previous 中已保留且健康的成对容器。
83
+ ## 服务器菜单
115
84
 
116
- rollback 的版本外,命令不得接受额外参数。
85
+ 工程师把 `deploy/` 内容完整上传为服务器项目根目录,随后无参数执行 `./deploy.sh`:
117
86
 
118
- ## 数据库
87
+ 1. 运行或替换前端:验证包和前端镜像标签,只载入并重建 frontend,验证 `/health` 与 `/version`。
88
+ 2. 运行或替换后端:验证包和后端镜像标签,只载入并重建 backend,验证 `/health` 与 `/version`。
89
+ 3. 运行或替换前后端:载入并重建两个服务,两个服务全部验证成功后报告成功。
90
+ 4. 停止:同时停止 frontend 和 backend,保留容器、镜像、上传文件和日志。
119
91
 
120
- 部署 Skill 不接受数据库迁移参数,也不打包、校验、提示或执行迁移文件。
92
+ 菜单操作使用 `release.env` 指向的版本,不接受版本参数。非法输入必须在 Docker 状态变更前失败。部署根始终由脚本物理路径确定。
121
93
 
122
- ## 失败边界
94
+ ## 数据库和失败边界
123
95
 
124
- - 新颜色前后端全部健康且后端版本正确前,不得修改 Nginx 或活动状态。
125
- - Nginx candidate、全局校验、reload 或状态提交失败时,必须恢复本项目旧配置和旧状态。
126
- - 不得停止旧颜色掩盖新颜色失败;不得修改、停止或删除其他项目任何资源。
127
- - `remove` 不得删除 logs、上传版本目录、release.env、状态文件或 Nginx 配置。
96
+ - 部署 Skill 不接受数据库迁移参数,不打包、校验、提示或执行迁移文件。
97
+ - 固定服务替换允许短暂停机;失败时返回非零并记录阶段,不自动回滚或伪造成功。
98
+ - 脚本不得上传文件、拉取代码、安装软件、修改宿主机 Nginx 或操作其他项目资源。
99
+ - 旧蓝绿容器、状态和宿主机 Nginx 配置必须由工程师按旧部署实际状态单独迁移,新脚本不得猜测并自动删除。
@@ -10,9 +10,8 @@ const mappings = [
10
10
  ['dockerignore.template', '.dockerignore', 0o644],
11
11
  ['frontend.Dockerfile.template', 'src/frontend/Dockerfile', 0o644],
12
12
  ['Dockerfile.template', 'src/backend/Dockerfile', 0o644],
13
- ['docker-compose.blue-green.yml', 'scripts/deployment/docker-compose.blue-green.yml', 0o644],
13
+ ['docker-compose.yml', 'scripts/deployment/docker-compose.yml', 0o644],
14
14
  ['frontend-container.conf.template', 'scripts/deployment/frontend-container.conf.template', 0o644],
15
- ['nginx-upstream.template.conf', 'scripts/deployment/nginx-site.conf', 0o644],
16
15
  ];
17
16
 
18
17
  const sectionNames = ['前端', '后端', '数据库', 'API', '部署'];
@@ -22,11 +21,16 @@ const baseKeys = new Map([
22
21
  ['数据库', ['DB_URL', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME', 'DB_SCHEMA']],
23
22
  ]);
24
23
  const deploymentKeys = [
25
- 'PROJECT_NAME', 'PROJECT_HTTP_PORT', 'DOCKER_BIND_IP',
26
- 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT',
24
+ 'PROJECT_NAME', 'DOCKER_BIND_IP',
27
25
  'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE',
28
26
  ];
29
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',
32
+ ]);
33
+
30
34
  function parseEnvironmentStructure(text, relativeTarget, requireExampleDefaults, exampleValues) {
31
35
  const normalized = text.replaceAll('\r\n', '\n');
32
36
  const sections = new Map(sectionNames.map((name) => [name, []]));
@@ -47,7 +51,7 @@ function parseEnvironmentStructure(text, relativeTarget, requireExampleDefaults,
47
51
  const entry = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
48
52
  if (!entry) throw new Error(`${relativeTarget}:${index + 1} must use KEY=VALUE syntax`);
49
53
  if (seenKeys.has(entry[1])) throw new Error(`${relativeTarget} contains duplicate key: ${entry[1]}`);
50
- if (['FRONTEND_CONTAINER_PORT', 'BACKEND_CONTAINER_PORT', 'BACKEND_LISTEN_HOST'].includes(entry[1]) || /_(?:HEALTH|VERSION)_URL$/.test(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])) {
51
55
  throw new Error(`${relativeTarget} contains legacy duplicate configuration: ${entry[1]}`);
52
56
  }
53
57
  seenKeys.add(entry[1]);
@@ -34,11 +34,14 @@ test('确定性生成全部标准文件并通过 check', async () => {
34
34
  const checked = run(root, '--check');
35
35
  assert.equal(checked.status, 0, checked.stderr);
36
36
  assert.match(checked.stdout, /verified deterministic deployment files/);
37
- assert.match(await readFile(join(root, 'deploy.sh'), 'utf8'), /build\|start\|replace/);
37
+ assert.match(await readFile(join(root, 'deploy.sh'), 'utf8'), /server_menu/);
38
+ assert.match(await readFile(join(root, 'scripts/deployment/docker-compose.yml'), 'utf8'), /^ frontend:[\s\S]*^ backend:/m);
39
+ await assert.rejects(stat(join(root, 'scripts/deployment/docker-compose.blue-green.yml')));
38
40
  const environment = await readFile(join(root, '.env'), 'utf8');
39
41
  const example = await readFile(join(root, '.env.example'), 'utf8');
40
42
  assert.match(environment, /^# 前端\nFRONTEND_URL=0\.0\.0\.0\nFRONTEND_PORT=5173$/m);
41
- assert.match(environment, /^# 部署\nPROJECT_NAME=\nPROJECT_HTTP_PORT=/m);
43
+ assert.match(environment, /^# 部署\nPROJECT_NAME=\nDOCKER_BIND_IP=/m);
44
+ assert.doesNotMatch(environment, /PROJECT_HTTP_PORT|BLUE_PORT|GREEN_PORT/);
42
45
  assert.match(environment, /^FRONTEND_BASE_IMAGE=nginx:stable$/m);
43
46
  assert.match(environment, /^BACKEND_BASE_IMAGE=node:24-alpine3\.24$/m);
44
47
  assert.equal(example, environment);
@@ -106,3 +109,12 @@ test('重复或冲突参数被明确拒绝', async () => {
106
109
  assert.notEqual(duplicateTarget.status, 0);
107
110
  assert.match(duplicateTarget.stderr, /Duplicate or missing --target argument/);
108
111
  });
112
+
113
+ test('旧蓝绿和宿主机 Nginx 配置键被明确拒绝', async () => {
114
+ const root = await projectRoot();
115
+ const envPath = join(root, '.env');
116
+ await writeFile(envPath, (await readFile(envPath, 'utf8')).replace('# 部署\n', '# 部署\nPROJECT_HTTP_PORT=9099\n'), { mode: 0o600 });
117
+ const generated = run(root, '--write');
118
+ assert.notEqual(generated.status, 0);
119
+ assert.match(generated.stderr, /legacy duplicate configuration/);
120
+ });
@@ -3,21 +3,21 @@ import { createHash } from 'node:crypto';
3
3
  import { lstat, readFile, readdir, stat } from 'node:fs/promises';
4
4
  import { basename, join, relative, resolve, sep } from 'node:path';
5
5
 
6
- const [deployPath, nginxPath, composePath, packageRoot] = process.argv.slice(2);
7
- if (!deployPath || !nginxPath || !composePath || !packageRoot) {
8
- throw new Error('Pass deploy.sh, Nginx template, Compose file and generated deploy directory paths');
6
+ const [deployPath, composePath, packageRoot] = process.argv.slice(2);
7
+ if (!deployPath || !composePath || !packageRoot || process.argv.length !== 5) {
8
+ throw new Error('Pass deploy.sh, Compose file and generated deploy directory paths');
9
9
  }
10
10
 
11
- const [deploy, nginx, compose] = await Promise.all([
12
- readFile(deployPath, 'utf8'),
13
- readFile(nginxPath, 'utf8'),
14
- readFile(composePath, 'utf8'),
15
- ]);
11
+ const [deploy, compose] = await Promise.all([readFile(deployPath, 'utf8'), readFile(composePath, 'utf8')]);
16
12
 
17
13
  function requirePattern(text, pattern, message) {
18
14
  if (!pattern.test(text)) throw new Error(message);
19
15
  }
20
16
 
17
+ function assertDeepEqual(actual, expected, message) {
18
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
19
+ }
20
+
21
21
  function parseDotenv(text) {
22
22
  const values = new Map();
23
23
  for (const [index, raw] of text.replaceAll('\r\n', '\n').split('\n').entries()) {
@@ -26,141 +26,98 @@ function parseDotenv(text) {
26
26
  if (!match) throw new Error(`Invalid root .env line ${index + 1}`);
27
27
  if (values.has(match[1])) throw new Error(`Duplicate root .env key: ${match[1]}`);
28
28
  let value = match[2];
29
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
30
- value = value.slice(1, -1);
31
- } else if (value.startsWith('"') || value.startsWith("'")) {
32
- throw new Error(`Unclosed root .env quote: ${match[1]}`);
33
- }
29
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
30
+ else if (value.startsWith('"') || value.startsWith("'")) throw new Error(`Unclosed root .env quote: ${match[1]}`);
34
31
  values.set(match[1], value);
35
32
  }
36
33
  return values;
37
34
  }
38
35
 
39
- const commands = ['build', 'start', 'replace', 'restart', 'stop', 'remove', 'status', 'rollback'];
40
- for (const command of commands) {
41
- requirePattern(deploy, new RegExp(`^ ${command}\\)`, 'm'), `Missing deployment command: ${command}`);
42
- }
43
- for (const command of commands.filter((item) => item !== 'rollback')) {
44
- requirePattern(
45
- deploy,
46
- new RegExp(`^ ${command}\\) \\[\\[ "\\$#" -eq 1 \\]\\] \\|\\| usage;`, 'm'),
47
- `${command} must reject extra arguments`,
48
- );
49
- }
50
- requirePattern(deploy, /^ rollback\) \[\[ "\$#" -eq 2 \]\] \|\| usage;/m, 'rollback must require exactly one version');
51
-
52
- const requiredDeploySemantics = [
53
- /release_ledger next/,
54
- /pnpm test/,
55
- /pnpm lint/,
56
- /pnpm typecheck/,
57
- /pnpm build/,
58
- /frontend_image="\$\{PROJECT_NAME\}_frontend:\$version"/,
59
- /backend_image="\$\{PROJECT_NAME\}_backend:\$version"/,
60
- /docker save -o "\$version_root\/frontend-image\.tar"/,
61
- /docker save -o "\$version_root\/backend-image\.tar"/,
62
- /load_dotenv "\$DEPLOY_ROOT\/\.env"/,
63
- /endpoint_url "\$DOCKER_BIND_IP"/,
64
- /flock -x 9/,
65
- /Nginx reload failed; old project configuration restored/,
66
- /docker ps -aq --filter "label=com\.docker\.compose\.project=\$PROJECT_NAME"/,
67
- /\$1 == frontend \|\| \$1 == backend/,
68
- ];
69
- for (const pattern of requiredDeploySemantics) requirePattern(deploy, pattern, `Missing deployment behavior: ${pattern}`);
70
- if (/\bsource\s+["']?[^\n]*(?:\.env|ACTIVE_STATE|PREVIOUS_STATE)/.test(deploy)) {
71
- throw new Error('Environment and state files must be parsed as data, not sourced');
72
- }
36
+ for (const [pattern, message] of [
37
+ [/if \[\[ "\$#" -eq 0 \]\]; then server_menu/, 'No-argument execution must open the server menu'],
38
+ [/elif \[\[ "\$#" -eq 1 && "\$1" == build \]\]; then build_release/, 'build must be the only command-line operation'],
39
+ [/'1\. 运行或替换前端'/, 'Missing frontend menu item'],
40
+ [/'2\. 运行或替换后端'/, 'Missing backend menu item'],
41
+ [/'3\. 运行或替换前后端'/, 'Missing dual-service menu item'],
42
+ [/'4\. 停止'/, 'Missing stop menu item'],
43
+ [/docker save -o "\$version_root\/frontend-image\.tar"/, 'Missing frontend image export'],
44
+ [/docker save -o "\$version_root\/backend-image\.tar"/, 'Missing backend image export'],
45
+ [/frontend_image="\$\{PROJECT_NAME\}_frontend:\$version"/, 'Missing versioned frontend image name'],
46
+ [/backend_image="\$\{PROJECT_NAME\}_backend:\$version"/, 'Missing versioned backend image name'],
47
+ [/compose up -d --force-recreate --no-deps frontend/, 'Frontend operation must only recreate frontend'],
48
+ [/compose up -d --force-recreate --no-deps backend/, 'Backend operation must only recreate backend'],
49
+ [/compose up -d --force-recreate frontend backend/, 'Combined operation must recreate both services'],
50
+ [/compose stop frontend backend/, 'Stop operation must stop both services'],
51
+ [/logs\/deploy\.log/, 'Server operations must write deploy.log'],
52
+ [/pnpm test/, 'Missing test release gate'],
53
+ [/pnpm lint/, 'Missing lint release gate'],
54
+ [/pnpm typecheck/, 'Missing typecheck release gate'],
55
+ [/pnpm build/, 'Missing build release gate'],
56
+ ]) requirePattern(deploy, pattern, message);
57
+
58
+ if (/^\s*(?:start|replace|restart|remove|status|rollback)\)/m.test(deploy)) throw new Error('Legacy server commands are forbidden');
59
+ if (/\bsource\s+["']?[^\n]*\.env/.test(deploy)) throw new Error('Environment files must be parsed as data, not sourced');
73
60
  if (/\beval\b/.test(deploy)) throw new Error('deploy.sh must not use eval');
74
61
  if (/\bpsql\b/.test(deploy)) throw new Error('deploy.sh must not execute database clients');
75
62
  if (/\b(?:scp|rsync)\b|\bgit\s+(?:pull|clone)\b/.test(deploy)) throw new Error('deploy.sh must not upload or pull artifacts');
76
- if (/(?:^|[\s"'])\/srv\//m.test(`${deploy}\n${nginx}\n${compose}`)) throw new Error('Fixed /srv deployment roots are forbidden');
63
+ if (/(?:^|[\s"'])\/srv\//m.test(`${deploy}\n${compose}`)) throw new Error('Fixed /srv deployment roots are forbidden');
64
+ if (/nginx\s+-(?:t|s)|active-upstreams|ACTIVE_COLOR|PREVIOUS_COLOR/.test(deploy)) throw new Error('Host Nginx and blue-green state are forbidden');
77
65
 
78
66
  const serviceNames = [...compose.matchAll(/^ ([a-z-]+):$/gm)].map((match) => match[1]);
79
- assertDeepEqual(serviceNames, ['frontend-blue', 'backend-blue', 'frontend-green', 'backend-green'], 'Compose must define exactly four ordered blue-green services');
67
+ assertDeepEqual(serviceNames, ['frontend', 'backend'], 'Compose must define exactly two ordered services');
80
68
  if (compose.includes(':-') || compose.includes('not-configured')) throw new Error('Compose image fallback is forbidden');
81
- if ((compose.match(/image: \$\{FRONTEND_IMAGE:\?FRONTEND_IMAGE is required\}/g) ?? []).length !== 2) throw new Error('Both frontend services must require FRONTEND_IMAGE');
82
- if ((compose.match(/image: \$\{BACKEND_IMAGE:\?BACKEND_IMAGE is required\}/g) ?? []).length !== 2) throw new Error('Both backend services must require BACKEND_IMAGE');
83
- if ((compose.match(/env_file: \.\.\/\.\.\/\.env/g) ?? []).length !== 4) throw new Error('All four services must use root .env');
84
- const logMounts = compose.match(/^\s*- \.\.\/\.\.\/logs:\/app\/logs\s*$/gm) ?? [];
85
- if (logMounts.length !== 4) throw new Error('All four services must mount flat project-root logs');
86
- if (/LOG_DIR|HOST_LOG_DIR|logs\/(?:frontend|backend|blue|green)/.test(compose)) throw new Error('Configurable or nested log directories are forbidden');
87
- if (/container_name:/.test(compose)) throw new Error('container_name would weaken Compose project isolation');
88
- if (/FRONTEND_CONTAINER_PORT|BACKEND_CONTAINER_PORT|BACKEND_LISTEN_HOST|_HEALTH_URL|_VERSION_URL/.test(compose)) {
89
- throw new Error('Compose contains legacy duplicate URL or port configuration');
90
- }
91
- for (const key of ['FRONTEND_PORT', 'BACKEND_PORT', 'FRONTEND_URL', 'BACKEND_URL']) {
92
- if (!compose.includes(`\${${key}}`)) throw new Error(`Compose must use ${key}`);
93
- }
94
-
95
- for (const placeholder of ['__PROJECT_NAME__', '__PROJECT_HTTP_PORT__', '__DEPLOY_ROOT__', '__FRONTEND_UPSTREAM_NAME__', '__BACKEND_UPSTREAM_NAME__']) {
96
- if (!nginx.includes(placeholder)) throw new Error(`Nginx template is missing ${placeholder}`);
97
- }
98
- if (!/^# wdyy-project: __PROJECT_NAME__$/m.test(nginx) || !/^# wdyy-port: __PROJECT_HTTP_PORT__$/m.test(nginx)) {
99
- throw new Error('Nginx template must expose deterministic project and port markers');
100
- }
101
- if (/listen\s+\d+/.test(nginx) || /proxy_pass\s+https?:\/\/(?!__)/.test(nginx)) throw new Error('Nginx URL or port is hardcoded');
69
+ if ((compose.match(/image: \$\{FRONTEND_IMAGE:\?FRONTEND_IMAGE is required\}/g) ?? []).length !== 1) throw new Error('Frontend must require FRONTEND_IMAGE');
70
+ if ((compose.match(/image: \$\{BACKEND_IMAGE:\?BACKEND_IMAGE is required\}/g) ?? []).length !== 1) throw new Error('Backend must require BACKEND_IMAGE');
71
+ if ((compose.match(/env_file: \.\.\/\.\.\/\.env/g) ?? []).length !== 2) throw new Error('Both services must use root .env');
72
+ if ((compose.match(/^\s*- \.\.\/\.\.\/logs:\/app\/logs\s*$/gm) ?? []).length !== 2) throw new Error('Both services must mount flat project-root logs');
73
+ if (!compose.includes('"${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}"')) throw new Error('Frontend must directly expose FRONTEND_PORT');
74
+ if (!compose.includes('"${DOCKER_BIND_IP}:${BACKEND_PORT}:${BACKEND_PORT}"')) throw new Error('Backend must directly expose BACKEND_PORT');
75
+ if (/LOG_DIR|HOST_LOG_DIR|logs\/(?:frontend|backend|blue|green)|container_name:/.test(compose)) throw new Error('Configurable, nested logs or container_name are forbidden');
76
+ if (/frontend-(?:blue|green)|backend-(?:blue|green)|PROJECT_HTTP_PORT|_(?:BLUE|GREEN)_PORT|nginx\/site/.test(compose)) throw new Error('Blue-green or host Nginx Compose configuration is forbidden');
102
77
 
103
78
  const rootEnvPath = join(packageRoot, '.env');
104
79
  const rootEnvStat = await stat(rootEnvPath);
105
- if (!rootEnvStat.isFile() || (rootEnvStat.mode & 0o077) !== 0 || (rootEnvStat.mode & 0o400) === 0) {
106
- throw new Error('Root .env must be a private owner-readable file');
107
- }
80
+ if (!rootEnvStat.isFile() || (rootEnvStat.mode & 0o077) !== 0 || (rootEnvStat.mode & 0o400) === 0) throw new Error('Root .env must be a private owner-readable file');
108
81
  const rootEnvText = await readFile(rootEnvPath, 'utf8');
109
82
  const env = parseDotenv(rootEnvText);
110
83
  const sectionNames = [...rootEnvText.matchAll(/^# (前端|后端|数据库|API|部署)$/gm)].map((match) => match[1]);
111
84
  if (sectionNames.join('|') !== '前端|后端|数据库|API|部署') throw new Error('Root .env must use the five standard sections in order');
112
- const requiredEnv = [
113
- 'FRONTEND_URL', 'FRONTEND_PORT', 'BACKEND_URL', 'BACKEND_PORT', 'DB_PORT',
114
- 'PROJECT_NAME', 'PROJECT_HTTP_PORT', 'DOCKER_BIND_IP',
115
- 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT',
116
- 'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE',
117
- ];
118
- for (const key of requiredEnv) if (!env.get(key)) throw new Error(`Root .env is missing ${key}`);
85
+ for (const key of ['FRONTEND_URL', 'FRONTEND_PORT', 'BACKEND_URL', 'BACKEND_PORT', 'DB_PORT', 'PROJECT_NAME', 'DOCKER_BIND_IP', 'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE']) {
86
+ if (!env.get(key)) throw new Error(`Root .env is missing ${key}`);
87
+ }
119
88
  for (const key of env.keys()) {
120
- if (['FRONTEND_CONTAINER_PORT', 'BACKEND_CONTAINER_PORT', 'BACKEND_LISTEN_HOST', 'DATABASE_MIGRATION_MODE', 'MIGRATIONS_SOURCE', 'MIGRATION_RUNNER_SOURCE'].includes(key) || /_(?:HEALTH|VERSION)_URL$/.test(key)) {
121
- throw new Error(`Root .env contains legacy duplicate configuration: ${key}`);
89
+ if (['PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT', 'SERVER_PROJECTS_ROOT', 'NGINX_SOURCE', 'LOG_DIR', 'HOST_LOG_DIR', 'FRONTEND_CONTAINER_PORT', 'BACKEND_CONTAINER_PORT', 'BACKEND_LISTEN_HOST', 'DATABASE_MIGRATION_MODE', 'MIGRATIONS_SOURCE', 'MIGRATION_RUNNER_SOURCE'].includes(key) || /_(?:HEALTH|VERSION)_URL$/.test(key)) {
90
+ throw new Error(`Root .env contains removed deployment configuration: ${key}`);
122
91
  }
123
92
  }
124
93
  if (!/^[a-z][a-z0-9_]*$/.test(env.get('PROJECT_NAME'))) throw new Error('Invalid PROJECT_NAME');
125
- for (const key of ['FRONTEND_URL', 'BACKEND_URL', 'DOCKER_BIND_IP']) {
126
- if (!/^[A-Za-z0-9:.%-]+$/.test(env.get(key))) throw new Error(`${key} contains unsafe characters`);
127
- }
128
- if (env.has('LOG_DIR') || env.has('HOST_LOG_DIR')) throw new Error('Root .env must not configure log directories');
129
- const allPortKeys = ['FRONTEND_PORT', 'BACKEND_PORT', 'DB_PORT', 'PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT'];
130
- for (const key of allPortKeys) {
94
+ for (const key of ['FRONTEND_URL', 'BACKEND_URL', 'DOCKER_BIND_IP']) if (!/^[A-Za-z0-9:.%-]+$/.test(env.get(key))) throw new Error(`${key} contains unsafe characters`);
95
+ for (const key of ['FRONTEND_PORT', 'BACKEND_PORT', 'DB_PORT']) {
131
96
  const port = Number(env.get(key));
132
97
  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`${key} must be a valid port`);
133
98
  }
134
- const hostPortKeys = ['PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT'];
135
- const hostPorts = hostPortKeys.map((key) => Number(env.get(key)));
136
- if (hostPorts.some((port) => !Number.isInteger(port) || port < 1 || port > 65535) || new Set(hostPorts).size !== hostPorts.length) {
137
- throw new Error('Project and host blue-green ports must be valid and unique');
138
- }
99
+ if (env.get('FRONTEND_PORT') === env.get('BACKEND_PORT')) throw new Error('Frontend and backend host ports must differ');
139
100
 
140
101
  const releaseEnv = await readFile(join(packageRoot, 'release.env'), 'utf8');
141
102
  const releaseMatch = releaseEnv.match(/^RELEASE_VERSION=(\d{8}-\d{3})\n$/);
142
103
  if (!releaseMatch) throw new Error('release.env must contain exactly one release version');
143
104
  const version = releaseMatch[1];
144
- const expectedRootEntries = ['.env', version, 'deploy.sh', 'release.env'].sort();
145
- assertDeepEqual((await readdir(packageRoot)).sort(), expectedRootEntries, 'Generated deploy root has unexpected entries');
105
+ assertDeepEqual((await readdir(packageRoot)).sort(), ['.env', version, 'deploy.sh', 'release.env'].sort(), 'Generated deploy root has unexpected entries');
146
106
  if (resolve(deployPath) !== resolve(packageRoot, 'deploy.sh')) throw new Error('deploy.sh must be the package root script');
147
- if (resolve(nginxPath) !== resolve(packageRoot, version, 'nginx/site.conf')) throw new Error('Nginx path must be inside the selected version');
148
- if (resolve(composePath) !== resolve(packageRoot, version, 'docker/docker-compose.blue-green.yml')) throw new Error('Compose path must be inside the selected version');
107
+ if (resolve(composePath) !== resolve(packageRoot, version, 'docker/docker-compose.yml')) throw new Error('Compose path must be inside the selected version');
149
108
 
150
109
  const versionRoot = join(packageRoot, version);
151
- const requiredPaths = ['frontend-image.tar', 'backend-image.tar', 'manifest.sha256', 'docker/docker-compose.blue-green.yml', 'nginx/site.conf'];
152
- for (const path of requiredPaths) {
110
+ for (const path of ['frontend-image.tar', 'backend-image.tar', 'manifest.sha256', 'docker/docker-compose.yml']) {
153
111
  const info = await stat(join(versionRoot, path));
154
112
  if (!info.isFile()) throw new Error(`Required release path is not a file: ${path}`);
155
113
  }
156
114
  await assertMissing(join(versionRoot, 'database'), 'Release package must not contain database migration files');
115
+ await assertMissing(join(versionRoot, 'nginx'), 'Release package must not contain host Nginx files');
157
116
 
158
117
  const versionFiles = (await listFiles(versionRoot)).sort();
159
118
  for (const path of versionFiles) {
160
119
  const name = basename(path);
161
- if (name === '.env' || name.startsWith('.env.') || /\.(?:pem|key)$/.test(name) || /^(?:id_rsa|id_ed25519|credentials(?:\..*)?)$/.test(name)) {
162
- throw new Error(`Secret file must not enter version directory: ${path}`);
163
- }
120
+ if (name === '.env' || name.startsWith('.env.') || /\.(?:pem|key)$/.test(name) || /^(?:id_rsa|id_ed25519|credentials(?:\..*)?)$/.test(name)) throw new Error(`Secret file must not enter version directory: ${path}`);
164
121
  }
165
122
  const manifest = await readFile(join(versionRoot, 'manifest.sha256'), 'utf8');
166
123
  const entries = new Map();
@@ -176,13 +133,7 @@ for (const [path, expected] of entries) {
176
133
  if (actual !== expected) throw new Error(`Checksum mismatch: ${path}`);
177
134
  }
178
135
 
179
- process.stdout.write(`valid dual-image deployment package ${version}\n`);
180
-
181
- function assertDeepEqual(actual, expected, message) {
182
- if (JSON.stringify(actual) !== JSON.stringify(expected)) {
183
- throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
184
- }
185
- }
136
+ process.stdout.write(`valid direct dual-image deployment package ${version}\n`);
186
137
 
187
138
  async function assertMissing(path, message) {
188
139
  try { await lstat(path); } catch (error) { if (error.code === 'ENOENT') return; throw error; }