@wdyy/skills 0.1.14 → 0.1.15
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.
- package/.well-known/skills/index.json +2 -2
- package/.well-known/skills/wdyy-api-standard/reference/api-his-sql.md +38 -0
- package/.well-known/skills/wdyy-deployment-standard/SKILL.md +15 -5
- package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +2 -2
- package/.well-known/skills/wdyy-deployment-standard/reference/docker-delivery-rules.md +16 -12
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.mjs +20 -7
- package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs +109 -1
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +22 -3
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +153 -9
- package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +61 -6
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.yml +4 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/env.example.template +2 -1
- package/README.md +2 -2
- package/lib/wdyy-cli.js +2 -2
- package/package.json +1 -1
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
},
|
|
8
8
|
{
|
|
9
9
|
"name": "wdyy-deployment-standard",
|
|
10
|
-
"description": "为无 CI/CD
|
|
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
15
|
"description": "根据“获取XX数据”“使用XX接口”等具体需求,定位已登记的接口 reference,并按已确认的接口契约将调用方式写入程序。Use when 需要查询或实现一个已登记接口的数据调用时。",
|
|
16
|
-
"files": ["SKILL.md", "agents/openai.yaml", "reference/api-demo.md", "reference/api-sms.md"]
|
|
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",
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
# 入参格式:JSON
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"sql": "SELECT * FROM SS_User WHERE ssusr_name = '示例用户'",
|
|
14
|
+
"params": []
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- `sql`:要执行的 SQL 查询语句。
|
|
19
|
+
- `params`:SQL 查询参数数组;示例 SQL 未使用参数,因此传入空数组。
|
|
20
|
+
|
|
21
|
+
# 返回值:JSON
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"data": [
|
|
26
|
+
{
|
|
27
|
+
"SSUSR_RowId": 20292,
|
|
28
|
+
"SSUSR_Initials": "20684",
|
|
29
|
+
"SSUSR_Name": "马俊"
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"success": true,
|
|
33
|
+
"count": 1,
|
|
34
|
+
"message": "查询执行成功"
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
# 备注:使用 `POST` 请求调用;将 SQL 查询语句放入 `sql` 字段,将对应的查询参数放入 `params` 数组。
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: wdyy-deployment-standard
|
|
3
|
-
description: 为无 CI/CD
|
|
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
|
-
|
|
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.
|
|
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
|
-
-
|
|
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
|
-
- [ ]
|
|
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: "
|
|
4
|
-
default_prompt: "Use $wdyy-deployment-standard to build a single-target linux/amd64 or linux/arm64 dual-image package
|
|
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-
|
|
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
|
|
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
|
-
|
|
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.
|
|
62
|
-
5.
|
|
63
|
-
6.
|
|
64
|
-
7.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
113
|
-
|
|
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:
|
|
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
|
|
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,
|
package/.well-known/skills/wdyy-deployment-standard/scripts/generate-deployment-files.test.mjs
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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(
|
|
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();
|
package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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('
|
|
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
|
-
|
|
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(
|
|
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
|
|
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:
|
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
|
|
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
|
|
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
|
|
93
|
+
- 生产部署必须使用 \`wdyy-deployment-standard\`:在 Linux、macOS Docker Desktop 或 Windows Docker Desktop + WSL2/Linux 容器模式中,只使用已有基础镜像构建前后端两个独立 Linux 镜像,分别导出后封装为一个压缩包;数据库直连远程实例且不得使用数据库容器;解压后无参数执行 \`./deploy.sh\`,一次性部署整个前后端项目栈。禁止原生 PowerShell、Windows 容器模式、自动拉取镜像、交互菜单和单独操作一个服务。
|
|
94
94
|
- 不得静默吞错、跳过验证、以兜底逻辑掩盖未决问题。
|
|
95
95
|
`;
|
|
96
96
|
|