@wdyy/skills 0.1.5 → 0.1.7

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 (24) hide show
  1. package/.well-known/skills/index.json +3 -3
  2. package/.well-known/skills/wdyy-deployment-standard/SKILL.md +51 -46
  3. package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +3 -3
  4. package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +133 -7
  5. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +213 -0
  6. package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +106 -0
  7. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +201 -32
  8. package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +548 -51
  9. package/.well-known/skills/wdyy-deployment-standard/templates/Dockerfile.template +11 -4
  10. package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +835 -142
  11. package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +48 -12
  12. package/.well-known/skills/wdyy-deployment-standard/templates/dockerignore.template +17 -0
  13. package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +30 -0
  14. package/.well-known/skills/wdyy-deployment-standard/templates/frontend-container.conf.template +24 -0
  15. package/.well-known/skills/wdyy-deployment-standard/templates/frontend.Dockerfile.template +7 -0
  16. package/.well-known/skills/wdyy-deployment-standard/templates/nginx-upstream.template.conf +17 -17
  17. package/.well-known/skills/wdyy-logging-standard/SKILL.md +8 -7
  18. package/.well-known/skills/wdyy-logging-standard/agents/openai.yaml +2 -2
  19. package/.well-known/skills/wdyy-logging-standard/reference/logging-rules.md +4 -3
  20. package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.test.mjs +50 -1
  21. package/.well-known/skills/wdyy-logging-standard/templates/logger.template.ts +7 -0
  22. package/README.md +2 -2
  23. package/lib/wdyy-cli.js +35 -9
  24. package/package.json +1 -1
@@ -7,8 +7,8 @@
7
7
  },
8
8
  {
9
9
  "name": "wdyy-deployment-standard",
10
- "description": "为无 CI/CD Linux 生产环境生成本地发布门禁、人工 SCP 交付约定、前端版本化 URL 和后端 Docker 蓝绿发布脚本。Use when 规划、实现、审查或执行手工生产部署时。",
11
- "files": ["SKILL.md", "agents/openai.yaml", "reference/linux-deployment-rules.md", "scripts/validate-deployment-package.mjs", "scripts/validate-deployment-package.test.mjs", "templates/Dockerfile.template", "templates/deploy.sh.template", "templates/docker-compose.blue-green.yml", "templates/nginx-upstream.template.conf"]
10
+ "description": "基于标准五段根 .env,为无 CI/CD 的多项目 Linux 服务器生成确定性的前后端双 Docker 发布配置、本地 deploy.sh build 完整打包、人工上传及按独立端口执行蓝绿启动、替换、重启、停止、删除和回滚。Use when 规划、实现、审查或执行 `/xxzx/home/项目目录` 下的手工生产部署时。",
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"]
12
12
  },
13
13
  {
14
14
  "name": "wdyy-api-standard",
@@ -22,7 +22,7 @@
22
22
  },
23
23
  {
24
24
  "name": "wdyy-logging-standard",
25
- "description": "为 NestJS 和 Vue 项目实现前端异常上报、后端统一结构化 JSON 日志、traceId 传递、完整原始入参与 2MB 文件轮转。Use when 编写日志、错误处理、接口日志或审查生产可观测性时。",
25
+ "description": "为 NestJS 和 Vue 项目实现仅写入项目根目录 ./logs 的结构化 JSON 日志、前端异常上报、traceId 传递、完整原始入参与 2MB 文件轮转。Use when 编写日志、错误处理、接口日志或审查生产可观测性时。",
26
26
  "files": ["SKILL.md", "agents/openai.yaml", "reference/logging-rules.md", "scripts/validate-log-entry.mjs", "scripts/validate-log-entry.test.mjs", "templates/frontend-error-report.template.ts", "templates/logger.template.ts"]
27
27
  }
28
28
  ]
@@ -1,68 +1,73 @@
1
1
  ---
2
2
  name: wdyy-deployment-standard
3
- description: 为无 CI/CD Linux 生产环境生成本地发布门禁、人工 SCP 交付约定、前端版本化 URL 和后端 Docker 蓝绿发布脚本。Use when 规划、实现、审查或执行手工生产部署时。
3
+ description: 基于标准五段根 .env,为无 CI/CD 的多项目 Linux 服务器生成确定性的前后端双 Docker 发布配置、本地 deploy.sh build 完整打包、人工上传及按独立端口执行蓝绿启动、替换、重启、停止、删除和回滚。Use when 规划、实现、审查或执行 `/xxzx/home/项目目录` 下的手工生产部署时。
4
4
  ---
5
5
 
6
6
  # 企业部署规范
7
7
 
8
8
  ## Overview
9
9
 
10
- Linux 发布约束落实为前端不可变 release 与后端可健康检查、可回滚的 Docker 发布。
10
+ 把开发、构建、上传和服务器操作分开:开发只用 `pnpm dev`;本地用 `./deploy.sh build` 生成前后端双镜像和完整 `deploy/`;工程师人工上传;服务器按根 `.env` 的项目端口执行成对蓝绿切流。
11
11
 
12
- ## When to Use
12
+ ## 使用边界
13
13
 
