@wdyy/skills 0.1.14 → 0.1.16

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.
@@ -7,13 +7,13 @@
7
7
  },
8
8
  {
9
9
  "name": "wdyy-deployment-standard",
10
- "description": "为无 CI/CD 的前后端项目生成并验证目标平台确定的统一 Docker 交付:在任意构建机上使用本地已有的目标架构基础镜像,构建 linux/amd64 或 linux/arm64 前后端镜像,分别导出后封装为一个压缩包,并在平台匹配的 Linux Docker Server 上无参数部署整个项目栈。Use when 规划、实现、审查或执行跨构建机平台的人工 Docker 生产交付时。",
10
+ "description": "为无 CI/CD 的前后端项目生成并验证跨宿主机统一 Docker 交付:在 Linux、macOS Docker Desktop 或 Windows Docker Desktop + WSL2/Linux 容器模式中,使用本地已有基础镜像构建单一 linux/amd64 或 linux/arm64 双镜像包,并在平台匹配的 Linux engine 上以固定端口、容器名、重启策略和 CST(UTC+8)时间整栈运行。Use when 规划、实现、审查或执行跨构建机平台的人工 Docker 生产交付时。",
11
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",
15
- "description": "根据“获取XX数据”“使用XX接口”等具体需求,定位已登记的接口 reference,并按已确认的接口契约将调用方式写入程序。Use when 需要查询或实现一个已登记接口的数据调用时。",
16
- "files": ["SKILL.md", "agents/openai.yaml", "reference/api-demo.md", "reference/api-sms.md"]
15
+ "description": "根据“获取XX数据”“使用XX接口”“使用指定查询名称”等具体需求,定位已登记的接口 reference;对于 HIS SQL 查询,从远程查询目录按 name 精确匹配,并按已确认的接口契约将调用方式写入程序。Use when 需要查询或实现一个已登记接口或 HIS SQL 查询的数据调用时。",
16
+ "files": ["SKILL.md", "agents/openai.yaml", "reference/api-demo.md", "reference/api-his-sql.md", "reference/api-sms.md"]
17
17
  },