14
- - 规划、实现、审查或发布生产部署时。
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。
18
+ 5. 生成后先运行生成器的 `--check`,再运行 `scripts/validate-deployment-package.mjs` 和全部行为测试。
15
19
 
16
- ## 协作边界
20
+ ## 固定契约
17
21
 
18
- 生产发布必须具备本地发布门禁、可观测性、健康检查、回滚和 GO/NO-GO 决策证据。工程师负责人工 SCP 上传并手动执行脚本;本 skill 不执行上传,也不取代发布决策。
22
+ - 项目名只允许小写字母、数字和下划线,并与服务器目录 `/xxzx/home/<项目名称>` 对应。
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/`,不得创建服务、颜色或日期子目录。
27
+ - 根 `.env` 原样复制到 `deploy/.env` 并人工上传,但必须被 `.dockerignore` 排除,且不得进入版本目录或任一镜像。
28
+ - 环境文件固定按“前端、后端、数据库、API、部署”排列;部署生成器保留前四段和已有值,只扩充部署段。
29
+ - `DATABASE_MIGRATION_MODE=manual` 时只打包迁移与执行脚本;`none` 时不要求数据库文件。任何部署命令都不得自动访问数据库。
30
+ - 相同输入生成的 deploy.sh、Compose、Nginx 配置必须字节一致;版本指针、镜像归档和 manifest 可随发布变化。
19
31
 
20
- ## 输入与输出
32
+ ## 工作流
21
33
 
22
- - 输入:`YYYYMMDD-NNN` 发布版本、前端压缩包、Docker 镜像归档、版本化迁移、健康检查 URL、Nginx 权限与生产配置。
23
- - 输出:Dockerfile、蓝绿 compose、版本化前端 Nginx 配置、`deploy.sh` 和部署说明。
24
- - 读取 [Linux 部署规则](reference/linux-deployment-rules.md),使用 templates shell scripts。
34
+ 1. 使用 `pnpm dev` 完成开发测试。
35
+ 2. 执行不带额外参数的 `./deploy.sh build`;从 `AGENTS.md` 唯一“发布记录”表生成 `YYYYMMDD-NNN`。
36
+ 3. 依次通过测试、lint、类型检查、构建、双镜像语义检查、包校验和配置确定性检查;成功后才替换 `deploy/` 并写发布记录。
37
+ 4. 人工把 `deploy/` 全部内容上传到 `.env` 声明的 `<SERVER_PROJECTS_ROOT>/<PROJECT_NAME>`;生产值不得通过命令行传入。
38
+ 5. 数据库模式为 manual 时,由工程师单独执行版本包中的迁移脚本并确认结果。
39
+ 6. 首次部署执行 `./deploy.sh start`;后续新版本执行 `./deploy.sh replace`。
40
+ 7. 通过 `restart`、`stop`、`status`、`rollback <version>` 管理当前项目;`remove` 只删除本项目容器、网络及两个精确镜像仓库。
25
41
 
26
- ## 执行步骤
42
+ ## Nginx 多项目隔离
27
43
 
28
- 1. 在本地依次通过测试、lint、类型检查、构建、镜像检查和发布包校验;任一步失败不得生成可发布结论。
29
- 2. 工程师将 `incoming/<version>/frontend.tar.gz`、`backend-image.tar` 和 `database/migrations` 人工 SCP 到服务器。
30
- 3. 前端构建使用 `/releases/<version>/` 资源基路径,解压到不可变 `releases/<version>`;`current` 仅用于入口页原子切换。
31
- 4. 配置版本化 hash 资源长期缓存、`index.html` 禁止强缓存;保留最近五个版本且不得早于约定观测窗口清理。
32
- 5. 使用原生 `psql` 执行尚未执行的向前兼容迁移。
33
- 6. 在非活动颜色启动新后端容器;健康检查和版本接口通过后切换 Nginx upstream,再切换前端 `current`。
34
- 7. 保留旧颜色容器作为上一版本;回滚只允许切回已保留并验证健康的上一版本。
35
- 8. 生成 `start <version>`、`stop`、`restart`、`status`、`rollback <version>`,并运行部署包校验器。
44
+ - 运维只在服务器初次接入时让共享 Nginx include `/xxzx/home/*/nginx/site.conf`;部署脚本不得编辑该全局配置。
45
+ - 每个项目只写自身 `nginx/site.conf` 和 `nginx/active-upstreams.conf`。
46
+ - upstream 名称由严格项目名确定,不接受自由文本配置。
47
+ - 在共享文件锁内检查其他项目配置和运行中容器的实际发布端口、安装候选配置、运行进程级 `nginx -t` 和 reload;失败时恢复本项目旧文件。
48
+ - 其他项目配置错误可以阻止本次 reload,但不得强制跳过检查或改写其他项目文件。
36
49
 
37
50
  ## 禁止事项
38
51
 
39
- - 不得直接覆盖正在服务的前端 `dist`。
40
- - 不得先停止旧后端容器再启动新容器。
41
- - 不得以 PM2 systemd 作为后端主部署方案。
42
- - 不得在无健康检查、版本标识或可演练回滚的情况下发布。
43
- - 不得在 `deploy.sh` 中执行 SCP、拉取未确认代码或静默安装依赖。
44
- - 不得把“保留旧 release 目录”等同于旧资源可访问;资源 URL 必须包含版本。
45
-
46
- ## Red Flags
47
-
48
- - 发布脚本在新版本通过健康检查前修改 upstream。
49
- - 前端目录被直接覆盖,或发布计划包含破坏性迁移。
52
+ - 不得接受人工 build/start/replace 版本参数,不得扫描目录替代发布账本或 `release.env`。
53
+ - 不得使用 Bash `source`/`eval` 解析 `.env` 或状态文件。
54
+ - 不得使用镜像 fallback、固定 `/srv`、固定 URL/IP/端口、PM2、systemd 或自动安装。
55
+ - 不得重新引入 `FRONTEND_CONTAINER_PORT`、`BACKEND_CONTAINER_PORT`、`BACKEND_LISTEN_HOST` 或任何可推导的 `*_HEALTH_URL`、`*_VERSION_URL`。
56
+ - 不得先停止活动颜色再启动新颜色,不得分别切换前后端,不得在验证前 reload。
57
+ - 不得自动执行 psql、迁移脚本或数据库回滚。
58
+ - 不得让 `remove` 删除其他项目资源、日志、发布文件、状态或 Nginx 配置。
59
+ - 不得以字符串关键字存在代替命令行为、失败恢复和资源边界测试。
50
60
 
51
61
  ## Verification
52
62
 
53
- - [ ] 新容器通过健康检查并返回目标版本。
54
- - [ ] upstream 切换后新请求命中新版本,旧资源在保留窗口不返回 404。
55
- - [ ] 回滚脚本在演练环境恢复旧版本。
56
- - [ ] 数据库变更的兼容性已审查。
57
- - [ ] `deploy.sh` 五个命令语义明确且不包含上传行为。
58
- - [ ] 人工上传目录包含前端包、后端镜像归档和版本化迁移。
59
-
60
- 任一步骤失败时保持或恢复旧 upstream,保留失败容器日志并报告失败环节;不得强制切流或删除旧版本掩盖问题。
61
-
62
- ## Common Rationalizations
63
-
64
- | 合理化说法 | 事实 |
65
- |---|---|
66
- | “停几秒没关系” | 发布规范要求后端无先停后启的中断窗口。 |
67
- | “覆盖 dist 最简单” | 旧页面会引用旧 hash 文件,覆盖会造成资源 404。 |
68
- | “小字段改动无需兼容” | 滚动与回滚期间新旧版本会并存。 |
63
+ - [ ] 两个镜像名称、标签和版本 label 精确匹配发布版本。
64
+ - [ ] 四个服务均使用根 `.env`,且只挂载 `../../logs:/app/logs`。
65
+ - [ ] `.env` 与 `.env.example` 的五段、变量顺序一致,部署段键相同且无重复 URL/端口配置。
66
+ - [ ] 外部项目端口唯一;Nginx 一次同时切换前后端,失败恢复旧流量和状态。
67
+ - [ ] start、replace、restart、stop、remove、status、rollback 行为与资源范围均通过测试。
68
+ - [ ] manual/none 两种数据库模式均通过测试,部署脚本不存在数据库执行路径。
69
+ - [ ] 连续两次构建的配置文件一致,失败不写发布记录、不暴露半成品。
70
+ - [ ] `deploy/` 含根 `.env`,但版本目录、Docker 上下文和镜像不含秘密文件。
71
+ - [ ] 替换或删除容器后,项目根 `logs/` 内容仍保留。
72
+
73
+ 任一步失败必须明确报告具体阶段并保持或恢复本项目原流量;不得以兜底、静默跳过、强制切流或删除失败证据规避问题。
@@ -1,4 +1,4 @@
1
1
  interface:
2
- display_name: "Enterprise Deployment Standard"
3
- short_description: "Prepare manual SCP releases with versioned assets and blue green"
4
- default_prompt: "Use $wdyy-deployment-standard to prepare and verify the manual SCP production deployment package."
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."
@@ -1,9 +1,135 @@
1
1
  # Linux 部署规则
2
2
 
3
- 1. 无 CI/CD 时必须在本地完成测试、lint、类型检查、构建、镜像和发布包验证,失败不得上传。
4
- 2. SCP 由工程师人工执行;`deploy.sh` 只消费 `incoming/<version>`,不得上传、拉取代码或安装未确认依赖。
5
- 3. 前端资源 URL 使用 `/releases/<version>/`,构建输出写入不可变 release;确认资源存在后才原子更新 `current`。
6
- 4. 后端固定使用 blue/green 两容器;非活动颜色启动、健康检查和版本接口通过后才能切换 upstream。
7
- 5. PostgreSQL 使用原生 `psql` 版本化迁移;迁移仅允许向前兼容的新增或扩展。
8
- 6. 旧颜色和旧前端 release 至少保留到观测窗口结束;默认保留最近五个版本。
9
- 7. 回滚只切换到已保留、健康且数据库兼容的上一版本。
3
+ ## 目录和受控源文件
4
+
5
+ 目标项目必须维护:
6
+
7
+ ```text
8
+ deploy.sh
9
+ .dockerignore
10
+ src/frontend/Dockerfile
11
+ src/backend/Dockerfile
12
+ scripts/deployment/docker-compose.blue-green.yml
13
+ scripts/deployment/frontend-container.conf.template
14
+ scripts/deployment/nginx-site.conf
15
+ .env.example
16
+ ```
17
+
18
+ 这些文件必须由 `scripts/generate-deployment-files.mjs --target <project-root> --write` 按固定映射原子生成,templates 是唯一源。既有差异默认失败,不得静默覆盖;工程师审查并确认后才允许 `--force`。生成后执行 `--check`。build 必须原样复制 deploy.sh、Compose 和 Nginx 模板,不得根据运行顺序产生随机内容;相同输入连续构建时这些配置的 SHA-256 必须一致。
19
+
20
+ ## 根 .env
21
+
22
+ 根 `.env` 是本地 build、上传包和服务器 Compose 的唯一配置文件。`.env` 与 `.env.example` 固定按“前端、后端、数据库、API、部署”排列;部署生成器保留前四段与已有值,只向两个文件的部署段追加同名空键。`.env.example` 必须直接采用 `templates/env.example.template` 的键、顺序与非敏感默认值。脚本必须按数据解析,不得 `source` 或 `eval`。部署前根 `.env` 至少要求:
23
+
24
+ ```dotenv
25
+ FRONTEND_URL=0.0.0.0
26
+ FRONTEND_PORT=5173
27
+ BACKEND_URL=0.0.0.0
28
+ BACKEND_PORT=3000
29
+ DB_URL=
30
+ DB_PORT=5432
31
+ DB_USER=
32
+ DB_PASSWORD=
33
+ DB_NAME=
34
+ DB_SCHEMA=
35
+ PROJECT_NAME=example_project
36
+ SERVER_PROJECTS_ROOT=/xxzx/home
37
+ PROJECT_HTTP_PORT=9099
38
+ DOCKER_BIND_IP=127.0.0.1
39
+ FRONTEND_BLUE_PORT=19091
40
+ FRONTEND_GREEN_PORT=19092
41
+ BACKEND_BLUE_PORT=19093
42
+ BACKEND_GREEN_PORT=19094
43
+ FRONTEND_BASE_IMAGE=nginx:1.29-alpine@sha256:<confirmed-digest>
44
+ BACKEND_BASE_IMAGE=node:24-bookworm-slim@sha256:<confirmed-digest>
45
+ DATABASE_MIGRATION_MODE=none
46
+ ```
47
+
48
+ `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`。
49
+
50
+ 部署示例值只说明字段,生成目标项目后必须由工程师确认 `.env` 实际值,不得保留占位符或示例部署端口。`PROJECT_NAME` 只允许 `^[a-z][a-z0-9_]*$`。所有宿主机端口必须互不相同且在 1–65535;基础镜像必须使用 digest 固定。数据库连接键允许为空,但项目使用数据库时必须填写真实值。
51
+
52
+ `.env` 权限必须为 `0600` 或只读的 `0400`。部署脚本只把明确的部署键载入自身环境;数据库密码、JWT 等其他应用键保留在文件中供 Compose 注入容器,不得改变脚本的 `PATH`、Shell 选项或命令行为。
53
+
54
+ 可选路径键为 `FRONTEND_DOCKERFILE`、`BACKEND_DOCKERFILE`、`MIGRATIONS_SOURCE`、`MIGRATION_RUNNER_SOURCE`、`DEPLOY_CONFIG_DIR`;未设置时只能使用与目标项目真实结构一致且已验证的标准路径。
55
+
56
+ ## Docker 与日志
57
+
58
+ - 前端镜像:`${PROJECT_NAME}_frontend:${RELEASE_VERSION}`。
59
+ - 后端镜像:`${PROJECT_NAME}_backend:${RELEASE_VERSION}`。
60
+ - 四个服务由同一个 `docker compose --project-name "$PROJECT_NAME"` 管理,不设置 `container_name`。
61
+ - 四个服务都使用 `../../.env`,复用 `FRONTEND_PORT`、`BACKEND_PORT`、`FRONTEND_URL`、`BACKEND_URL`,并且只有 `../../logs:/app/logs` 一个日志挂载。
62
+ - 前端 Nginx 容器的 access/error 文件直接写入 `/app/logs/frontend-<color>-*.log`;不得创建子目录。
63
+ - 后端必须把自身日志直接写入 `/app/logs`,不得依赖容器可写层保存持久日志。
64
+ - `.dockerignore` 必须排除 `.env`、`.env.*`、密钥、凭据、deploy、logs、Git、依赖缓存和虚拟环境。
65
+
66
+ ## 发布账本与本地包
67
+
68
+ `AGENTS.md` 只允许一个固定两列表:
69
+
70
+ ```markdown
71
+ ## 发布记录
72
+
73
+ | 版本号 | 构建时间 |
74
+ |---|---|
75
+ | 20260808-001 | 2026-08-08 09:30:00 +08:00 |
76
+ ```
77
+
78
+ build 使用项目级原子锁,严格验证账本后生成当天下一序号。全部门禁、双镜像、包结构和确定性校验通过后才写账本。失败必须保留原 deploy 和原账本。
79
+
80
+ ```text
81
+ deploy/
82
+ ├── .env
83
+ ├── deploy.sh
84
+ ├── release.env
85
+ └── YYYYMMDD-NNN/
86
+ ├── frontend-image.tar
87
+ ├── backend-image.tar
88
+ ├── manifest.sha256
89
+ ├── docker/docker-compose.blue-green.yml
90
+ ├── nginx/site.conf
91
+ └── database/ # 仅 manual 模式
92
+ ├── migrations/
93
+ └── apply-migrations.sh
94
+ ```
95
+
96
+ `release.env` 只包含当前版本。manifest 覆盖版本目录内除自身外的全部普通文件,禁止符号链接、硬链接秘密、绝对路径和 `..`。根 `.env` 可上传,但版本目录与镜像不得包含它。
97
+
98
+ ## 服务器部署和 Nginx
99
+
100
+ 上传目标必须是 `${SERVER_PROJECTS_ROOT}/${PROJECT_NAME}`,生产值为 `/xxzx/home/<项目名称>`。脚本通过自身物理路径验证该关系,不依赖调用者当前目录。
101
+
102
+ 共享 Nginx 主配置由运维预先 include `/xxzx/home/*/nginx/site.conf`。项目脚本不得写 `/etc/nginx` 或其他项目,只维护:
103
+
104
+ ```text
105
+ <project-root>/nginx/site.conf
106
+ <project-root>/nginx/active-upstreams.conf
107
+ ```
108
+
109
+ site 监听 `PROJECT_HTTP_PORT`,并带项目名、外部端口及四个 Docker 宿主机端口标记。首次部署和端口变更必须在共享锁内检查展开的 Nginx 配置、其他标准项目 site 以及运行中容器的实际发布端口;任一外部端口或 blue/green 宿主机端口重复、其他项目缺少端口标记或不能证明唯一时失败。候选 site 与前后端 upstream 一起安装,`nginx -t` 或 reload 失败时恢复旧文件。
110
+
111
+ ## 命令
112
+
113
+ - `./deploy.sh start`:只允许无活动状态的首次 blue 部署。
114
+ - `./deploy.sh replace`:只允许已有活动状态且 release.env 指向不同新版本。
115
+ - `./deploy.sh restart`:重新创建并验证当前颜色的两个容器,不改变流量颜色和版本。
116
+ - `./deploy.sh stop`:停止当前颜色的两个容器,保留所有文件和状态。
117
+ - `./deploy.sh remove`:删除 Compose project 下四个容器及网络,并删除两个精确项目镜像仓库的全部标签;保留日志、包、状态和 Nginx 文件。
118
+ - `./deploy.sh status`:报告 active/previous 和四服务状态。
119
+ - `./deploy.sh rollback <version>`:只允许 previous 中已保留且健康的成对容器。
120
+
121
+ 除 rollback 的版本外,命令不得接受额外参数。
122
+
123
+ ## 数据库
124
+
125
+ - `DATABASE_MIGRATION_MODE=none`:版本包不要求数据库目录。
126
+ - `DATABASE_MIGRATION_MODE=manual`:必须打包完整迁移目录和可执行脚本。
127
+ - deploy.sh 的任何服务器命令都不得调用迁移脚本、psql 或数据库客户端。
128
+ - build 完成和服务器 start/replace 必须明确提示 manual 操作尚需工程师单独执行,不得声称已迁移。
129
+
130
+ ## 失败边界
131
+
132
+ - 新颜色前后端全部健康且后端版本正确前,不得修改 Nginx 或活动状态。
133
+ - Nginx candidate、全局校验、reload 或状态提交失败时,必须恢复本项目旧配置和旧状态。
134
+ - 不得停止旧颜色掩盖新颜色失败;不得修改、停止或删除其他项目任何资源。
135
+ - `remove` 不得删除 logs、上传版本目录、release.env、状态文件或 Nginx 配置。
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ import { chmod, lstat, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const skillRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
7
+ const templatesRoot = join(skillRoot, 'templates');
8
+ const mappings = [
9
+ ['deploy.sh.template', 'deploy.sh', 0o755],
10
+ ['dockerignore.template', '.dockerignore', 0o644],
11
+ ['frontend.Dockerfile.template', 'src/frontend/Dockerfile', 0o644],
12
+ ['Dockerfile.template', 'src/backend/Dockerfile', 0o644],
13
+ ['docker-compose.blue-green.yml', 'scripts/deployment/docker-compose.blue-green.yml', 0o644],
14
+ ['frontend-container.conf.template', 'scripts/deployment/frontend-container.conf.template', 0o644],
15
+ ['nginx-upstream.template.conf', 'scripts/deployment/nginx-site.conf', 0o644],
16
+ ];
17
+
18
+ const sectionNames = ['前端', '后端', '数据库', 'API', '部署'];
19
+ const baseKeys = new Map([
20
+ ['前端', ['FRONTEND_URL', 'FRONTEND_PORT']],
21
+ ['后端', ['BACKEND_URL', 'BACKEND_PORT']],
22
+ ['数据库', ['DB_URL', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME', 'DB_SCHEMA']],
23
+ ]);
24
+ const deploymentKeys = [
25
+ 'PROJECT_NAME', 'SERVER_PROJECTS_ROOT', 'PROJECT_HTTP_PORT', 'DOCKER_BIND_IP',
26
+ 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT',
27
+ 'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE', 'DATABASE_MIGRATION_MODE',
28
+ ];
29
+
30
+ function parseEnvironmentStructure(text, relativeTarget, requireExampleDefaults, exampleValues) {
31
+ const normalized = text.replaceAll('\r\n', '\n');
32
+ const sections = new Map(sectionNames.map((name) => [name, []]));
33
+ const encounteredSections = [];
34
+ const seenKeys = new Set();
35
+ let currentSection;
36
+
37
+ for (const [index, line] of normalized.split('\n').entries()) {
38
+ if (!line) continue;
39
+ const heading = line.match(/^# (前端|后端|数据库|API|部署)$/);
40
+ if (heading) {
41
+ currentSection = heading[1];
42
+ encounteredSections.push(currentSection);
43
+ continue;
44
+ }
45
+ if (line.startsWith('#')) continue;
46
+ if (!currentSection) throw new Error(`${relativeTarget}:${index + 1} must be inside a standard section`);
47
+ const entry = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
48
+ if (!entry) throw new Error(`${relativeTarget}:${index + 1} must use KEY=VALUE syntax`);
49
+ 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])) {
51
+ throw new Error(`${relativeTarget} contains legacy duplicate configuration: ${entry[1]}`);
52
+ }
53
+ seenKeys.add(entry[1]);
54
+ sections.get(currentSection).push({ key: entry[1], value: entry[2] });
55
+ }
56
+
57
+ if (encounteredSections.join('|') !== sectionNames.join('|')) {
58
+ throw new Error(`${relativeTarget} section order must be: ${sectionNames.map((name) => `# ${name}`).join(', ')}`);
59
+ }
60
+ for (const [section, expectedKeys] of baseKeys) {
61
+ const entries = sections.get(section);
62
+ const actualKeys = entries.map(({ key }) => key);
63
+ if (actualKeys.join('|') !== expectedKeys.join('|')) {
64
+ throw new Error(`${relativeTarget} # ${section} keys must be: ${expectedKeys.join(', ')}`);
65
+ }
66
+ if (requireExampleDefaults) {
67
+ for (const { key, value } of entries) {
68
+ if (value !== exampleValues.get(key)) throw new Error(`${relativeTarget} must keep the standard default for ${key}`);
69
+ }
70
+ }
71
+ }
72
+ const actualDeploymentKeys = sections.get('部署').map(({ key }) => key);
73
+ if (actualDeploymentKeys.length !== 0 && actualDeploymentKeys.join('|') !== deploymentKeys.join('|')) {
74
+ throw new Error(`${relativeTarget} # 部署 keys must be empty or exactly: ${deploymentKeys.join(', ')}`);
75
+ }
76
+ if (requireExampleDefaults && sections.get('部署').some(({ value }) => value !== '')) {
77
+ throw new Error(`${relativeTarget} deployment example values must be empty`);
78
+ }
79
+ return {
80
+ extended: actualDeploymentKeys.length === deploymentKeys.length,
81
+ nonDeploymentKeys: [...seenKeys].filter((key) => !deploymentKeys.includes(key)),
82
+ };
83
+ }
84
+
85
+ function extendDeploymentSection(text) {
86
+ return `${text.trimEnd()}\n${deploymentKeys.map((key) => `${key}=`).join('\n')}\n`;
87
+ }
88
+
89
+ const args = process.argv.slice(2);
90
+ let mode;
91
+ let force = false;
92
+ let targetArgument;
93
+ for (let index = 0; index < args.length; index += 1) {
94
+ const argument = args[index];
95
+ if (argument === '--target') {
96
+ if (targetArgument !== undefined || !args[index + 1] || args[index + 1].startsWith('--')) {
97
+ throw new Error('Duplicate or missing --target argument');
98
+ }
99
+ targetArgument = args[index + 1];
100
+ index += 1;
101
+ } else if (argument === '--write' || argument === '--check') {
102
+ if (mode !== undefined) throw new Error('Exactly one of --write or --check is required');
103
+ mode = argument.slice(2);
104
+ } else if (argument === '--force') {
105
+ if (force) throw new Error('Duplicate --force argument');
106
+ force = true;
107
+ } else {
108
+ throw new Error(`Unknown argument: ${argument}`);
109
+ }
110
+ }
111
+ if (!mode || targetArgument === undefined || (force && mode !== 'write')) {
112
+ throw new Error('Usage: generate-deployment-files.mjs --target <project-root> (--write [--force] | --check)');
113
+ }
114
+
115
+ const targetRoot = resolve(targetArgument);
116
+ const targetStat = await stat(targetRoot);
117
+ if (!targetStat.isDirectory()) throw new Error(`Target project root is not a directory: ${targetRoot}`);
118
+
119
+ const environmentTemplate = await readFile(join(templatesRoot, 'env.example.template'), 'utf8');
120
+ const exampleValues = new Map(
121
+ [...environmentTemplate.matchAll(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/gm)].map((match) => [match[1], match[2]]),
122
+ );
123
+ const environmentPlans = [];
124
+ for (const [relativeTarget, expectedMode, requireExampleDefaults] of [['.env', 0o600, false], ['.env.example', 0o644, true]]) {
125
+ const target = join(targetRoot, relativeTarget);
126
+ const targetInfo = await lstat(target);
127
+ if (targetInfo.isSymbolicLink() || !targetInfo.isFile()) throw new Error(`Environment target must be a regular file: ${relativeTarget}`);
128
+ const existing = await readFile(target, 'utf8');
129
+ const structure = parseEnvironmentStructure(existing, relativeTarget, requireExampleDefaults, exampleValues);
130
+ environmentPlans.push({
131
+ target,
132
+ relativeTarget,
133
+ expectedMode,
134
+ existing,
135
+ nonDeploymentKeys: structure.nonDeploymentKeys,
136
+ expected: structure.extended ? existing : extendDeploymentSection(existing),
137
+ });
138
+ }
139
+ if (environmentPlans[0].nonDeploymentKeys.join('|') !== environmentPlans[1].nonDeploymentKeys.join('|')) {
140
+ throw new Error('.env and .env.example variable names must remain consistent');
141
+ }
142
+
143
+ const differences = [];
144
+ for (const [templateName, relativeTarget, expectedMode] of mappings) {
145
+ const source = join(templatesRoot, templateName);
146
+ const target = join(targetRoot, relativeTarget);
147
+ const expected = await readFile(source);
148
+ let existing;
149
+ try {
150
+ const targetInfo = await lstat(target);
151
+ if (targetInfo.isSymbolicLink() || !targetInfo.isFile()) throw new Error(`Generated target must be a regular file: ${relativeTarget}`);
152
+ existing = await readFile(target);
153
+ } catch (error) {
154
+ if (error.code !== 'ENOENT') throw error;
155
+ }
156
+
157
+ const differs = !existing || !existing.equals(expected);
158
+ if (mode === 'check') {
159
+ if (differs) differences.push(relativeTarget);
160
+ if (!differs && relativeTarget === 'deploy.sh') {
161
+ const info = await stat(target);
162
+ if ((info.mode & 0o111) === 0) differences.push(`${relativeTarget} (not executable)`);
163
+ }
164
+ continue;
165
+ }
166
+ if (differs && existing && !force) {
167
+ differences.push(relativeTarget);
168
+ continue;
169
+ }
170
+ if (differs) {
171
+ await mkdir(dirname(target), { recursive: true });
172
+ const temporary = `${target}.wdyy-${process.pid}.tmp`;
173
+ try {
174
+ await writeFile(temporary, expected, { flag: 'wx', mode: expectedMode });
175
+ await chmod(temporary, expectedMode);
176
+ await rename(temporary, target);
177
+ } catch (error) {
178
+ await unlink(temporary).catch(() => {});
179
+ throw error;
180
+ }
181
+ } else {
182
+ await chmod(target, expectedMode);
183
+ }
184
+ }
185
+
186
+ if (mode === 'write' && differences.length) {
187
+ throw new Error(`Refusing to overwrite differing deployment files without --force: ${differences.join(', ')}`);
188
+ }
189
+
190
+ for (const plan of environmentPlans) {
191
+ if (plan.existing !== plan.expected) {
192
+ if (mode === 'check') differences.push(plan.relativeTarget);
193
+ else {
194
+ const temporary = `${plan.target}.wdyy-${process.pid}.tmp`;
195
+ try {
196
+ await writeFile(temporary, plan.expected, { flag: 'wx', mode: plan.expectedMode });
197
+ await chmod(temporary, plan.expectedMode);
198
+ await rename(temporary, plan.target);
199
+ } catch (error) {
200
+ await unlink(temporary).catch(() => {});
201
+ throw error;
202
+ }
203
+ }
204
+ } else if (mode === 'write') {
205
+ await chmod(plan.target, plan.expectedMode);
206
+ }
207
+ }
208
+
209
+ if (differences.length) {
210
+ const action = mode === 'check' ? 'Generated deployment files differ' : 'Refusing to overwrite differing deployment files without --force';
211
+ throw new Error(`${action}: ${differences.join(', ')}`);
212
+ }
213
+ process.stdout.write(`${mode === 'write' ? 'generated' : 'verified'} deterministic deployment files in ${targetRoot}\n`);
@@ -0,0 +1,106 @@
1
+ import assert from 'node:assert/strict';
2
+ import { chmod, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { afterEach, test } from 'node:test';
6
+ import { spawnSync } from 'node:child_process';
7
+
8
+ const script = new URL('./generate-deployment-files.mjs', import.meta.url).pathname;
9
+ const environmentTemplate = new URL('../templates/env.example.template', import.meta.url).pathname;
10
+ const temporaryDirectories = [];
11
+
12
+ afterEach(async () => {
13
+ await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
14
+ });
15
+
16
+ async function projectRoot() {
17
+ const root = await mkdtemp(join(tmpdir(), 'deployment-generator-'));
18
+ temporaryDirectories.push(root);
19
+ const template = await readFile(environmentTemplate, 'utf8');
20
+ const initializedEnvironment = `${template.slice(0, template.indexOf('# 部署') + '# 部署'.length)}\n`;
21
+ await writeFile(join(root, '.env'), initializedEnvironment, { mode: 0o600 });
22
+ await writeFile(join(root, '.env.example'), initializedEnvironment);
23
+ return root;
24
+ }
25
+
26
+ function run(root, ...args) {
27
+ return spawnSync(process.execPath, [script, '--target', root, ...args], { encoding: 'utf8' });
28
+ }
29
+
30
+ test('确定性生成全部标准文件并通过 check', async () => {
31
+ const root = await projectRoot();
32
+ const generated = run(root, '--write');
33
+ assert.equal(generated.status, 0, generated.stderr);
34
+ const checked = run(root, '--check');
35
+ assert.equal(checked.status, 0, checked.stderr);
36
+ assert.match(checked.stdout, /verified deterministic deployment files/);
37
+ assert.match(await readFile(join(root, 'deploy.sh'), 'utf8'), /build\|start\|replace/);
38
+ const environment = await readFile(join(root, '.env'), 'utf8');
39
+ const example = await readFile(join(root, '.env.example'), 'utf8');
40
+ assert.match(environment, /^# 前端\nFRONTEND_URL=0\.0\.0\.0\nFRONTEND_PORT=5173$/m);
41
+ assert.match(environment, /^# 部署\nPROJECT_NAME=\nSERVER_PROJECTS_ROOT=/m);
42
+ assert.equal(example, environment);
43
+ if (process.platform !== 'win32') assert.equal((await stat(join(root, '.env'))).mode & 0o777, 0o600);
44
+ });
45
+
46
+ test('扩充部署段时保留 .env 的真实基础值', async () => {
47
+ const root = await projectRoot();
48
+ const envPath = join(root, '.env');
49
+ await writeFile(envPath, (await readFile(envPath, 'utf8')).replace('DB_PASSWORD=', 'DB_PASSWORD=private-value'), { mode: 0o600 });
50
+ const generated = run(root, '--write');
51
+ assert.equal(generated.status, 0, generated.stderr);
52
+ assert.match(await readFile(envPath, 'utf8'), /^DB_PASSWORD=private-value$/m);
53
+ });
54
+
55
+ test('非标准环境文件被拒绝且不修改', async () => {
56
+ const root = await projectRoot();
57
+ const envPath = join(root, '.env');
58
+ await writeFile(envPath, (await readFile(envPath, 'utf8')).replace('# 数据库', '# 数据'), { mode: 0o600 });
59
+ const before = await readFile(envPath, 'utf8');
60
+ const generated = run(root, '--write');
61
+ assert.notEqual(generated.status, 0);
62
+ assert.match(generated.stderr, /section order/);
63
+ assert.equal(await readFile(envPath, 'utf8'), before);
64
+ });
65
+
66
+ test('.env 与 .env.example 变量名不一致时拒绝修改', async () => {
67
+ const root = await projectRoot();
68
+ const examplePath = join(root, '.env.example');
69
+ await writeFile(examplePath, (await readFile(examplePath, 'utf8')).replace('# API\n', '# API\nAPI_URL=\n'));
70
+ const envBefore = await readFile(join(root, '.env'), 'utf8');
71
+ const exampleBefore = await readFile(examplePath, 'utf8');
72
+ const generated = run(root, '--write');
73
+ assert.notEqual(generated.status, 0);
74
+ assert.match(generated.stderr, /variable names must remain consistent/);
75
+ assert.equal(await readFile(join(root, '.env'), 'utf8'), envBefore);
76
+ assert.equal(await readFile(examplePath, 'utf8'), exampleBefore);
77
+ });
78
+
79
+ test('差异文件默认拒绝覆盖,显式 force 后恢复一致', async () => {
80
+ const root = await projectRoot();
81
+ assert.equal(run(root, '--write').status, 0);
82
+ await writeFile(join(root, 'deploy.sh'), '# changed\n');
83
+ await chmod(join(root, 'deploy.sh'), 0o755);
84
+ const refused = run(root, '--write');
85
+ assert.notEqual(refused.status, 0);
86
+ assert.match(refused.stderr, /Refusing to overwrite/);
87
+ assert.equal(await readFile(join(root, 'deploy.sh'), 'utf8'), '# changed\n');
88
+ const forced = run(root, '--write', '--force');
89
+ assert.equal(forced.status, 0, forced.stderr);
90
+ assert.equal(run(root, '--check').status, 0);
91
+ });
92
+
93
+ test('重复或冲突参数被明确拒绝', async () => {
94
+ const root = await projectRoot();
95
+ const duplicateMode = run(root, '--write', '--check');
96
+ assert.notEqual(duplicateMode.status, 0);
97
+ assert.match(duplicateMode.stderr, /Exactly one of --write or --check is required/);
98
+
99
+ const duplicateTarget = spawnSync(
100
+ process.execPath,
101
+ [script, '--target', root, '--target', root, '--check'],
102
+ { encoding: 'utf8' },
103
+ );
104
+ assert.notEqual(duplicateTarget.status, 0);
105
+ assert.match(duplicateTarget.stderr, /Duplicate or missing --target argument/);
106
+ });