18
18
  {
19
19
  "name": "wdyy-bug-record",
@@ -1,25 +1,27 @@
1
1
  ---
2
2
  name: wdyy-api-standard
3
- description: 根据“获取XX数据”“使用XX接口”等具体需求,定位已登记的接口 reference,并按已确认的接口契约将调用方式写入程序。Use when 需要查询或实现一个已登记接口的数据调用时。
3
+ description: 根据“获取XX数据”“使用XX接口”“使用指定查询名称”等具体需求,定位已登记的接口 reference;对于 HIS SQL 查询,从远程查询目录按 name 精确匹配,并按已确认的接口契约将调用方式写入程序。Use when 需要查询或实现一个已登记接口或 HIS SQL 查询的数据调用时。
4
4
  ---
5
5
 
6
6
  # 接口路由
7
7
 
8
8
  ## 用途
9
9
 
10
- 本 Skill 只负责根据具体需求定位接口 reference,并将已确认的接口调用方式写入程序。接口明细一律维护在 `reference/` 中,每个接口一个文档。
10
+ 本 Skill 只负责根据具体需求定位接口 reference,并将已确认的接口调用方式写入程序。接口明细和 HIS SQL 查询目录使用规则一律维护在 `reference/` 中,每个接口一个文档。
11
11
 
12
12
  ## 何时使用
13
13
 
14
14
  - 用户描述“获取XX数据”“使用XX接口”或等价的数据查询、接口调用需求时。
15
+ - 用户描述“使用 `<查询名称>`”并需要从已登记 HIS SQL 查询目录实现调用时。
15
16
  - 需要将已登记接口的 URL、入参和返回值处理方式写入程序时。
16
17
 
17
18
  ## 路由步骤
18
19
 
19
20
  1. 根据需求中的数据名称、业务对象或接口名称,在 `reference/api-*.md` 中查找候选业务接口,排除仅用于说明格式的 `api-demo.md`。
20
- 2. 仅在唯一匹配时读取该 reference,使用其中的接口 URL、JSON 入参和 JSON 返回值编写调用方式。
21
- 3. reference 的“备注”实现特殊调用要求。
22
- 4. 若没有匹配接口、存在多个候选接口,或 URL、入参、返回值不完整,先要求补充或确认接口信息;不得编造调用细节。
21
+ 2. 若需求为“使用 `<查询名称>`”且未直接匹配接口名称,则读取 `reference/api-his-sql.md`,按其中的远程目录和查询名称匹配规则处理。
22
+ 3. 仅在唯一匹配时读取对应 reference,使用其中的接口 URL、JSON 入参和 JSON 返回值编写调用方式。
23
+ 4. reference 的“备注”实现特殊调用要求。
24
+ 5. 若没有匹配接口、存在多个候选接口,或 URL、入参、返回值不完整,先要求补充或确认接口信息;不得编造调用细节。
23
25
 
24
26
  ## 接口文档约定
25
27
 
@@ -1,4 +1,4 @@
1
1
  interface:
2
- display_name: "Enterprise API Standard"
3
- short_description: "Route data requests to documented API references"
4
- default_prompt: "Use $wdyy-api-standard to find the documented API for this requirement and write its call into the program."
2
+ display_name: "wdyy-api-standard"
3
+ short_description: "Route APIs and named HIS SQL queries into program code"
4
+ default_prompt: "Use $wdyy-api-standard to resolve the documented API or named HIS SQL query and write its call into the program."
@@ -0,0 +1,53 @@
1
+ # 接口名称:api-his-sql
2
+
3
+ # 接口用途:执行 SQL 查询并返回查询结果
4
+
5
+ # 接口URL:http://172.26.1.64:8999/api/database/query
6
+
7
+ `POST` 请求,`Content-Type` 为 `application/json`。
8
+
9
+ HIS SQL 查询目录:
10
+
11
+ - 人工查看:`http://172.26.1.64:9003/lihuanyu/cache_query/-/blob/main/cache-query.json?ref_type=heads`
12
+ - 程序读取:`http://172.26.1.64:9003/lihuanyu/cache_query/-/raw/main/cache-query.json`
13
+
14
+ # 入参格式:JSON
15
+
16
+ ```json
17
+ {
18
+ "sql": "<cache-query.json 中匹配对象的 sql 原文>",
19
+ "params": [
20
+ "<按照匹配对象 params 顺序提供的实际值>"
21
+ ]
22
+ }
23
+ ```
24
+
25
+ 按以下步骤生成项目程序中的调用方式:
26
+
27
+ 1. 每次实现调用时读取“程序读取”地址,解析当前 `cache-query.json`;不得使用本地缓存或本 reference 中的示例 SQL 代替远程目录。
28
+ 2. 遍历 JSON 顶层对象的全部值,仅选择对象 `name` 与用户指定查询名称完全相等的候选项;不得使用 JSON 顶层键代替 `name` 匹配。
29
+ 3. 仅允许一个匹配对象。零个匹配或多个同名匹配时,明确报告问题并停止生成调用方式。
30
+ 4. 要求匹配对象包含非空字符串 `sql` 和数组 `params`。将 `sql` 原样写入请求体,不得生成、改写或拼接 SQL。
31
+ 5. 将目录中的 `params` 视为实际参数值的名称和顺序说明,不得把这些名称直接作为实际值发送。若用户或业务上下文未提供全部实际值,按数组顺序要求补充。
32
+ 6. 获得全部实际值后,按照目录 `params` 的相同顺序写入请求体 `params` 数组。
33
+
34
+ 例如,用户描述“使用 `get_diagnosis_info`”时,按对象 `name` 匹配该查询,并依次要求提供“开始日期”“结束日期”“诊断名称”;SQL 必须使用该匹配对象当前登记的原文。
35
+
36
+ # 返回值:JSON
37
+
38
+ ```json
39
+ {
40
+ "data": [
41
+ {
42
+ "SSUSR_RowId": 20292,
43
+ "SSUSR_Initials": "20684",
44
+ "SSUSR_Name": "马俊"
45
+ }
46
+ ],
47
+ "success": true,
48
+ "count": 1,
49
+ "message": "查询执行成功"
50
+ }
51
+ ```
52
+
53
+ # 备注:本 Skill 只将调用方式写入项目程序,不直接向本接口发送查询请求。远程目录不可访问、指定 `name` 不存在、存在多个同名对象、`sql` 不是非空字符串或 `params` 不是数组时,必须明确报告具体问题;不得使用旧副本、示例 SQL、顶层键匹配或自行生成 SQL 继续实现。
@@ -1,13 +1,15 @@
1
1
  ---
2
2
  name: wdyy-deployment-standard
3
- description: 为无 CI/CD 的前后端项目生成并验证目标平台确定的统一 Docker 交付:在任意构建机上使用本地已有的目标架构基础镜像,构建 linux/amd64 或 linux/arm64 前后端镜像,分别导出后封装为一个压缩包,并在平台匹配的 Linux Docker Server 上无参数部署整个项目栈。Use when 规划、实现、审查或执行跨构建机平台的人工 Docker 生产交付时。
3
+ description: 为无 CI/CD 的前后端项目生成并验证跨宿主机统一 Docker 交付:在 Linux、macOS Docker Desktop 或 Windows Docker Desktop + WSL2/Linux 容器模式中,使用本地已有基础镜像构建单一 linux/amd64 或 linux/arm64 双镜像包,并在平台匹配的 Linux engine 上以固定端口、容器名、重启策略和 CST(UTC+8)时间整栈运行。Use when 规划、实现、审查或执行跨构建机平台的人工 Docker 生产交付时。
4
4
  ---
5
5
 
6
6
  # 统一 Docker 交付规范
7
7
 
8
8
  ## Overview
9
9
 
10
- 把一个项目作为一个整体交付:在任意支持目标架构的 Docker 构建机上执行 `./deploy.sh build`,按 `DOCKER_PLATFORM` 生成前端、后端两个单架构镜像并分别导出,最终只产出一个压缩包;工程师人工上传并解压;平台匹配的 Linux Docker Server 无参数执行 `./deploy.sh`,一次性加载并部署整个前后端项目栈。
10
+ 把一个项目作为一个整体交付:在 Linux、macOS Docker Desktop,或 Windows Docker Desktop + WSL2/Linux 容器模式中执行 `./deploy.sh build`,按 `DOCKER_PLATFORM` 生成前端、后端两个 Linux 单架构镜像并分别导出,最终只产出一个压缩包;工程师人工复制并解压;Docker Server 平台匹配时无参数执行 `./deploy.sh`,一次性加载并部署整个前后端项目栈。
11
+
12
+ macOS/arm64 宿主机的 Docker Server 平台是 `linux/arm64`。Windows 必须在启用 Docker Desktop 集成的 WSL2 发行版和 Linux 文件系统中执行同一 Bash 脚本,并使用 Linux 容器 engine;不支持原生 PowerShell、Windows 容器模式或 `windows/*` 镜像。
11
13
 
12
14
  ## Workflow
13
15
 
@@ -16,7 +18,7 @@ description: 为无 CI/CD 的前后端项目生成并验证目标平台确定的
16
18
  3. 运行 `node scripts/generate-deployment-files.mjs --target <目标项目根目录> --write` 生成受版本控制文件并扩充 `.env`、`.env.example` 的部署段。既有生成文件不一致时先展示差异并等待工程师确认,确认后才使用 `--force`。
17
19
  4. 在目标项目根执行生成器 `--check`,再运行 `scripts/validate-deployment-package.mjs` 和行为测试。
18
20
  5. 本地执行 `./deploy.sh build`;只在全部校验成功后原子替换 `deploy/<PROJECT_NAME>-docker.tar.gz`。
19
- 6. 人工上传并解压压缩包,在服务器解压目录无参数执行 `./deploy.sh`。
21
+ 6. 人工复制并解压压缩包,在 Linux 文件系统的解压目录无参数执行 `./deploy.sh`;Windows 场景必须从 WSL2 执行。
20
22
 
21
23
  ## Fixed Contract
22
24
 
@@ -24,11 +26,15 @@ description: 为无 CI/CD 的前后端项目生成并验证目标平台确定的
24
26
  - 每个交付包只对应 `DOCKER_PLATFORM` 指定的 `linux/amd64` 或 `linux/arm64`;构建机自身平台可以不同,不得回退到构建机原生架构。
25
27
  - 分别导出 `frontend-image.tar`、`backend-image.tar`,但只交付一个 `<PROJECT_NAME>-docker.tar.gz`。
26
28
  - 构建前检查 `FRONTEND_BASE_IMAGE`、`BACKEND_BASE_IMAGE` 已存在于本地;缺失即失败,不执行 `docker pull`。
27
- - 服务器加载镜像前读取 Docker Server OS/架构;无法读取、不是 Linux 或不等于 `DOCKER_PLATFORM` 时立即失败。
29
+ - 宿主机可以是 Linux、macOS 或 Windows,但镜像和 Docker Server 必须是 `linux/amd64` `linux/arm64`;加载前读取 Server OS/架构,无法读取、Windows 容器模式或不等于 `DOCKER_PLATFORM` 时立即失败。
28
30
  - Compose 只定义 frontend、backend;数据库通过 `.env` 直连远程实例,不生成数据库容器、数据卷或迁移路径。
29
31
  - 服务器无参数命令整体部署;只额外允许 `stop` 和 `status`,不得独立操作单一服务。
30
32
  - 前后端直接暴露 `.env` 指定的宿主机端口;两个容器共同挂载项目根 `./logs` 到 `/app/logs`。
33
+ - `FRONTEND_PORT` 同时控制 frontend 宿主机映射和容器 Nginx 监听;`BACKEND_PORT` 同时控制 backend 宿主机映射、Node 监听、健康检查和 frontend 内部代理,不增加重复容器端口变量。
34
+ - frontend 容器和镜像 repository 都命名为 `<PROJECT_NAME>_frontend`,backend 都命名为 `<PROJECT_NAME>_backend`;镜像额外使用 `:latest` 标签。同名容器不属于当前 Compose 项目和服务时必须在加载镜像前失败,不得删除或接管。
35
+ - frontend 与 backend 都使用 `restart: unless-stopped`:意外退出由 Docker 自动重启,`./deploy.sh stop` 人工停止后保持停止,直到再次明确整栈部署。
31
36
  - frontend Nginx 使用 `.env` 的 `VITE_API_BASE_URL` 与 `BACKEND_PORT`,把同源 API 请求代理到 Compose `backend` 服务;`VITE_API_BASE_URL` 必须等于 `/${API_PREFIX}`。
37
+ - 根 `.env` 与 `.env.example` 必须严格使用 `TZ=Asia/Shanghai`。构建后、Docker save 前实际探测两个镜像;整栈健康后实际探测两个运行容器,均必须以 24 小时格式输出 `CST +0800`。
32
38
  - `.env` 可以进入人工上传的压缩包,但必须被 `.dockerignore` 排除,不得进入镜像。
33
39
 
34
40
  ## Prohibited
@@ -36,6 +42,7 @@ description: 为无 CI/CD 的前后端项目生成并验证目标平台确定的
36
42
  - 不得使用镜像仓库 pull、镜像 fallback、数据库容器、数据库迁移、宿主机 Nginx、蓝绿、滚动切流或自动回滚。
37
43
  - 不得保留发布账本、版本目录、`release.env`、交互菜单或前后端独立替换命令。
38
44
  - 不得上传文件、拉取代码、安装 Docker、固定服务器目录或使用 `source`、`eval` 执行 `.env`。
45
+ - 不得生成 PowerShell 备用脚本、Windows 镜像或自动切换 Docker Desktop engine;Windows 原生容器模式必须明确失败。
39
46
  - 不得跳过清单、镜像标签、Compose 服务范围、远程数据库配置或健康状态验证。
40
47
 
41
48
  ## Verification
@@ -45,8 +52,11 @@ description: 为无 CI/CD 的前后端项目生成并验证目标平台确定的
45
52
  - [ ] 两个镜像归档各自只含预期的 `latest` 标签。
46
53
  - [ ] 单一压缩包不含 macOS AppleDouble、资源叉或扩展属性元数据。
47
54
  - [ ] 构建与服务器 Compose 均禁止 pull,服务器禁止 build;基础镜像、构建参数、产出镜像和 Compose 平台均与 `DOCKER_PLATFORM` 一致。
48
- - [ ] 服务器 Docker Engine 平台在加载镜像前通过校验,并严格等于 `DOCKER_PLATFORM`。
55
+ - [ ] Linux Engine、macOS/arm64 Docker Desktop Windows Docker Desktop + WSL2 场景使用同一包契约;Windows 原生容器模式被拒绝。
56
+ - [ ] Docker Server 平台在加载镜像前通过校验,并严格等于 `DOCKER_PLATFORM`。
49
57
  - [ ] Compose 只含 frontend、backend,并使用远程数据库环境变量和根 logs。
58
+ - [ ] Compose 的双向端口都来自 FRONTEND_PORT/BACKEND_PORT;两个固定容器名等于各自镜像 repository,并各使用 `unless-stopped`。
59
+ - [ ] 根 `.env` 严格使用 `TZ=Asia/Shanghai`;两个镜像和两个运行容器的真实时间探测均为 24 小时制 `CST +0800`。
50
60
  - [ ] frontend 镜像不包含 `.env`,同源 API 代理能够把真实页面查询转发到 backend。
51
61
  - [ ] 无参数部署、stop、status 的实际行为及失败传播通过测试。
52
62
 
@@ -1,4 +1,4 @@
1
1
  interface:
2
2
  display_name: "wdyy-deployment-standard"
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."
3
+ short_description: " Linux、Mac 与 Windows WSL2 生成并验证统一双镜像交付"
4
+ default_prompt: "Use $wdyy-deployment-standard to build and run a single-target linux/amd64 or linux/arm64 dual-image package on Linux engines, macOS Docker Desktop, or Windows Docker Desktop through WSL2, with deterministic ports, names, restart policy, and CST UTC+8 time."
@@ -37,7 +37,8 @@ PROJECT_NAME=example_project
37
37
  DOCKER_BIND_IP=0.0.0.0
38
38
  DOCKER_PLATFORM=linux/amd64
39
39
  FRONTEND_BASE_IMAGE=nginx:stable
40
- BACKEND_BASE_IMAGE=node:24-alpine3.24
40
+ BACKEND_BASE_IMAGE=node:24-bookworm-slim
41
+ TZ=Asia/Shanghai
41
42
  ```
42
43
 
43
44
  - `PROJECT_NAME` 只允许小写字母开头,后续使用小写字母、数字或下划线。
@@ -45,23 +46,25 @@ BACKEND_BASE_IMAGE=node:24-alpine3.24
45
46
  - `.env` 权限必须为 `0600` 或只读 `0400`。
46
47
  - 按数据解析 `.env`;不得 `source`、`eval` 或执行其中内容。
47
48
  - `DB_URL` 指向远程数据库主机或地址;部署资产不得把它解释为 Docker 服务名并创建数据库容器。
48
- - `DOCKER_PLATFORM` 必须为 `linux/amd64` 或 `linux/arm64`,表示当前单一交付包的目标平台,并与目标 Docker Server 平台一致;不得根据构建机平台自动改写。
49
+ - `DOCKER_PLATFORM` 必须为 `linux/amd64` 或 `linux/arm64`,表示当前单一交付包的镜像目标平台,并与运行时 Docker Server 平台一致;不得根据 Linux、macOS、Windows 宿主机或 CPU 自动改写,不得使用 `darwin/*` 或 `windows/*`。
50
+ - `TZ` 必须严格等于 `Asia/Shanghai`,使两个容器统一使用 24 小时制的 CST(UTC+8);其他合法 IANA 时区也不属于本交付合同。
49
51
  - `API_PREFIX` 只允许一个安全路径段,`VITE_API_BASE_URL` 必须严格等于 `/${API_PREFIX}`;生成器在两个键都缺失时加入标准值,存在缺失或冲突时明确失败。
50
52
  - `.env.example` 可保留空数据库凭据,但真实 `.env` 构建时必须完整。
51
53
 
52
54
  ## 3. 本地镜像与构建
53
55
 
54
- 基础镜像引用允许使用标签,但目标架构版本必须在构建机本地预先存在。构建机可以是 macOSWindows 或 Linux,也可以与目标架构不同;相同标签被人工替换后可能对应不同内容,构建过程不得访问镜像仓库或回退到构建机原生架构。
56
+ 基础镜像引用允许使用标签,但目标架构版本必须在构建机本地预先存在。构建命令可以在 Linux、macOS Docker Desktop,或 Windows Docker Desktop + WSL2/Linux 容器模式中执行,也可以与目标架构不同;Windows 必须从启用 Docker Desktop 集成的 WSL2 发行版和 Linux 文件系统执行,不支持原生 PowerShell Windows 容器模式。相同标签被人工替换后可能对应不同内容,构建过程不得访问镜像仓库或回退到构建机原生架构。
55
57
 
56
58
  `./deploy.sh build` 必须按顺序:
57
59
 
58
60
  1. 验证根 `.env`、Dockerfile、Compose 和 `.dockerignore`。
59
61
  2. 使用平台感知的 `docker image inspect --platform "$DOCKER_PLATFORM"` 确认两个基础镜像的目标架构版本存在。
60
62
  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`,成功后原子替换同名旧包。
63
+ 4. 对两个产出镜像分别使用 `docker run --rm --pull=never --platform "$DOCKER_PLATFORM"` 探测 `date '+%H %Z %z'`,要求小时为 `00`–`23`、时区为 `CST +0800`。
64
+ 5. 分别执行 Docker save,生成 `frontend-image.tar`、`backend-image.tar`。
65
+ 6. 复制根 `.env`、deploy.sh、docker-compose.yml,生成覆盖全部包内文件的 `manifest.sha256`。
66
+ 7. 验证镜像归档各自只含预期标签,验证完整包结构。
67
+ 8. 使用 `COPYFILE_DISABLE=1` 和 `tar --no-xattrs` 排除 macOS AppleDouble、资源叉和扩展属性,生成 `deploy/<PROJECT_NAME>-docker.tar.gz`,成功后原子替换同名旧包。
65
68
 
66
69
  构建失败必须保留上一份成功压缩包,并清理本次临时目录。不得维护发布账本、版本号、版本目录、`release.env` 或半成品目录。
67
70
 
@@ -82,9 +85,9 @@ manifest.sha256
82
85
 
83
86
  ## 5. 服务器一键操作
84
87
 
85
- 工程师人工上传并解压单一压缩包。服务器部署根由解压后的 deploy.sh 物理路径确定。上传用的 `.tar.gz` 必须放在部署根外,或在解压后移出部署根;执行 `./deploy.sh` 时,部署根只允许保留压缩包契约中的 6 个文件和运行后生成的 `logs/` 目录。
88
+ 工程师人工复制并解压单一压缩包。部署根由解压后的 deploy.sh 物理路径确定。Windows Docker Desktop 场景必须把包放在 WSL2 Linux 文件系统中;不能满足 `.env` owner-only 权限的 Windows 挂载目录必须失败。复制用的 `.tar.gz` 必须放在部署根外,或在解压后移出部署根;执行 `./deploy.sh` 时,部署根只允许保留压缩包契约中的 6 个文件和运行后生成的 `logs/` 目录。
86
89
 
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` 整体部署并等待健康。
90
+ - `./deploy.sh`:校验 `.env` 权限和内容、清单、两个镜像 tar 及预期标签;通过 `docker version` 读取 Docker Server OS/架构并要求是与 `DOCKER_PLATFORM` 相等的 Linux engine;检查固定容器名不存在或属于当前 Compose 项目和对应服务;校验通过后才加载两个镜像,再使用一次 `docker compose up -d --force-recreate --no-build --pull never --wait frontend backend` 整体部署并等待健康,最后在两个运行容器中验证 24 小时制 `CST +0800`。
88
91
  - 加载后必须再次校验两个镜像与 `DOCKER_PLATFORM` 一致;Compose 的两个服务也必须显式声明同一平台。
89
92
  - `./deploy.sh stop`:同时停止 frontend、backend,保留容器、镜像、交付文件和日志。
90
93
  - `./deploy.sh status`:输出当前 Compose 项目的服务状态,不改变 Docker 状态。
@@ -93,18 +96,19 @@ manifest.sha256
93
96
 
94
97
  ## 6. Compose、数据库和日志
95
98
 
96
- - Compose 只允许 frontend、backend 两个服务,不设置 `container_name`。
99
+ - Compose 只允许 frontend、backend 两个服务;`container_name` 分别固定为 `${PROJECT_NAME}_frontend`、`${PROJECT_NAME}_backend`,与镜像 repository 相同但不含 `:latest`。
97
100
  - 镜像固定为 `${PROJECT_NAME}_frontend:latest`、`${PROJECT_NAME}_backend:latest`,不得提供 fallback。
98
101
  - frontend 映射 `${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}`;backend 同理。
102
+ - 两个服务都固定使用 `restart: unless-stopped`;意外退出由 Docker 重启,`./deploy.sh stop` 后不得由脚本再次启动。
99
103
  - frontend Nginx 把 `${VITE_API_BASE_URL}/...` 原样代理到 `http://backend:${BACKEND_PORT}`;不得硬编码服务器地址或后端端口。
100
- - 两个服务均使用根 `.env`,并挂载 `./logs:/app/logs`。
104
+ - 两个服务均使用根 `.env`,显式接收同一 `TZ`,并挂载 `./logs:/app/logs`。
101
105
  - backend 直接读取 DB_URL、DB_PORT、DB_USER、DB_PASSWORD、DB_NAME、DB_SCHEMA。
102
106
  - Compose 不得定义数据库服务、数据库镜像、数据库 volume、数据库端口映射或迁移任务。
103
107
  - 前端 Nginx 日志写入 `/app/logs/frontend-access.log`、`/app/logs/frontend-error.log`;后端遵循 wdyy-logging-standard;部署操作写入 `./logs/deploy.log`。
104
108
 
105
109
  ## 7. 失败边界
106
110
 
107
- - 目标架构基础镜像缺失、Docker Server 平台无法读取或不匹配、清单错误、镜像标签错误、远程数据库配置不完整或任一服务不健康时明确失败。
111
+ - 目标架构基础镜像缺失、Docker Server 平台无法读取或不匹配、Windows 容器模式、同名容器所有权冲突、TZ 不等于 `Asia/Shanghai`、镜像或运行容器时间不是 24 小时制 `CST +0800`、清单错误、镜像标签错误、远程数据库配置不完整或任一服务不健康时明确失败。
108
112
  - 禁止 `docker pull`、服务器 `docker build`、镜像 fallback、自动回滚、自动安装和静默跳过。
109
113
  - 脚本只操作当前 `PROJECT_NAME` 的 Compose 项目和 frontend、backend 服务,不清理其他项目资源。
110
114
  - 旧部署资产由工程师根据实际状态单独迁移;新脚本不得猜测并删除。
@@ -25,7 +25,8 @@ const exampleDefaults = new Map([
25
25
  ['DB_URL', ''], ['DB_PORT', '5432'], ['DB_USER', ''], ['DB_PASSWORD', ''], ['DB_NAME', ''], ['DB_SCHEMA', ''],
26
26
  ]);
27
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'];
28
+ const legacyDeploymentKeys = ['PROJECT_NAME', 'DOCKER_BIND_IP', 'DOCKER_PLATFORM', 'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE'];
29
+ const deploymentKeys = [...legacyDeploymentKeys, 'TZ'];
29
30
  const removedKeys = new Set([
30
31
  'PROJECT_HTTP_PORT', 'FRONTEND_BLUE_PORT', 'FRONTEND_GREEN_PORT', 'BACKEND_BLUE_PORT', 'BACKEND_GREEN_PORT',
31
32
  'SERVER_PROJECTS_ROOT', 'NGINX_SOURCE', 'LOG_DIR', 'HOST_LOG_DIR', 'FRONTEND_CONTAINER_PORT',
@@ -109,17 +110,26 @@ function parseEnvironment(text, relativePath, requireExampleDefaults, deployment
109
110
  }
110
111
  const deploymentEntries = sections.get('部署');
111
112
  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(', ')}`);
113
+ const deploymentKeySequence = actualDeploymentKeys.join('|');
114
+ const isEmptyDeployment = actualDeploymentKeys.length === 0;
115
+ const isLegacyDeployment = deploymentKeySequence === legacyDeploymentKeys.join('|');
116
+ const isCurrentDeployment = deploymentKeySequence === deploymentKeys.join('|');
117
+ if (!isEmptyDeployment && !isLegacyDeployment && !isCurrentDeployment) {
118
+ throw new Error(`${relativePath} # 部署 keys must be empty, the legacy sequence ${legacyDeploymentKeys.join(', ')}, or exactly: ${deploymentKeys.join(', ')}`);
114
119
  }
115
120
  if (requireExampleDefaults) {
116
121
  for (const { key, value } of deploymentEntries) {
117
122
  if (value !== deploymentDefaults.get(key)) throw new Error(`${relativePath} must keep the standard deployment default for ${key}`);
118
123
  }
119
124
  }
125
+ if (isCurrentDeployment) {
126
+ const timezone = deploymentEntries.find(({ key }) => key === 'TZ')?.value;
127
+ if (timezone !== 'Asia/Shanghai') throw new Error(`${relativePath} TZ must equal Asia/Shanghai for CST (UTC+8)`);
128
+ }
120
129
  return {
121
130
  apiExtended: presentApiKeys.length === apiKeys.length,
122
- deploymentExtended: deploymentEntries.length === deploymentKeys.length,
131
+ deploymentExtended: isCurrentDeployment,
132
+ presentDeploymentKeys: actualDeploymentKeys,
123
133
  nonDeploymentKeys: [...seen].filter((key) => !deploymentKeys.includes(key)),
124
134
  };
125
135
  }
@@ -129,8 +139,11 @@ function extendApiSection(text, apiDefaults) {
129
139
  return `${text.replaceAll('\r\n', '\n').trimEnd().replace(/^# 部署$/m, `${additions}\n\n# 部署`)}\n`;
130
140
  }
131
141
 
132
- function extendDeploymentSection(text, deploymentDefaults) {
133
- const additions = deploymentKeys.map((key) => `${key}=${deploymentDefaults.get(key)}`).join('\n');
142
+ function extendDeploymentSection(text, deploymentDefaults, presentDeploymentKeys) {
143
+ const additions = deploymentKeys
144
+ .filter((key) => !presentDeploymentKeys.includes(key))
145
+ .map((key) => `${key}=${deploymentDefaults.get(key)}`)
146
+ .join('\n');
134
147
  return `${text.trimEnd()}\n${additions}\n`;
135
148
  }
136
149
 
@@ -175,7 +188,7 @@ for (const [relativePath, expectedMode, requireDefaults] of [['.env', 0o600, fal
175
188
  const parsed = parseEnvironment(existing, relativePath, requireDefaults, deploymentDefaults);
176
189
  let expected = existing;
177
190
  if (!parsed.apiExtended) expected = extendApiSection(expected, apiDefaults);
178
- if (!parsed.deploymentExtended) expected = extendDeploymentSection(expected, deploymentDefaults);
191
+ if (!parsed.deploymentExtended) expected = extendDeploymentSection(expected, deploymentDefaults, parsed.presentDeploymentKeys);
179
192
  environmentPlans.push({
180
193
  path, relativePath, expectedMode,
181
194
  existing: existingBuffer,
@@ -75,7 +75,15 @@ test('生成统一 Docker 交付文件并通过确定性检查', async () => {
75
75
  assert.match(environment, /API_URL=\nAPI_PREFIX=api\nVITE_API_BASE_URL=\/api\n\n# 部署/);
76
76
  assert.match(environment, /FRONTEND_BASE_IMAGE=nginx:stable/);
77
77
  assert.match(environment, /DOCKER_PLATFORM=linux\/amd64/);
78
- assert.match(await readFile(join(root, '.env.example'), 'utf8'), /BACKEND_BASE_IMAGE=node:24-alpine3\.24/);
78
+ assert.match(environment, /TZ=Asia\/Shanghai/);
79
+ assert.match(await readFile(join(root, '.env.example'), 'utf8'), /BACKEND_BASE_IMAGE=node:24-bookworm-slim/);
80
+ const compose = await readFile(join(root, 'scripts/deployment/docker-compose.yml'), 'utf8');
81
+ assert.equal((compose.match(/TZ: "\$\{TZ:\?TZ is required\}"/g) ?? []).length, 2);
82
+ assert.match(compose, /container_name: "\$\{PROJECT_NAME:\?PROJECT_NAME is required\}_frontend"/);
83
+ assert.match(compose, /container_name: "\$\{PROJECT_NAME:\?PROJECT_NAME is required\}_backend"/);
84
+ assert.equal((compose.match(/restart: unless-stopped/g) ?? []).length, 2);
85
+ assert.match(compose, /"\$\{DOCKER_BIND_IP\}:\$\{FRONTEND_PORT\}:\$\{FRONTEND_PORT\}"/);
86
+ assert.match(compose, /"\$\{DOCKER_BIND_IP\}:\$\{BACKEND_PORT\}:\$\{BACKEND_PORT\}"/);
79
87
  const nginx = await readFile(join(root, 'scripts/deployment/frontend-container.conf.template'), 'utf8');
80
88
  assert.match(nginx, /location \$\{VITE_API_BASE_URL\}\/ \{/);
81
89
  assert.match(nginx, /proxy_pass http:\/\/backend:\$\{BACKEND_PORT\};/);
@@ -101,6 +109,89 @@ test('扩充部署段时完整保留真实环境值', async () => {
101
109
  assert.match(actual, /PROJECT_NAME=\nDOCKER_BIND_IP=\n/);
102
110
  });
103
111
 
112
+ test('旧五键部署段只追加标准时区并保留既有值', async () => {
113
+ const legacyReal = realEnv.replace('# 部署\n', `# 部署
114
+ PROJECT_NAME=custom_project
115
+ DOCKER_BIND_IP=127.0.0.1
116
+ DOCKER_PLATFORM=linux/arm64
117
+ FRONTEND_BASE_IMAGE=custom-nginx:1
118
+ BACKEND_BASE_IMAGE=custom-node:1
119
+ `);
120
+ const legacyExample = exampleEnv.replace('# 部署\n', `# 部署
121
+ PROJECT_NAME=
122
+ DOCKER_BIND_IP=
123
+ DOCKER_PLATFORM=linux/amd64
124
+ FRONTEND_BASE_IMAGE=nginx:stable
125
+ BACKEND_BASE_IMAGE=node:24-bookworm-slim
126
+ `);
127
+ const root = await fixture(legacyReal, legacyExample);
128
+ await invoke(root, '--write');
129
+ const actual = await readFile(join(root, '.env'), 'utf8');
130
+ assert.match(actual, /PROJECT_NAME=custom_project/);
131
+ assert.match(actual, /DOCKER_PLATFORM=linux\/arm64/);
132
+ assert.match(actual, /BACKEND_BASE_IMAGE=custom-node:1\nTZ=Asia\/Shanghai\n$/);
133
+ assert.match(await readFile(join(root, '.env.example'), 'utf8'), /BACKEND_BASE_IMAGE=node:24-bookworm-slim\nTZ=Asia\/Shanghai\n$/);
134
+ await invoke(root, '--check');
135
+ });
136
+
137
+ test('非标准时区默认值和无效部署键顺序被拒绝且不修改', async () => {
138
+ const root = await fixture();
139
+ await invoke(root, '--write');
140
+ const examplePath = join(root, '.env.example');
141
+ const invalidTimezoneExample = (await readFile(examplePath, 'utf8')).replace('TZ=Asia/Shanghai', 'TZ=UTC');
142
+ await writeFile(examplePath, invalidTimezoneExample, { mode: 0o644 });
143
+ const before = await readFile(examplePath);
144
+ await assert.rejects(invoke(root, '--write'), /standard deployment default for TZ/);
145
+ assert.deepEqual(await readFile(examplePath), before);
146
+
147
+ const invalidOrder = realEnv.replace('# 部署\n', `# 部署
148
+ TZ=Asia/Shanghai
149
+ PROJECT_NAME=example_project
150
+ DOCKER_BIND_IP=0.0.0.0
151
+ DOCKER_PLATFORM=linux/amd64
152
+ FRONTEND_BASE_IMAGE=nginx:stable
153
+ BACKEND_BASE_IMAGE=node:24-bookworm-slim
154
+ `);
155
+ await assert.rejects(invoke(await fixture(invalidOrder), '--write'), /# 部署 keys must be/);
156
+ });
157
+
158
+ test('真实环境必须固定 Asia/Shanghai 且失败时不修改受控文件', async () => {
159
+ const root = await fixture();
160
+ await invoke(root, '--write');
161
+ const envPath = join(root, '.env');
162
+ const invalid = (await readFile(envPath, 'utf8')).replace('TZ=Asia/Shanghai', 'TZ=UTC');
163
+ await writeFile(envPath, invalid, { mode: 0o600 });
164
+ const beforeEnv = await readFile(envPath);
165
+ const beforeDeploy = await readFile(join(root, 'deploy.sh'));
166
+ await assert.rejects(invoke(root, '--write'), /TZ must equal Asia\/Shanghai for CST \(UTC\+8\)/);
167
+ assert.deepEqual(await readFile(envPath), beforeEnv);
168
+ assert.deepEqual(await readFile(join(root, 'deploy.sh')), beforeDeploy);
169
+ });
170
+
171
+ test('非默认应用端口保持单一来源且宿主机变量不改写镜像平台', async () => {
172
+ const configured = realEnv
173
+ .replace('FRONTEND_PORT=5173', 'FRONTEND_PORT=18080')
174
+ .replace('BACKEND_PORT=3000', 'BACKEND_PORT=19090')
175
+ .replace('# 部署\n', `# 部署
176
+ PROJECT_NAME=desktop_project
177
+ DOCKER_BIND_IP=127.0.0.1
178
+ DOCKER_PLATFORM=linux/arm64
179
+ FRONTEND_BASE_IMAGE=nginx:stable
180
+ BACKEND_BASE_IMAGE=node:24-bookworm-slim
181
+ TZ=Asia/Shanghai
182
+ `);
183
+ const root = await fixture(configured);
184
+ await invoke(root, '--write');
185
+ const actual = await readFile(join(root, '.env'), 'utf8');
186
+ assert.match(actual, /FRONTEND_PORT=18080/);
187
+ assert.match(actual, /BACKEND_PORT=19090/);
188
+ assert.match(actual, /DOCKER_PLATFORM=linux\/arm64/);
189
+ const compose = await readFile(join(root, 'scripts/deployment/docker-compose.yml'), 'utf8');
190
+ assert.match(compose, /"\$\{DOCKER_BIND_IP\}:\$\{FRONTEND_PORT\}:\$\{FRONTEND_PORT\}"/);
191
+ assert.match(compose, /"\$\{DOCKER_BIND_IP\}:\$\{BACKEND_PORT\}:\$\{BACKEND_PORT\}"/);
192
+ assert.doesNotMatch(compose, /18080|19090|FRONTEND_CONTAINER_PORT|BACKEND_CONTAINER_PORT/);
193
+ });
194
+
104
195
  test('保留有效的自定义 API 路径', async () => {
105
196
  const configuredReal = realEnv.replace('API_URL=\n', 'API_URL=\nAPI_PREFIX=service\nVITE_API_BASE_URL=/service\n');
106
197
  const configuredExample = exampleEnv.replace('API_URL=\n', 'API_URL=\nAPI_PREFIX=service\nVITE_API_BASE_URL=/service\n');
@@ -133,6 +224,23 @@ test('非标准环境文件被拒绝且不修改', async () => {
133
224
  await assert.rejects(stat(join(root, 'deploy.sh')), { code: 'ENOENT' });
134
225
  });
135
226
 
227
+ test('旧后端基础镜像默认值被拒绝且两个环境文件均不修改', async () => {
228
+ const root = await fixture();
229
+ await invoke(root, '--write');
230
+ const envPath = join(root, '.env');
231
+ const examplePath = join(root, '.env.example');
232
+ const configuredEnv = (await readFile(envPath, 'utf8')).replace('node:24-bookworm-slim', 'node:24-alpine3.24');
233
+ const outdatedExample = (await readFile(examplePath, 'utf8')).replace('node:24-bookworm-slim', 'node:24-alpine3.24');
234
+ await writeFile(envPath, configuredEnv, { mode: 0o600 });
235
+ await writeFile(examplePath, outdatedExample, { mode: 0o644 });
236
+ const beforeEnv = await readFile(envPath);
237
+ const beforeExample = await readFile(examplePath);
238
+
239
+ await assert.rejects(invoke(root, '--write'), /must keep the standard deployment default for BACKEND_BASE_IMAGE/);
240
+ assert.deepEqual(await readFile(envPath), beforeEnv);
241
+ assert.deepEqual(await readFile(examplePath), beforeExample);
242
+ });
243
+
136
244
  test('环境变量集合不一致时拒绝修改', async () => {
137
245
  const root = await fixture(realEnv, exampleEnv.replace('API_URL=', 'API_URL=\nAPI_TOKEN='));
138
246
  await assert.rejects(invoke(root, '--write'), /variable names must remain consistent/);
@@ -55,7 +55,7 @@ const envText = await readFile(join(packageRoot, '.env'), 'utf8');
55
55
  const env = parseDotenv(envText);
56
56
  const sections = [...envText.matchAll(/^# (前端|后端|数据库|API|部署)$/gm)].map((match) => match[1]);
57
57
  assert(sections.join('|') === '前端|后端|数据库|API|部署', 'Root .env must use the five standard sections in order');
58
- for (const key of ['FRONTEND_URL', 'FRONTEND_PORT', 'BACKEND_URL', 'BACKEND_PORT', 'API_PREFIX', 'VITE_API_BASE_URL', 'DB_URL', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME', 'DB_SCHEMA', 'PROJECT_NAME', 'DOCKER_BIND_IP', 'DOCKER_PLATFORM', 'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE']) {
58
+ for (const key of ['FRONTEND_URL', 'FRONTEND_PORT', 'BACKEND_URL', 'BACKEND_PORT', 'API_PREFIX', 'VITE_API_BASE_URL', 'DB_URL', 'DB_PORT', 'DB_USER', 'DB_PASSWORD', 'DB_NAME', 'DB_SCHEMA', 'PROJECT_NAME', 'DOCKER_BIND_IP', 'DOCKER_PLATFORM', 'FRONTEND_BASE_IMAGE', 'BACKEND_BASE_IMAGE', 'TZ']) {
59
59
  assert(env.get(key), `Root .env is missing ${key}`);
60
60
  }
61
61
  assert(/^[a-z][a-z0-9_]*$/.test(env.get('PROJECT_NAME')), 'Invalid PROJECT_NAME');
@@ -67,6 +67,7 @@ assert(env.get('FRONTEND_PORT') !== env.get('BACKEND_PORT'), 'Frontend and backe
67
67
  assert(/^[A-Za-z][A-Za-z0-9_-]*$/.test(env.get('API_PREFIX')), 'API_PREFIX must be one safe path segment');
68
68
  assert(env.get('VITE_API_BASE_URL') === `/${env.get('API_PREFIX')}`, 'VITE_API_BASE_URL must equal /API_PREFIX');
69
69
  assert(/^(?:linux\/amd64|linux\/arm64)$/.test(env.get('DOCKER_PLATFORM')), 'Invalid DOCKER_PLATFORM');
70
+ assert(env.get('TZ') === 'Asia/Shanghai', 'Invalid TZ; expected Asia/Shanghai for CST (UTC+8)');
70
71
  for (const key of env.keys()) {
71
72
  assert(!['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', 'RELEASE_VERSION'].includes(key) && !/_(?:HEALTH|VERSION)_URL$/.test(key), `Root .env contains removed configuration: ${key}`);
72
73
  }
@@ -85,6 +86,12 @@ for (const [pattern, message] of [
85
86
  [/docker save -o "\$package_root\/backend-image\.tar"/, 'Backend image must be exported separately'],
86
87
  [/env COPYFILE_DISABLE=1 tar --no-xattrs -czf "\$candidate"/, 'Delivery archive must exclude macOS metadata and extended attributes'],
87
88
  [/docker version --format '\{\{\.Server\.Os\}\}\/\{\{\.Server\.Arch\}\}'/, 'Deploy must read the Docker Server platform'],
89
+ [/Windows container mode is unsupported; switch Docker Desktop to Linux containers/, 'Deploy must reject Windows container mode explicitly'],
90
+ [/assert_stack_container_ownership/, 'Deploy must validate fixed container name ownership'],
91
+ [/assert_image_cst_time "\$frontend_image"/, 'Frontend built image CST time must be checked'],
92
+ [/assert_image_cst_time "\$backend_image"/, 'Backend built image CST time must be checked'],
93
+ [/assert_container_cst_time "\$\{PROJECT_NAME\}_frontend"/, 'Running frontend CST time must be checked'],
94
+ [/assert_container_cst_time "\$\{PROJECT_NAME\}_backend"/, 'Running backend CST time must be checked'],
88
95
  [/compose up -d --force-recreate --no-build --pull never --wait frontend backend/, 'Deploy must recreate and wait for both services without build or pull'],
89
96
  [/compose stop frontend backend/, 'Stop must target both services'],
90
97
  [/compose ps frontend backend/, 'Status must inspect both services'],
@@ -92,9 +99,17 @@ for (const [pattern, message] of [
92
99
  assert((deploy.match(/docker build --pull=false --platform "\$DOCKER_PLATFORM"/g) ?? []).length === 2, 'Exactly two Docker builds must use the configured target platform and disable pull');
93
100
  const deployStackIndex = deploy.indexOf('deploy_stack() {');
94
101
  const serverPlatformCheckIndex = deploy.indexOf(' assert_server_platform\n', deployStackIndex);
102
+ const ownershipCheckIndex = deploy.indexOf(' assert_stack_container_ownership\n', deployStackIndex);
95
103
  const firstImageLoadIndex = deploy.indexOf(' docker load -i ', deployStackIndex);
96
- assert(deployStackIndex >= 0 && serverPlatformCheckIndex > deployStackIndex && firstImageLoadIndex > serverPlatformCheckIndex, 'Docker Server platform must be checked before loading images');
104
+ const composeUpIndex = deploy.indexOf(' compose up -d --force-recreate --no-build --pull never --wait frontend backend', deployStackIndex);
105
+ const runningTimeCheckIndex = deploy.indexOf(' assert_container_cst_time "${PROJECT_NAME}_frontend"', deployStackIndex);
106
+ assert(deployStackIndex >= 0 && serverPlatformCheckIndex > deployStackIndex && ownershipCheckIndex > serverPlatformCheckIndex && firstImageLoadIndex > ownershipCheckIndex, 'Docker Server platform and container ownership must be checked before loading images');
107
+ assert(composeUpIndex > firstImageLoadIndex && runningTimeCheckIndex > composeUpIndex, 'Running container CST time must be checked after Compose health wait');
108
+ const frontendImageTimeIndex = deploy.indexOf(' assert_image_cst_time "$frontend_image"');
109
+ const firstImageSaveIndex = deploy.indexOf(' docker save -o "$package_root/frontend-image.tar"');
110
+ assert(frontendImageTimeIndex >= 0 && firstImageSaveIndex > frontendImageTimeIndex, 'Built image CST time must be checked before Docker save');
97
111
  assert(!/\bdocker\s+pull\b/.test(deploy), 'docker pull is forbidden');
112
+ assert(!/\bdocker\s+(?:start|restart)\b/.test(deploy), 'Deployment script must not restart manually stopped containers outside explicit Compose deploy');
98
113
  assert(!/\bsource\s+[^\n]*\.env|\beval\b/.test(deploy), 'Environment files must be parsed as data');
99
114
  assert(!/server_menu|运行或替换|read_release_pointer|release_ledger|AGENTS\.md/.test(deploy), 'Legacy menu or release ledger logic is forbidden');
100
115
  assert(!/\bpsql\b|\b(?:scp|rsync)\b|\bgit\s+(?:pull|clone)\b/.test(deploy), 'Database, upload or source-pull commands are forbidden');
@@ -104,15 +119,19 @@ const serviceNames = [...compose.matchAll(/^ ([a-z][a-z0-9_-]*):$/gm)].map((mat
104
119
  assert(JSON.stringify(serviceNames) === JSON.stringify(['frontend', 'backend']), `Compose must define only frontend and backend; got ${serviceNames.join(', ')}`);
105
120
  assert(compose.includes('image: "${PROJECT_NAME:?PROJECT_NAME is required}_frontend:latest"'), 'Compose frontend image is not fixed');
106
121
  assert(compose.includes('image: "${PROJECT_NAME:?PROJECT_NAME is required}_backend:latest"'), 'Compose backend image is not fixed');
122
+ assert(compose.includes('container_name: "${PROJECT_NAME:?PROJECT_NAME is required}_frontend"'), 'Compose frontend container name must equal its image repository');
123
+ assert(compose.includes('container_name: "${PROJECT_NAME:?PROJECT_NAME is required}_backend"'), 'Compose backend container name must equal its image repository');
107
124
  assert((compose.match(/platform: "\$\{DOCKER_PLATFORM:\?DOCKER_PLATFORM is required\}"/g) ?? []).length === 2, 'Both services must use DOCKER_PLATFORM');
108
125
  assert((compose.match(/env_file: \.\/\.env/g) ?? []).length === 2, 'Both services must use root .env');
126
+ assert((compose.match(/TZ: "\$\{TZ:\?TZ is required\}"/g) ?? []).length === 2, 'Both services must explicitly use the same required TZ');
127
+ assert((compose.match(/restart: unless-stopped/g) ?? []).length === 2, 'Both services must use restart: unless-stopped');
109
128
  assert((compose.match(/^\s*- \.\/logs:\/app\/logs\s*$/gm) ?? []).length === 2, 'Both services must mount root logs');
110
129
  assert(compose.includes('"${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}"'), 'Frontend port mapping is invalid');
111
130
  assert(compose.includes('"${DOCKER_BIND_IP}:${BACKEND_PORT}:${BACKEND_PORT}"'), 'Backend port mapping is invalid');
112
131
  assert(compose.includes('VITE_API_BASE_URL: "${VITE_API_BASE_URL:?VITE_API_BASE_URL is required}"'), 'Frontend API path is not passed to Nginx');
113
132
  assert(compose.includes('BACKEND_PORT: "${BACKEND_PORT:?BACKEND_PORT is required}"'), 'Backend port is not passed to Nginx');
114
133
  assert(compose.includes('NGINX_ENVSUBST_FILTER: "^(FRONTEND_URL|FRONTEND_PORT|VITE_API_BASE_URL|BACKEND_PORT)$"'), 'Nginx envsubst filter does not cover the runtime API proxy');
115
- assert(!/container_name:|\b(?:postgres|database|db):\s*$|postgres:\d|DATABASE_MIGRATION|MIGRATIONS_SOURCE|RELEASE_VERSION|blue|green/i.test(compose), 'Compose contains a database, migration, release or blue-green configuration');
134
+ assert(!/\b(?:postgres|database|db):\s*$|postgres:\d|DATABASE_MIGRATION|MIGRATIONS_SOURCE|RELEASE_VERSION|blue|green/i.test(compose), 'Compose contains a database, migration, release or blue-green configuration');
116
135
 
117
136
  const manifestText = await readFile(join(packageRoot, 'manifest.sha256'), 'utf8');
118
137
  const manifestEntries = new Map();
@@ -38,7 +38,8 @@ PROJECT_NAME=example_project
38
38
  DOCKER_BIND_IP=0.0.0.0
39
39
  DOCKER_PLATFORM=linux/amd64
40
40
  FRONTEND_BASE_IMAGE=nginx:stable
41
- BACKEND_BASE_IMAGE=node:24-alpine3.24
41
+ BACKEND_BASE_IMAGE=node:24-bookworm-slim
42
+ TZ=Asia/Shanghai
42
43
  `;
43
44
  const exampleText = `# 前端
44
45
  FRONTEND_URL=0.0.0.0
@@ -62,7 +63,8 @@ PROJECT_NAME=
62
63
  DOCKER_BIND_IP=
63
64
  DOCKER_PLATFORM=linux/amd64
64
65
  FRONTEND_BASE_IMAGE=nginx:stable
65
- BACKEND_BASE_IMAGE=node:24-alpine3.24
66
+ BACKEND_BASE_IMAGE=node:24-bookworm-slim
67
+ TZ=Asia/Shanghai
66
68
  `;
67
69
 
68
70
  const fakeDocker = `#!/usr/bin/env node
@@ -89,6 +91,30 @@ if (args[0] === 'build') {
89
91
  if (process.env.FAIL_BUILD && tag.includes(process.env.FAIL_BUILD)) process.exit(1);
90
92
  process.exit(0);
91
93
  }
94
+ if (args[0] === 'run') {
95
+ const image = args[args.indexOf('-c') - 1];
96
+ if (process.env.FAIL_IMAGE_TIME && image.includes(process.env.FAIL_IMAGE_TIME)) process.exit(1);
97
+ process.stdout.write((process.env.IMAGE_TIME || '19 CST +0800') + '\\n');
98
+ process.exit(0);
99
+ }
100
+ if (args[0] === 'exec') {
101
+ const name = args[1];
102
+ if (process.env.FAIL_CONTAINER_TIME && name.includes(process.env.FAIL_CONTAINER_TIME)) process.exit(1);
103
+ process.stdout.write((process.env.CONTAINER_TIME || '19 CST +0800') + '\\n');
104
+ process.exit(0);
105
+ }
106
+ if (args[0] === 'container' && args[1] === 'ls') {
107
+ const filter = args[args.indexOf('--filter') + 1] || '';
108
+ const name = filter.replace('name=^/', '').replace('$$', '').replace('$', '');
109
+ if (process.env.EXISTING_CONTAINER_NAME === name) process.stdout.write(name + '\\n');
110
+ process.exit(0);
111
+ }
112
+ if (args[0] === 'container' && args[1] === 'inspect') {
113
+ const name = args[args.length - 1];
114
+ if (process.env.EXISTING_CONTAINER_NAME !== name) process.exit(1);
115
+ process.stdout.write((process.env.CONTAINER_PROJECT || '') + '|' + (process.env.CONTAINER_SERVICE || '') + '\\n');
116
+ process.exit(0);
117
+ }
92
118
  if (args[0] === 'save') {
93
119
  const output = args[args.indexOf('-o') + 1];
94
120
  const tag = args[args.length - 1];
@@ -103,6 +129,9 @@ if (args[0] === 'save') {
103
129
  if (args[0] === 'compose' && args.includes('up') && process.env.FAIL_COMPOSE_UP === '1') process.exit(1);
104
130
  process.exit(0);
105
131
  `;
132
+ const fakeUname = `#!/usr/bin/env node
133
+ process.stdout.write((process.env.HOST_KERNEL || 'Linux') + '\\n');
134
+ `;
106
135
 
107
136
  async function fixture() {
108
137
  const root = await mkdtemp(join(tmpdir(), 'wdyy-unified-delivery-'));
@@ -119,7 +148,9 @@ async function fixture() {
119
148
  const bin = join(root, 'fake-bin');
120
149
  await mkdir(bin);
121
150
  await writeFile(join(bin, 'docker'), fakeDocker, { mode: 0o755 });
151
+ await writeFile(join(bin, 'uname'), fakeUname, { mode: 0o755 });
122
152
  await chmod(join(bin, 'docker'), 0o755);
153
+ await chmod(join(bin, 'uname'), 0o755);
123
154
  const log = join(root, 'docker.log');
124
155
  const environment = { ...process.env, PATH: `${bin}:${process.env.PATH}`, DOCKER_LOG: log };
125
156
  return { root, log, environment, archive: join(root, 'deploy/example_project-docker.tar.gz') };
@@ -192,11 +223,17 @@ test('build 使用本地基础镜像并生成一个含双镜像归档的压缩
192
223
  assert.deepEqual((await readdir(extracted)).sort(), ['.env', 'backend-image.tar', 'deploy.sh', 'docker-compose.yml', 'frontend-image.tar', 'manifest.sha256'].sort());
193
224
  const validation = await run(process.execPath, [validator, extracted]);
194
225
  assert.match(validation.stdout, /valid unified Docker delivery package/);
226
+ const compose = await readFile(join(extracted, 'docker-compose.yml'), 'utf8');
227
+ assert.equal((compose.match(/TZ: "\$\{TZ:\?TZ is required\}"/g) ?? []).length, 2);
228
+ assert.match(compose, /container_name: "\$\{PROJECT_NAME:\?PROJECT_NAME is required\}_frontend"/);
229
+ assert.match(compose, /container_name: "\$\{PROJECT_NAME:\?PROJECT_NAME is required\}_backend"/);
230
+ assert.equal((compose.match(/restart: unless-stopped/g) ?? []).length, 2);
195
231
  const log = await readFile(item.log, 'utf8');
196
232
  assert.match(log, /image inspect nginx:stable/);
197
- assert.match(log, /image inspect node:24-alpine3\.24/);
233
+ assert.match(log, /image inspect node:24-bookworm-slim/);
198
234
  assert.equal((log.match(/build --pull=false --platform linux\/amd64/g) ?? []).length, 2);
199
235
  assert.match(log, /build --pull=false --platform linux\/amd64 .*--build-arg VITE_API_BASE_URL=\/api .*example_project_frontend:latest/);
236
+ assert.equal((log.match(/^run --rm --pull=never --platform linux\/amd64 /gm) ?? []).length, 2);
200
237
  assert.doesNotMatch(log, /(^|\s)pull(\s|$)/m);
201
238
  assert.match(log, /save -o .*frontend-image\.tar example_project_frontend:latest/);
202
239
  assert.match(log, /save -o .*backend-image\.tar example_project_backend:latest/);
@@ -210,10 +247,10 @@ test('基础镜像平台不匹配时在 build 前失败', async () => {
210
247
  assert.equal((await readdir(join(item.root, 'deploy'))).length, 0);
211
248
  });
212
249
 
213
- test('linux/arm64 目标在不同构建机平台上生成并部署单架构交付包', async () => {
250
+ test('macOS arm64 与 Windows WSL2 使用 Linux engine 生成并部署单架构交付包', async () => {
214
251
  const item = await fixture();
215
252
  await setTargetPlatform(item.root, 'linux/arm64');
216
- await buildProject(item, { IMAGE_PLATFORM: 'linux/arm64' });
253
+ await buildProject(item, { HOST_KERNEL: 'Darwin', IMAGE_PLATFORM: 'linux/arm64' });
217
254
  const buildLog = await readFile(item.log, 'utf8');
218
255
  assert.equal((buildLog.match(/build --pull=false --platform linux\/arm64/g) ?? []).length, 2);
219
256
  const extracted = await extractArchive(item);
@@ -222,10 +259,17 @@ test('linux/arm64 目标在不同构建机平台上生成并部署单架构交
222
259
  const validation = await run(process.execPath, [validator, extracted]);
223
260
  assert.match(validation.stdout, /valid unified Docker delivery package/);
224
261
  await writeFile(item.log, '');
225
- await run(join(extracted, 'deploy.sh'), [], { env: { ...item.environment, IMAGE_PLATFORM: 'linux/arm64', SERVER_PLATFORM: 'linux/arm64' } });
262
+ await run(join(extracted, 'deploy.sh'), [], { env: { ...item.environment, HOST_KERNEL: 'Linux', WSL_DISTRO_NAME: 'Ubuntu', IMAGE_PLATFORM: 'linux/arm64', SERVER_PLATFORM: 'linux/arm64' } });
226
263
  const deployLog = await readFile(item.log, 'utf8');
227
264
  assert.match(deployLog, /^version --format \{\{\.Server\.Os\}\}\/\{\{\.Server\.Arch\}\}$/m);
228
265
  assert.equal((deployLog.match(/^load -i /gm) ?? []).length, 2);
266
+ assert.equal((deployLog.match(/^exec example_project_(?:frontend|backend) /gm) ?? []).length, 2);
267
+ });
268
+
269
+ test('原生 Windows shell 在任何 Docker 操作前被拒绝', async () => {
270
+ const item = await fixture();
271
+ await assert.rejects(buildProject(item, { HOST_KERNEL: 'Windows_NT' }), /unsupported execution host: Windows_NT/);
272
+ await assert.rejects(stat(item.log), { code: 'ENOENT' });
229
273
  });
230
274
 
231
275
  test('缺少本地基础镜像时在 build 前失败', async () => {
@@ -247,6 +291,20 @@ test('构建失败保留上一份成功交付包且不暴露半成品', async ()
247
291
  assert.deepEqual(await readdir(join(item.root, 'deploy')), ['example_project-docker.tar.gz']);
248
292
  });
249
293
 
294
+ test('镜像时间探测失败时保留上一份成功交付包', async () => {
295
+ const item = await fixture();
296
+ await buildProject(item);
297
+ const before = createHash('sha256').update(await readFile(item.archive)).digest('hex');
298
+ await assert.rejects(buildProject(item, { FAIL_IMAGE_TIME: 'frontend' }), /unable to verify image CST time: example_project_frontend:latest/);
299
+ const after = createHash('sha256').update(await readFile(item.archive)).digest('hex');
300
+ assert.equal(after, before);
301
+ const log = await readFile(item.log, 'utf8');
302
+ const lastSave = log.lastIndexOf('save -o ');
303
+ const lastRun = log.lastIndexOf('run --rm --pull=never');
304
+ assert(lastRun > lastSave);
305
+ assert.doesNotMatch(log.slice(lastRun), /^save -o /m);
306
+ });
307
+
250
308
  test('服务器无参数执行时从脚本目录整体加载并部署前后端', async () => {
251
309
  const item = await fixture();
252
310
  await buildProject(item);
@@ -256,10 +314,12 @@ test('服务器无参数执行时从脚本目录整体加载并部署前后端',
256
314
  const log = await readFile(item.log, 'utf8');
257
315
  const versionIndex = log.indexOf('version --format {{.Server.Os}}/{{.Server.Arch}}');
258
316
  const firstLoadIndex = log.indexOf('load -i ');
259
- assert(versionIndex >= 0 && firstLoadIndex > versionIndex);
317
+ const ownershipIndex = log.indexOf('container ls --all --filter name=^/example_project_frontend$');
318
+ assert(versionIndex >= 0 && ownershipIndex > versionIndex && firstLoadIndex > ownershipIndex);
260
319
  assert.equal((log.match(/^load -i /gm) ?? []).length, 2);
261
320
  assert.match(log, /compose --env-file .* --project-name example_project -f .* up -d --force-recreate --no-build --pull never --wait frontend backend/);
262
- assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=deploy result=success/);
321
+ assert.equal((log.match(/^exec example_project_(?:frontend|backend) /gm) ?? []).length, 2);
322
+ assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /CST \+0800 operation=deploy result=success/);
263
323
  });
264
324
 
265
325
  test('服务器平台无法读取、不受支持或不匹配时在加载镜像前失败', async () => {
@@ -267,7 +327,7 @@ test('服务器平台无法读取、不受支持或不匹配时在加载镜像
267
327
  await buildProject(item);
268
328
  const cases = [
269
329
  [{ FAIL_SERVER_PLATFORM_READ: '1' }, /Docker Server platform is unavailable/],
270
- [{ SERVER_PLATFORM: 'windows/amd64' }, /unsupported Docker Server platform: windows\/amd64/],
330
+ [{ SERVER_PLATFORM: 'windows/amd64' }, /Windows container mode is unsupported; switch Docker Desktop to Linux containers/],
271
331
  [{ SERVER_PLATFORM: 'linux/arm64' }, /Docker Server platform mismatch: expected linux\/amd64, got linux\/arm64/],
272
332
  ];
273
333
  for (const [extraEnvironment, expectedError] of cases) {
@@ -282,6 +342,26 @@ test('服务器平台无法读取、不受支持或不匹配时在加载镜像
282
342
  }
283
343
  });
284
344
 
345
+ test('固定容器名冲突在镜像加载前失败,当前项目所有权允许继续', async () => {
346
+ const item = await fixture();
347
+ await buildProject(item);
348
+ const conflictPackage = await extractArchive(item);
349
+ await writeFile(item.log, '');
350
+ await assert.rejects(run(join(conflictPackage, 'deploy.sh'), [], {
351
+ env: { ...item.environment, EXISTING_CONTAINER_NAME: 'example_project_frontend', CONTAINER_PROJECT: 'other_project', CONTAINER_SERVICE: 'frontend' }
352
+ }), /container name conflict: example_project_frontend belongs to other_project\|frontend/);
353
+ const conflictLog = await readFile(item.log, 'utf8');
354
+ assert.match(conflictLog, /^container inspect .*example_project_frontend$/m);
355
+ assert.doesNotMatch(conflictLog, /^load -i /m);
356
+
357
+ const ownedPackage = await extractArchive(item);
358
+ await writeFile(item.log, '');
359
+ await run(join(ownedPackage, 'deploy.sh'), [], {
360
+ env: { ...item.environment, EXISTING_CONTAINER_NAME: 'example_project_frontend', CONTAINER_PROJECT: 'example_project', CONTAINER_SERVICE: 'frontend' }
361
+ });
362
+ assert.equal(((await readFile(item.log, 'utf8')).match(/^load -i /gm) ?? []).length, 2);
363
+ });
364
+
285
365
  test('服务器在 Docker 操作前拒绝 AppleDouble 和上传压缩包残留', async () => {
286
366
  const item = await fixture();
287
367
  await buildProject(item);
@@ -306,6 +386,7 @@ test('stop 与 status 始终作用于完整项目栈,非法参数不触发 Doc
306
386
  const beforeInvalid = await readFile(item.log, 'utf8');
307
387
  assert.match(beforeInvalid, /stop frontend backend/);
308
388
  assert.match(beforeInvalid, /ps frontend backend/);
389
+ assert.doesNotMatch(beforeInvalid, /^\s*(?:start|restart)\b|^compose .* up /m);
309
390
  await assert.rejects(run(join(extracted, 'deploy.sh'), ['frontend'], { env: item.environment }), /usage:/);
310
391
  assert.equal(await readFile(item.log, 'utf8'), beforeInvalid);
311
392
  assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=stop result=success/);
@@ -319,6 +400,19 @@ test('整栈健康等待失败返回非零并记录失败', async () => {
319
400
  assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=deploy result=failure/);
320
401
  });
321
402
 
403
+ test('运行容器时间探测失败在健康等待后明确失败并记录容器', async () => {
404
+ const item = await fixture();
405
+ await buildProject(item);
406
+ const extracted = await extractArchive(item);
407
+ await writeFile(item.log, '');
408
+ await assert.rejects(run(join(extracted, 'deploy.sh'), [], { env: { ...item.environment, FAIL_CONTAINER_TIME: 'backend' } }), /unable to verify running container CST time: example_project_backend/);
409
+ const log = await readFile(item.log, 'utf8');
410
+ const composeIndex = log.indexOf(' up -d --force-recreate');
411
+ const execIndex = log.indexOf('exec example_project_backend');
412
+ assert(composeIndex >= 0 && execIndex > composeIndex);
413
+ assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /operation=deploy result=failure/);
414
+ });
415
+
322
416
  test('validator 拒绝数据库容器和不完整远程数据库配置', async () => {
323
417
  const item = await fixture();
324
418
  await buildProject(item);
@@ -333,6 +427,56 @@ test('validator 拒绝数据库容器和不完整远程数据库配置', async (
333
427
  await assert.rejects(run(process.execPath, [validator, envPackage]), /Root \.env is missing DB_URL/);
334
428
  });
335
429
 
430
+ test('缺失或非 Asia/Shanghai TZ 在 Docker 状态变化前被拒绝', async () => {
431
+ const item = await fixture();
432
+ const environmentPath = join(item.root, '.env');
433
+ await writeFile(environmentPath, envText.replace('TZ=Asia/Shanghai', 'TZ=../Shanghai'), { mode: 0o600 });
434
+ await assert.rejects(buildProject(item), /TZ must equal Asia\/Shanghai for CST \(UTC\+8\)/);
435
+ await assert.rejects(stat(item.log), { code: 'ENOENT' });
436
+
437
+ await writeFile(environmentPath, envText, { mode: 0o600 });
438
+ await buildProject(item);
439
+
440
+ const missingPackage = await extractArchive(item);
441
+ await writeFile(join(missingPackage, '.env'), envText.replace('TZ=Asia/Shanghai\n', ''), { mode: 0o600 });
442
+ await rewriteManifest(missingPackage);
443
+ await assert.rejects(run(process.execPath, [validator, missingPackage]), /Root \.env is missing TZ/);
444
+
445
+ const invalidPackage = await extractArchive(item);
446
+ await writeFile(join(invalidPackage, '.env'), envText.replace('TZ=Asia/Shanghai', 'TZ=UTC'), { mode: 0o600 });
447
+ await rewriteManifest(invalidPackage);
448
+ await assert.rejects(run(process.execPath, [validator, invalidPackage]), /Invalid TZ; expected Asia\/Shanghai/);
449
+
450
+ const composePackage = await extractArchive(item);
451
+ const compose = await readFile(join(composePackage, 'docker-compose.yml'), 'utf8');
452
+ await writeFile(join(composePackage, 'docker-compose.yml'), compose.replace(' TZ: "${TZ:?TZ is required}"\n', ''));
453
+ await rewriteManifest(composePackage);
454
+ await assert.rejects(run(process.execPath, [validator, composePackage]), /Both services must explicitly use the same required TZ/);
455
+ });
456
+
457
+ test('validator 拒绝容器名、重启策略或端口单一来源被破坏', async () => {
458
+ const item = await fixture();
459
+ await buildProject(item);
460
+
461
+ const namePackage = await extractArchive(item);
462
+ let compose = await readFile(join(namePackage, 'docker-compose.yml'), 'utf8');
463
+ await writeFile(join(namePackage, 'docker-compose.yml'), compose.replace('container_name: "${PROJECT_NAME:?PROJECT_NAME is required}_frontend"', 'container_name: unrelated_frontend'));
464
+ await rewriteManifest(namePackage);
465
+ await assert.rejects(run(process.execPath, [validator, namePackage]), /frontend container name must equal its image repository/);
466
+
467
+ const restartPackage = await extractArchive(item);
468
+ compose = await readFile(join(restartPackage, 'docker-compose.yml'), 'utf8');
469
+ await writeFile(join(restartPackage, 'docker-compose.yml'), compose.replace('restart: unless-stopped', 'restart: always'));
470
+ await rewriteManifest(restartPackage);
471
+ await assert.rejects(run(process.execPath, [validator, restartPackage]), /Both services must use restart: unless-stopped/);
472
+
473
+ const portPackage = await extractArchive(item);
474
+ compose = await readFile(join(portPackage, 'docker-compose.yml'), 'utf8');
475
+ await writeFile(join(portPackage, 'docker-compose.yml'), compose.replace('${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}', '${DOCKER_BIND_IP}:${FRONTEND_PORT}:80'));
476
+ await rewriteManifest(portPackage);
477
+ await assert.rejects(run(process.execPath, [validator, portPackage]), /Frontend port mapping is invalid/);
478
+ });
479
+
336
480
  test('validator 拒绝冲突的 API 路径和缺失的代理注入', async () => {
337
481
  const item = await fixture();
338
482
  await buildProject(item);
@@ -53,16 +53,28 @@ validate_env_permissions() {
53
53
  [[ "$mode" == 400 || "$mode" == 600 ]] || fail "root .env permissions must be 400 or 600; got $mode"
54
54
  }
55
55
 
56
+ assert_execution_host() {
57
+ local kernel
58
+ kernel="$(uname -s 2>/dev/null)" || fail "execution host OS is unavailable"
59
+ case "$kernel" in
60
+ Darwin|Linux) ;;
61
+ *) fail "unsupported execution host: $kernel; use Linux, macOS, or Windows Docker Desktop through WSL2" ;;
62
+ esac
63
+ if [[ "$kernel" == Linux ]] && { [[ -n "${WSL_DISTRO_NAME:-}" ]] || grep -qi microsoft /proc/sys/kernel/osrelease 2>/dev/null; }; then
64
+ [[ ! "$SCRIPT_DIR" =~ ^/mnt/[A-Za-z](/|$) ]] || fail "Windows Docker Desktop execution must use the WSL2 Linux filesystem, not $SCRIPT_DIR"
65
+ fi
66
+ }
67
+
56
68
  reset_configuration() {
57
69
  unset FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT
58
70
  unset API_PREFIX VITE_API_BASE_URL
59
71
  unset DB_URL DB_PORT DB_USER DB_PASSWORD DB_NAME DB_SCHEMA
60
- unset PROJECT_NAME DOCKER_BIND_IP DOCKER_PLATFORM FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE
72
+ unset PROJECT_NAME DOCKER_BIND_IP DOCKER_PLATFORM FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE TZ
61
73
  }
62
74
 
63
75
  is_configuration_key() {
64
76
  case "$1" in
65
- FRONTEND_URL|FRONTEND_PORT|BACKEND_URL|BACKEND_PORT|API_PREFIX|VITE_API_BASE_URL|DB_URL|DB_PORT|DB_USER|DB_PASSWORD|DB_NAME|DB_SCHEMA|PROJECT_NAME|DOCKER_BIND_IP|DOCKER_PLATFORM|FRONTEND_BASE_IMAGE|BACKEND_BASE_IMAGE)
77
+ FRONTEND_URL|FRONTEND_PORT|BACKEND_URL|BACKEND_PORT|API_PREFIX|VITE_API_BASE_URL|DB_URL|DB_PORT|DB_USER|DB_PASSWORD|DB_NAME|DB_SCHEMA|PROJECT_NAME|DOCKER_BIND_IP|DOCKER_PLATFORM|FRONTEND_BASE_IMAGE|BACKEND_BASE_IMAGE|TZ)
66
78
  return 0 ;;
67
79
  *) return 1 ;;
68
80
  esac
@@ -106,7 +118,7 @@ load_dotenv() {
106
118
 
107
119
  validate_configuration() {
108
120
  local key value
109
- for key in FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT API_PREFIX VITE_API_BASE_URL DB_URL DB_PORT DB_USER DB_PASSWORD DB_NAME DB_SCHEMA PROJECT_NAME DOCKER_BIND_IP DOCKER_PLATFORM FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE; do
121
+ for key in FRONTEND_URL FRONTEND_PORT BACKEND_URL BACKEND_PORT API_PREFIX VITE_API_BASE_URL DB_URL DB_PORT DB_USER DB_PASSWORD DB_NAME DB_SCHEMA PROJECT_NAME DOCKER_BIND_IP DOCKER_PLATFORM FRONTEND_BASE_IMAGE BACKEND_BASE_IMAGE TZ; do
110
122
  value="${!key:-}"
111
123
  [[ -n "$value" ]] || fail "required configuration is missing: $key"
112
124
  done
@@ -120,6 +132,7 @@ validate_configuration() {
120
132
  [[ "$DB_URL" =~ ^[A-Za-z0-9._:/?@\&=%-]+$ ]] || fail "DB_URL contains unsafe characters"
121
133
  [[ "$FRONTEND_BASE_IMAGE" =~ ^[A-Za-z0-9._/:@-]+$ ]] || fail "FRONTEND_BASE_IMAGE is not a valid image reference"
122
134
  [[ "$BACKEND_BASE_IMAGE" =~ ^[A-Za-z0-9._/:@-]+$ ]] || fail "BACKEND_BASE_IMAGE is not a valid image reference"
135
+ [[ "$TZ" == Asia/Shanghai ]] || fail "TZ must equal Asia/Shanghai for CST (UTC+8)"
123
136
  assert_port FRONTEND_PORT "$FRONTEND_PORT"
124
137
  assert_port BACKEND_PORT "$BACKEND_PORT"
125
138
  assert_port DB_PORT "$DB_PORT"
@@ -136,10 +149,43 @@ assert_server_platform() {
136
149
  local actual
137
150
  actual="$(docker version --format '{{.Server.Os}}/{{.Server.Arch}}' 2>/dev/null)" || fail "Docker Server platform is unavailable"
138
151
  [[ -n "$actual" ]] || fail "Docker Server platform is empty"
152
+ [[ "$actual" != windows/* ]] || fail "Windows container mode is unsupported; switch Docker Desktop to Linux containers"
139
153
  [[ "$actual" =~ ^linux/(amd64|arm64)$ ]] || fail "unsupported Docker Server platform: $actual"
140
154
  [[ "$actual" == "$DOCKER_PLATFORM" ]] || fail "Docker Server platform mismatch: expected $DOCKER_PLATFORM, got $actual"
141
155
  }
142
156
 
157
+ assert_container_ownership() {
158
+ local name="$1" expected_service="$2" matches ownership
159
+ matches="$(docker container ls --all --filter "name=^/${name}$" --format '{{.Names}}' 2>/dev/null)" || fail "unable to inspect existing container name: $name"
160
+ [[ -n "$matches" ]] || return 0
161
+ [[ "$matches" == "$name" ]] || fail "container name lookup returned unexpected results for $name: $matches"
162
+ ownership="$(docker container inspect --format '{{ index .Config.Labels "com.docker.compose.project" }}|{{ index .Config.Labels "com.docker.compose.service" }}' "$name" 2>/dev/null)" || fail "unable to inspect container ownership: $name"
163
+ [[ "$ownership" == "$PROJECT_NAME|$expected_service" ]] || fail "container name conflict: $name belongs to ${ownership:-unknown}; expected $PROJECT_NAME|$expected_service"
164
+ }
165
+
166
+ assert_stack_container_ownership() {
167
+ assert_container_ownership "${PROJECT_NAME}_frontend" frontend
168
+ assert_container_ownership "${PROJECT_NAME}_backend" backend
169
+ }
170
+
171
+ assert_cst_time_value() {
172
+ local label="$1" actual="$2"
173
+ actual="${actual%$'\r'}"
174
+ [[ "$actual" =~ ^([01][0-9]|2[0-3])[[:space:]]CST[[:space:]]\+0800$ ]] || fail "$label time must use 24-hour CST +0800; got ${actual:-empty}"
175
+ }
176
+
177
+ assert_image_cst_time() {
178
+ local image="$1" actual
179
+ actual="$(docker run --rm --pull=never --platform "$DOCKER_PLATFORM" --entrypoint sh --env TZ=Asia/Shanghai "$image" -c 'LC_ALL=C date "+%H %Z %z"' 2>/dev/null)" || fail "unable to verify image CST time: $image"
180
+ assert_cst_time_value "image $image" "$actual"
181
+ }
182
+
183
+ assert_container_cst_time() {
184
+ local name="$1" actual
185
+ actual="$(docker exec "$name" sh -c 'LC_ALL=C date "+%H %Z %z"' 2>/dev/null)" || fail "unable to verify running container CST time: $name"
186
+ assert_cst_time_value "container $name" "$actual"
187
+ }
188
+
143
189
  validate_dockerignore() {
144
190
  local file="$1" pattern line
145
191
  [[ -f "$file" && ! -L "$file" ]] || fail "required Docker ignore file is missing: $file"
@@ -224,8 +270,9 @@ ensure_deploy_output_scope() {
224
270
  build_release() {
225
271
  local project_root="$SCRIPT_DIR" frontend_dockerfile backend_dockerfile compose_source dockerignore_file
226
272
  local frontend_image backend_image package_root deploy_dir archive_name candidate
227
- for command in docker tar install find sort grep sed awk mktemp; do require_command "$command"; done
273
+ for command in docker tar install find sort grep sed awk mktemp uname; do require_command "$command"; done
228
274
  select_checksum_tool
275
+ assert_execution_host
229
276
  load_dotenv "$project_root/.env"
230
277
  validate_configuration
231
278
  frontend_dockerfile="$project_root/src/frontend/Dockerfile"
@@ -250,6 +297,8 @@ build_release() {
250
297
  docker image inspect "$backend_image" >/dev/null 2>&1 || fail "backend image build did not create $backend_image"
251
298
  assert_image_platform "$frontend_image"
252
299
  assert_image_platform "$backend_image"
300
+ assert_image_cst_time "$frontend_image"
301
+ assert_image_cst_time "$backend_image"
253
302
  STAGING_ROOT="$(mktemp -d "$project_root/.deploy-build.XXXXXX")"
254
303
  package_root="$STAGING_ROOT/package"
255
304
  mkdir -p "$package_root"
@@ -280,8 +329,9 @@ build_release() {
280
329
 
281
330
  initialize_server_context() {
282
331
  DEPLOY_ROOT="$SCRIPT_DIR"
283
- for command in docker tar find sort grep sed awk; do require_command "$command"; done
332
+ for command in docker tar find sort grep sed awk uname; do require_command "$command"; done
284
333
  select_checksum_tool
334
+ assert_execution_host
285
335
  load_dotenv "$DEPLOY_ROOT/.env"
286
336
  validate_configuration
287
337
  [[ -f "$DEPLOY_ROOT/docker-compose.yml" && ! -L "$DEPLOY_ROOT/docker-compose.yml" ]] || fail "docker-compose.yml is missing"
@@ -295,7 +345,7 @@ compose() {
295
345
  record_operation() {
296
346
  local result="$1" timestamp
297
347
  [[ -n "$DEPLOY_ROOT" && -d "$DEPLOY_ROOT/logs" && -n "$OPERATION" ]] || return 0
298
- timestamp="$(date '+%Y-%m-%d %H:%M:%S %z')"
348
+ timestamp="$(TZ=Asia/Shanghai LC_ALL=C date '+%Y-%m-%d %H:%M:%S %Z %z')"
299
349
  printf '%s operation=%s result=%s\n' "$timestamp" "$OPERATION" "$result" >> "$DEPLOY_ROOT/logs/deploy.log"
300
350
  }
301
351
 
@@ -306,6 +356,7 @@ deploy_stack() {
306
356
  archive_has_tag "$DEPLOY_ROOT/frontend-image.tar" "$frontend_image"
307
357
  archive_has_tag "$DEPLOY_ROOT/backend-image.tar" "$backend_image"
308
358
  assert_server_platform
359
+ assert_stack_container_ownership
309
360
  docker load -i "$DEPLOY_ROOT/frontend-image.tar" >/dev/null
310
361
  docker load -i "$DEPLOY_ROOT/backend-image.tar" >/dev/null
311
362
  assert_image_platform "${PROJECT_NAME}_frontend:latest"
@@ -313,6 +364,8 @@ deploy_stack() {
313
364
  docker image inspect "$frontend_image" >/dev/null 2>&1 || fail "loaded frontend image is missing: $frontend_image"
314
365
  docker image inspect "$backend_image" >/dev/null 2>&1 || fail "loaded backend image is missing: $backend_image"
315
366
  compose up -d --force-recreate --no-build --pull never --wait frontend backend
367
+ assert_container_cst_time "${PROJECT_NAME}_frontend"
368
+ assert_container_cst_time "${PROJECT_NAME}_backend"
316
369
  }
317
370
 
318
371
  run_logged_operation() {
@@ -332,6 +385,8 @@ server_deploy() {
332
385
 
333
386
  server_stop() {
334
387
  initialize_server_context
388
+ assert_server_platform
389
+ assert_stack_container_ownership
335
390
  run_logged_operation stop compose stop frontend backend
336
391
  }
337
392
 
@@ -1,12 +1,14 @@
1
1
  services:
2
2
  frontend:
3
3
  image: "${PROJECT_NAME:?PROJECT_NAME is required}_frontend:latest"
4
+ container_name: "${PROJECT_NAME:?PROJECT_NAME is required}_frontend"
4
5
  platform: "${DOCKER_PLATFORM:?DOCKER_PLATFORM is required}"
5
6
  env_file: ./.env
6
7
  environment:
7
8
  NGINX_ENVSUBST_FILTER: "^(FRONTEND_URL|FRONTEND_PORT|VITE_API_BASE_URL|BACKEND_PORT)$"
8
9
  VITE_API_BASE_URL: "${VITE_API_BASE_URL:?VITE_API_BASE_URL is required}"
9
10
  BACKEND_PORT: "${BACKEND_PORT:?BACKEND_PORT is required}"
11
+ TZ: "${TZ:?TZ is required}"
10
12
  ports:
11
13
  - "${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}"
12
14
  volumes:
@@ -23,11 +25,13 @@ services:
23
25
 
24
26
  backend:
25
27
  image: "${PROJECT_NAME:?PROJECT_NAME is required}_backend:latest"
28
+ container_name: "${PROJECT_NAME:?PROJECT_NAME is required}_backend"
26
29
  platform: "${DOCKER_PLATFORM:?DOCKER_PLATFORM is required}"
27
30
  env_file: ./.env
28
31
  environment:
29
32
  HOST: "${BACKEND_URL}"
30
33
  PORT: "${BACKEND_PORT}"
34
+ TZ: "${TZ:?TZ is required}"
31
35
  ports:
32
36
  - "${DOCKER_BIND_IP}:${BACKEND_PORT}:${BACKEND_PORT}"
33
37
  volumes:
@@ -4,4 +4,5 @@ PROJECT_NAME=
4
4
  DOCKER_BIND_IP=
5
5
  DOCKER_PLATFORM=linux/amd64
6
6
  FRONTEND_BASE_IMAGE=nginx:stable
7
- BACKEND_BASE_IMAGE=node:24-alpine3.24
7
+ BACKEND_BASE_IMAGE=node:24-bookworm-slim
8
+ TZ=Asia/Shanghai
package/README.md CHANGED
@@ -18,7 +18,7 @@ OpenSpec 单项变更的需求、设计、任务、实
18
18
  | `wdyy-api-standard` | 根据“获取XX数据”“使用XX接口”等需求定位接口 reference,并按已确认契约写入调用方式。 |
19
19
  | `wdyy-bug-record` | 手动汇总当前任务已解决的问题,并按模板追加至 `docs/bug_record.md`。 |
20
20
  | `wdyy-logging-standard` | 结构化日志、敏感字段移除、traceId、前端异常上报与日志轮转。 |
21
- | `wdyy-deployment-standard` | 使用本地基础镜像构建前后端独立镜像,分别导出并统一压缩交付,远程数据库直连,服务器一键部署整个项目栈。 |
21
+ | `wdyy-deployment-standard` | Linux、macOS Docker Desktop 或 Windows Docker Desktop + WSL2/Linux 容器模式中,使用本地基础镜像构建前后端独立镜像,分别导出并统一压缩交付,远程数据库直连并一键部署整个项目栈。 |
22
22
 
23
23
  ## 使用方式
24
24
 
@@ -43,7 +43,7 @@ wdyy update-skill --all
43
43
 
44
44
  `wdyy update-skill <skill-name>` 只接受 5 个受管理的 `wdyy-*` Skill,且仅替换系统用户目录 `.agents/skills/` 中的指定目录;`--all` 替换全部 5 个受管理 Skill。两种方式都不会写入调用项目的 `AGENTS.md`、`AGENTS_new.md`、`docs/bug_record.md` 或 `.agents/skills/`,也不会执行 `openspec init`。首次初始化或需要重新生成项目规则时,使用 `wdyy init`。
45
45
 
46
- 部署 Skill 生成的标准流程为:在本地项目根目录执行 `./deploy.sh build`,只使用本地已有的 Node、Nginx 基础镜像构建 `<项目名>_frontend:latest` 与 `<项目名>_backend:latest`,分别导出两个镜像 tar,并统一封装为 `deploy/<项目名>-docker.tar.gz`。工程师人工上传并解压该压缩包,服务器无参数执行 `./deploy.sh` 即校验、加载并整体部署前后端;另可执行 `./deploy.sh stop` 和 `./deploy.sh status`。数据库通过 `.env` 直连远程 PostgreSQL,不生成数据库容器;前后端直接暴露 `.env` 中的宿主机端口,日志直接保存在服务器部署根 `logs/`。
46
+ 部署 Skill 生成的标准流程为:在 Linux、macOS Docker Desktop,或 Windows Docker Desktop + WSL2/Linux 文件系统的项目根目录执行 `./deploy.sh build`,只使用本地已有的 Node、Nginx 基础镜像构建 `<项目名>_frontend:latest` 与 `<项目名>_backend:latest`,分别导出两个镜像 tar,并统一封装为 `deploy/<项目名>-docker.tar.gz`。工程师人工复制并解压该压缩包,在平台匹配的 Linux engine 上无参数执行 `./deploy.sh` 即校验、加载并整体部署前后端;另可执行 `./deploy.sh stop` 和 `./deploy.sh status`。数据库通过 `.env` 直连远程 PostgreSQL,不生成数据库容器;前后端的宿主机与容器端口都来自 `.env`,容器名与镜像 repository 相同,使用 `unless-stopped`,并验证 24 小时制 CST(UTC+8);日志直接保存在部署根 `logs/`。不支持原生 PowerShell 或 Windows 容器模式。
47
47
 
48
48
  中大型变更先使用 `/opsx:explore` 或 `/opsx:propose <change-name>`,在方案确认后使用 `/opsx:apply`;完成验证后使用 `/opsx:archive`。涉及数据库、内部 API、日志、部署时,按 `AGENTS.md` 的路由加载相应企业 Skill。所有 URL、端口和 IP 地址从 `.env` 读取,不得硬编码;所有项目管理的日志直接写入项目根目录 `logs/`;生产部署不使用蓝绿发布。不得提交秘密或生产环境 `.env`。项目完成后创建 `README.md`,说明技术栈、目录结构和安装部署。
49
49
 
package/lib/wdyy-cli.js CHANGED
@@ -82,7 +82,7 @@ const generatedAgentsContent = `# 企业开发规则
82
82
  | 获取已登记接口数据、使用已登记接口 | \`wdyy-api-standard\` |
83
83
  | 汇总当前任务已解决的 BUG、漏洞或运行异常 | 手动调用 \`wdyy-bug-record\`,追加写入 \`docs/bug_record.md\` |
84
84
  | 结构化日志、traceId、完整原始入参、前端异常上报 | \`wdyy-logging-standard\` |
85
- | 统一 Docker 交付、本地双镜像、远程数据库与整栈一键部署 | \`wdyy-deployment-standard\` |
85
+ | 统一 Docker 交付、Linux/Mac/Windows WSL2 双镜像、远程数据库与整栈一键部署 | \`wdyy-deployment-standard\` |
86
86
 
87
87
  ## 通用边界
88
88
 
@@ -90,7 +90,7 @@ const generatedAgentsContent = `# 企业开发规则
90
90
  - 代码、配置、迁移、测试与部署文件必须以实际实现为准;不得用模板虚构已实现能力。
91
91
  - 所有需求(如数据库变更、流程变更、功能变更、从零开发项目等),必须先使用\`openspec-explore\`技能确认需求,再根据实际情况,使用\`openspec\`的其它技能。
92
92
  - 所有项目管理的日志必须直接写入项目根目录 \`logs/\`,不得通过环境变量改写目录或创建服务、实例、日期等日志子目录。
93
- - 生产部署必须使用 \`wdyy-deployment-standard\`:本地只使用已有基础镜像构建前后端两个独立镜像,分别导出后封装为一个压缩包;数据库直连远程实例且不得使用数据库容器;服务器解压后无参数执行 \`./deploy.sh\`,一次性部署整个前后端项目栈。禁止自动拉取镜像、交互菜单和单独操作一个服务。
93
+ - 生产部署必须使用 \`wdyy-deployment-standard\`:在 Linux、macOS Docker Desktop 或 Windows Docker Desktop + WSL2/Linux 容器模式中,只使用已有基础镜像构建前后端两个独立 Linux 镜像,分别导出后封装为一个压缩包;数据库直连远程实例且不得使用数据库容器;解压后无参数执行 \`./deploy.sh\`,一次性部署整个前后端项目栈。禁止原生 PowerShell、Windows 容器模式、自动拉取镜像、交互菜单和单独操作一个服务。
94
94
  - 不得静默吞错、跳过验证、以兜底逻辑掩盖未决问题。
95
95
  `;
96
96
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wdyy/skills",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "企业内部开发 Skill 与项目初始化命令",
5
5
  "type": "module",
6
6
  "bin": {