@wdyy/skills 0.1.17 → 0.1.18

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,7 +7,7 @@
7
7
  },
8
8
  {
9
9
  "name": "wdyy-deployment-standard",
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 生产交付时。",
10
+ "description": "为无 CI/CD 的前后端项目生成并验证跨宿主机统一 Docker 交付:在 Linux、macOS Docker Desktop 或 Windows Docker Desktop + WSL2/Linux 容器模式中,使用本地已有基础镜像构建单一 linux/amd64 或 linux/arm64 双镜像包,支持 macOS/arm64 以仿真运行 amd64 镜像,并以固定端口、容器名、重启策略和 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
  {
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: wdyy-api-standard
3
3
  description: 根据“获取XX数据”“使用XX接口”“使用指定查询名称”等具体需求,定位已登记的接口 reference;对于 HIS SQL 查询,从远程查询目录按 name 精确匹配,并按已确认的接口契约将调用方式写入程序。Use when 需要查询或实现一个已登记接口或 HIS SQL 查询的数据调用时。
4
+ disable-model-invocation: true
4
5
  ---
5
6
 
6
7
  # 接口路由
@@ -1,19 +1,19 @@
1
1
  ---
2
2
  name: wdyy-deployment-standard
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 生产交付时。
3
+ description: 为无 CI/CD 的前后端项目生成并验证跨宿主机统一 Docker 交付:在 Linux、macOS Docker Desktop 或 Windows Docker Desktop + WSL2/Linux 容器模式中,使用本地已有基础镜像构建单一 linux/amd64 或 linux/arm64 双镜像包,支持 macOS/arm64 以仿真运行 amd64 镜像,并以固定端口、容器名、重启策略和 CST(UTC+8)时间整栈运行。Use when 规划、实现、审查或执行跨构建机平台的人工 Docker 生产交付时。
4
4
  ---
5
5
 
6
6
  # 统一 Docker 交付规范
7
7
 
8
8
  ## Overview
9
9
 
10
- 把一个项目作为一个整体交付:在 Linux、macOS Docker Desktop,或 Windows Docker Desktop + WSL2/Linux 容器模式中执行 `./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
11
 
12
- macOS/arm64 宿主机的 Docker Server 平台是 `linux/arm64`。Windows 必须在启用 Docker Desktop 集成的 WSL2 发行版和 Linux 文件系统中执行同一 Bash 脚本,并使用 Linux 容器 engine;不支持原生 PowerShell、Windows 容器模式或 `windows/*` 镜像。
12
+ macOS/arm64 宿主机的 Docker Server 通常报告 `linux/arm64`,但可以通过 `--platform linux/amd64` 仿真运行 amd64 镜像;是否兼容必须由禁止拉取的目标镜像探针、健康检查和业务验收实际证明。Windows 必须在启用 Docker Desktop 集成的 WSL2 发行版和 Linux 文件系统中执行同一 Bash 脚本,并使用 Linux 容器 engine;不支持原生 PowerShell、Windows 容器模式或 `windows/*` 镜像。
13
13
 
14
14
  ## Workflow
15
15
 
16
- 1. 读取目标项目真实的 pnpm scripts、前后端构建输出、Dockerfile、健康检查、根 `.env` 和远程数据库连接方式。
16
+ 1. 读取目标项目真实的 pnpm scripts、前后端构建输出、Dockerfile、健康检查、根 `.env` 和远程数据库连接方式。若 frontend 使用 Vite,确认只有 `serve` 校验 `FRONTEND_URL`、`FRONTEND_PORT`、`VITE_DEV_PROXY_TARGET`,生产 `build` 不依赖这些开发服务器变量。
17
17
  2. 完整读取 [统一 Docker 交付规则](reference/docker-delivery-rules.md)。
18
18
  3. 运行 `node scripts/generate-deployment-files.mjs --target <目标项目根目录> --write` 生成受版本控制文件并扩充 `.env`、`.env.example` 的部署段。既有生成文件不一致时先展示差异并等待工程师确认,确认后才使用 `--force`。
19
19
  4. 在目标项目根执行生成器 `--check`,再运行 `scripts/validate-deployment-package.mjs` 和行为测试。
@@ -25,9 +25,11 @@ macOS/arm64 宿主机的 Docker Server 平台是 `linux/arm64`。Windows 必须
25
25
  - 构建 `<PROJECT_NAME>_frontend:latest` 与 `<PROJECT_NAME>_backend:latest` 两个独立镜像。
26
26
  - 每个交付包只对应 `DOCKER_PLATFORM` 指定的 `linux/amd64` 或 `linux/arm64`;构建机自身平台可以不同,不得回退到构建机原生架构。
27
27
  - 分别导出 `frontend-image.tar`、`backend-image.tar`,但只交付一个 `<PROJECT_NAME>-docker.tar.gz`。
28
- - 构建前检查 `FRONTEND_BASE_IMAGE`、`BACKEND_BASE_IMAGE` 已存在于本地;缺失即失败,不执行 `docker pull`。
29
- - 宿主机可以是 Linux、macOS 或 Windows,但镜像和 Docker Server 必须是 `linux/amd64` `linux/arm64`;加载前读取 Server OS/架构,无法读取、Windows 容器模式或不等于 `DOCKER_PLATFORM` 时立即失败。
28
+ - 构建前检查 `FRONTEND_BASE_IMAGE`、`BACKEND_BASE_IMAGE` 已存在于本地,并使用 `--pull=never --platform "$DOCKER_PLATFORM"` 的一次性容器探针验证实际选中的目标平台子镜像架构;缺失即失败,不执行 `docker pull`,不以多平台索引的默认元数据代替实际选择结果。
29
+ - 宿主机可以是 Linux、macOS 或 Windows,镜像与 Docker Server 必须是受支持的 Linux 平台;加载前读取 Server OS/架构,无法读取或 Windows 容器模式时立即失败。仅允许 macOS 执行宿主机在 `linux/arm64` Server 上加载 `linux/amd64` 包;其他 Server/目标架构不匹配继续失败。
30
+ - macOS/arm64 加载 amd64 包后、Compose 状态变化前,必须对两个已加载镜像执行 `--pull=never --platform linux/amd64` 目标探针;失败时不得切换平台、pull、fallback 或声称兼容成功。
30
31
  - Compose 只定义 frontend、backend;数据库通过 `.env` 直连远程实例,不生成数据库容器、数据卷或迁移路径。
32
+ - backend 必须以原变量名显式接收 `BACKEND_URL`、`BACKEND_PORT`,不得映射或在镜像中重复设置应用未读取的 `HOST`、`PORT`;生成器和交付包校验器必须拒绝旧变量。
31
33
  - 服务器无参数命令整体部署;只额外允许 `stop` 和 `status`,不得独立操作单一服务。
32
34
  - 前后端直接暴露 `.env` 指定的宿主机端口;两个容器共同挂载项目根 `./logs` 到 `/app/logs`。
33
35
  - `FRONTEND_PORT` 同时控制 frontend 宿主机映射和容器 Nginx 监听;`BACKEND_PORT` 同时控制 backend 宿主机映射、Node 监听、健康检查和 frontend 内部代理,不增加重复容器端口变量。
@@ -36,6 +38,8 @@ macOS/arm64 宿主机的 Docker Server 平台是 `linux/arm64`。Windows 必须
36
38
  - frontend Nginx 使用 `.env` 的 `VITE_API_BASE_URL` 与 `BACKEND_PORT`,把同源 API 请求代理到 Compose `backend` 服务;`VITE_API_BASE_URL` 必须等于 `/${API_PREFIX}`。
37
39
  - 根 `.env` 与 `.env.example` 必须严格使用 `TZ=Asia/Shanghai`。构建后、Docker save 前实际探测两个镜像;整栈健康后实际探测两个运行容器,均必须以 24 小时格式输出 `CST +0800`。
38
40
  - `.env` 可以进入人工上传的压缩包,但必须被 `.dockerignore` 排除,不得进入镜像。
41
+ - 宿主机端口占用时先只读确认监听进程、容器和项目归属;不得自动停止或杀死进程,确认属于当前项目后再正常停止并重新整栈部署。
42
+ - 容器健康后仍须执行真实登录和核心业务查询。若出现关系或表不存在,先只读核对 schema、迁移记录和业务表;迁移与种子按 `wdyy-database-standard` 单独确认,不进入 Docker 自动部署。
39
43
 
40
44
  ## Prohibited
41
45
 
@@ -43,6 +47,8 @@ macOS/arm64 宿主机的 Docker Server 平台是 `linux/arm64`。Windows 必须
43
47
  - 不得保留发布账本、版本目录、`release.env`、交互菜单或前后端独立替换命令。
44
48
  - 不得上传文件、拉取代码、安装 Docker、固定服务器目录或使用 `source`、`eval` 执行 `.env`。
45
49
  - 不得生成 PowerShell 备用脚本、Windows 镜像或自动切换 Docker Desktop engine;Windows 原生容器模式必须明确失败。
50
+ - 不得把 amd64 仿真描述为无条件兼容,不得自动安装或启用 Rosetta、QEMU、binfmt 等仿真能力。
51
+ - 不得自动停止端口占用进程或自动执行数据库迁移、种子。
46
52
  - 不得跳过清单、镜像标签、Compose 服务范围、远程数据库配置或健康状态验证。
47
53
 
48
54
  ## Verification
@@ -51,13 +57,15 @@ macOS/arm64 宿主机的 Docker Server 平台是 `linux/arm64`。Windows 必须
51
57
  - [ ] 压缩包解压后只含 `.env`、deploy.sh、docker-compose.yml、两个镜像 tar 和 manifest.sha256。
52
58
  - [ ] 两个镜像归档各自只含预期的 `latest` 标签。
53
59
  - [ ] 单一压缩包不含 macOS AppleDouble、资源叉或扩展属性元数据。
54
- - [ ] 构建与服务器 Compose 均禁止 pull,服务器禁止 build;基础镜像、构建参数、产出镜像和 Compose 平台均与 `DOCKER_PLATFORM` 一致。
55
- - [ ] Linux Engine、macOS/arm64 Docker Desktop 与 Windows Docker Desktop + WSL2 场景使用同一包契约;Windows 原生容器模式被拒绝。
56
- - [ ] Docker Server 平台在加载镜像前通过校验,并严格等于 `DOCKER_PLATFORM`。
60
+ - [ ] 构建与服务器 Compose 均禁止 pull,服务器禁止 build;基础镜像和产出镜像均通过目标平台一次性容器探针验证,构建参数和 Compose 平台均与 `DOCKER_PLATFORM` 一致。
61
+ - [ ] Linux Engine、macOS/arm64 Docker Desktop 与 Windows Docker Desktop + WSL2 场景使用同一包契约;Windows 原生容器模式被拒绝,macOS/arm64 可通过实际探针运行 amd64 包。
62
+ - [ ] Docker Server 支持范围在加载镜像前通过校验;macOS/arm64 的 amd64 例外在加载后、Compose 前通过目标镜像探针验证,其他架构不匹配被拒绝。
57
63
  - [ ] Compose 只含 frontend、backend,并使用远程数据库环境变量和根 logs。
64
+ - [ ] backend 显式环境项使用 `BACKEND_URL`、`BACKEND_PORT` 原名,后端镜像和运行容器均不存在 `HOST`、`PORT` 旧变量。
58
65
  - [ ] Compose 的双向端口都来自 FRONTEND_PORT/BACKEND_PORT;两个固定容器名等于各自镜像 repository,并各使用 `unless-stopped`。
59
66
  - [ ] 根 `.env` 严格使用 `TZ=Asia/Shanghai`;两个镜像和两个运行容器的真实时间探测均为 24 小时制 `CST +0800`。
60
67
  - [ ] frontend 镜像不包含 `.env`,同源 API 代理能够把真实页面查询转发到 backend。
68
+ - [ ] Vite 生产构建不要求开发服务器变量;端口占用和数据库缺表均按只读诊断、确认后处理的边界执行。
61
69
  - [ ] 无参数部署、stop、status 的实际行为及失败传播通过测试。
62
70
 
63
71
  任一步失败必须指出具体阶段并返回非零;不得静默跳过、伪造成功或删除上一份成功交付包。
@@ -1,4 +1,4 @@
1
1
  interface:
2
2
  display_name: "wdyy-deployment-standard"
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."
3
+ short_description: "跨 Linux、Mac 与 Windows WSL2 生成并验证单架构双镜像交付"
4
+ default_prompt: "Use $wdyy-deployment-standard to build and run one linux/amd64 or linux/arm64 dual-image package on Linux engines, macOS Docker Desktop including verified amd64 emulation on Apple silicon, or Windows Docker Desktop through WSL2, with deterministic ports, names, restart policy, and CST UTC+8 time."
@@ -46,7 +46,7 @@ TZ=Asia/Shanghai
46
46
  - `.env` 权限必须为 `0600` 或只读 `0400`。
47
47
  - 按数据解析 `.env`;不得 `source`、`eval` 或执行其中内容。
48
48
  - `DB_URL` 指向远程数据库主机或地址;部署资产不得把它解释为 Docker 服务名并创建数据库容器。
49
- - `DOCKER_PLATFORM` 必须为 `linux/amd64` 或 `linux/arm64`,表示当前单一交付包的镜像目标平台,并与运行时 Docker Server 平台一致;不得根据 Linux、macOS、Windows 宿主机或 CPU 自动改写,不得使用 `darwin/*` 或 `windows/*`。
49
+ - `DOCKER_PLATFORM` 必须为 `linux/amd64` 或 `linux/arm64`,只表示当前单一交付包的镜像和 Compose 目标平台;不得根据 Linux、macOS、Windows 宿主机或 CPU 自动改写,不得使用 `darwin/*` 或 `windows/*`。macOS/arm64 Docker Desktop 可以在 `linux/arm64` Server 上通过仿真运行 `linux/amd64`,但必须由目标镜像探针、健康检查和业务验收实际证明。
50
50
  - `TZ` 必须严格等于 `Asia/Shanghai`,使两个容器统一使用 24 小时制的 CST(UTC+8);其他合法 IANA 时区也不属于本交付合同。
51
51
  - `API_PREFIX` 只允许一个安全路径段,`VITE_API_BASE_URL` 必须严格等于 `/${API_PREFIX}`;生成器在两个键都缺失时加入标准值,存在缺失或冲突时明确失败。
52
52
  - `.env.example` 可保留空数据库凭据,但真实 `.env` 构建时必须完整。
@@ -55,11 +55,13 @@ TZ=Asia/Shanghai
55
55
 
56
56
  基础镜像引用允许使用标签,但目标架构版本必须在构建机本地预先存在。构建命令可以在 Linux、macOS Docker Desktop,或 Windows Docker Desktop + WSL2/Linux 容器模式中执行,也可以与目标架构不同;Windows 必须从启用 Docker Desktop 集成的 WSL2 发行版和 Linux 文件系统执行,不支持原生 PowerShell 或 Windows 容器模式。相同标签被人工替换后可能对应不同内容,构建过程不得访问镜像仓库或回退到构建机原生架构。
57
57
 
58
+ 若 frontend 使用 Vite,配置必须通过 `defineConfig(({ command, mode }) => ...)` 或等价方式区分命令:仅 `command === 'serve'` 时读取、校验并配置 `FRONTEND_URL`、`FRONTEND_PORT`、`VITE_DEV_PROXY_TARGET`;`command === 'build'` 时只读取 `VITE_API_BASE_URL` 等真实构建配置。生产构建上下文不包含根 `.env` 时仍必须通过 frontend lint、测试和 `pnpm build`,不得为了构建复制根 `.env`。
59
+
58
60
  `./deploy.sh build` 必须按顺序:
59
61
 
60
62
  1. 验证根 `.env`、Dockerfile、Compose 和 `.dockerignore`。
61
- 2. 使用平台感知的 `docker image inspect --platform "$DOCKER_PLATFORM"` 确认两个基础镜像的目标架构版本存在。
62
- 3. 校验本地基础镜像平台与 `DOCKER_PLATFORM` 一致,使用 `docker build --pull=false --platform "$DOCKER_PLATFORM"` 构建 `<PROJECT_NAME>_frontend:latest` 与 `<PROJECT_NAME>_backend:latest`,并校验产出镜像平台。
63
+ 2. 保留普通 `docker image inspect <image>` 作为标签存在性检查,再通过 `docker run --rm --pull=never --platform "$DOCKER_PLATFORM" --entrypoint uname <image> -m` 实际选择目标平台子镜像并验证机器架构:`linux/amd64` 必须输出 `x86_64`,`linux/arm64` 必须输出 `aarch64` 或 `arm64`。目标子镜像缺失时必须失败,禁止 pull。
64
+ 3. 使用 `docker build --pull=false --platform "$DOCKER_PLATFORM"` 构建 `<PROJECT_NAME>_frontend:latest` 与 `<PROJECT_NAME>_backend:latest`,并用同一目标平台一次性容器探针校验产出镜像。Docker Desktop 的 containerd 镜像存储可让一个标签指向多平台索引,`docker image inspect --platform` 可能返回索引默认平台或不完整元数据,不得用它判定目标子镜像是否存在或架构是否匹配。
63
65
  4. 对两个产出镜像分别使用 `docker run --rm --pull=never --platform "$DOCKER_PLATFORM"` 探测 `date '+%H %Z %z'`,要求小时为 `00`–`23`、时区为 `CST +0800`。
64
66
  5. 分别执行 Docker save,生成 `frontend-image.tar`、`backend-image.tar`。
65
67
  6. 复制根 `.env`、deploy.sh、docker-compose.yml,生成覆盖全部包内文件的 `manifest.sha256`。
@@ -87,8 +89,9 @@ manifest.sha256
87
89
 
88
90
  工程师人工复制并解压单一压缩包。部署根由解压后的 deploy.sh 物理路径确定。Windows Docker Desktop 场景必须把包放在 WSL2 Linux 文件系统中;不能满足 `.env` owner-only 权限的 Windows 挂载目录必须失败。复制用的 `.tar.gz` 必须放在部署根外,或在解压后移出部署根;执行 `./deploy.sh` 时,部署根只允许保留压缩包契约中的 6 个文件和运行后生成的 `logs/` 目录。
89
91
 
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`。
91
- - 加载后必须再次校验两个镜像与 `DOCKER_PLATFORM` 一致;Compose 的两个服务也必须显式声明同一平台。
92
+ - `./deploy.sh`:校验 `.env` 权限和内容、清单、两个镜像 tar 及预期标签;通过 `docker version` 读取 Docker Server OS/架构,无法读取、Windows 容器模式或不受支持的平台立即失败;检查固定容器名不存在或属于当前 Compose 项目和对应服务;校验通过后才加载两个镜像,再使用一次 `docker compose up -d --force-recreate --no-build --pull never --wait frontend backend` 整体部署并等待健康,最后在两个运行容器中验证 24 小时制 `CST +0800`。
93
+ - Docker Server 架构通常必须等于 `DOCKER_PLATFORM`。唯一例外是脚本实际运行于 macOS、Docker Server 为 `linux/arm64`、交付包为 `linux/amd64`;该场景允许加载镜像,但加载后必须用禁止拉取的 `--platform linux/amd64` 一次性容器探针验证两个镜像均实际输出 `x86_64`,再进入 Compose。探针失败必须明确终止,不切换平台、不 pull、不 fallback。
94
+ - 加载后必须用禁止拉取的目标平台一次性容器探针再次校验两个镜像与 `DOCKER_PLATFORM` 一致;Compose 的两个服务也必须显式声明同一平台。amd64 仿真可能更慢或存在二进制限制,不得仅凭 Server 平台或静态镜像元数据声称兼容。
92
95
  - `./deploy.sh stop`:同时停止 frontend、backend,保留容器、镜像、交付文件和日志。
93
96
  - `./deploy.sh status`:输出当前 Compose 项目的服务状态,不改变 Docker 状态。
94
97
 
@@ -103,12 +106,16 @@ manifest.sha256
103
106
  - frontend Nginx 把 `${VITE_API_BASE_URL}/...` 原样代理到 `http://backend:${BACKEND_PORT}`;不得硬编码服务器地址或后端端口。
104
107
  - 两个服务均使用根 `.env`,显式接收同一 `TZ`,并挂载 `./logs:/app/logs`。
105
108
  - backend 直接读取 DB_URL、DB_PORT、DB_USER、DB_PASSWORD、DB_NAME、DB_SCHEMA。
109
+ - backend 显式接收 `${BACKEND_URL:?BACKEND_URL is required}` 与 `${BACKEND_PORT:?BACKEND_PORT is required}`,变量名必须与应用契约一致;Compose 和后端镜像均不得改写或重复设置 `HOST`、`PORT`。
106
110
  - Compose 不得定义数据库服务、数据库镜像、数据库 volume、数据库端口映射或迁移任务。
107
111
  - 前端 Nginx 日志写入 `/app/logs/frontend-access.log`、`/app/logs/frontend-error.log`;后端遵循 wdyy-logging-standard;部署操作写入 `./logs/deploy.log`。
108
112
 
113
+ 容器健康只证明健康检查端点,不证明数据库业务表已初始化。部署后必须执行真实登录、核心查询和详情读取;出现 `relation "..." does not exist` 或等价缺表错误时,先只读核对远程数据库目标 schema、迁移记录、业务表和必要种子。需要写入时使用 `wdyy-database-standard`,展示迁移与种子范围并等待工程师确认;部署脚本不得自动执行。
114
+
109
115
  ## 7. 失败边界
110
116
 
111
- - 目标架构基础镜像缺失、Docker Server 平台无法读取或不匹配、Windows 容器模式、同名容器所有权冲突、TZ 不等于 `Asia/Shanghai`、镜像或运行容器时间不是 24 小时制 `CST +0800`、清单错误、镜像标签错误、远程数据库配置不完整或任一服务不健康时明确失败。
117
+ - 目标架构基础镜像缺失、Docker Server 平台无法读取或不受支持、非 macOS 已确认例外的 Server/目标架构不匹配、macOS amd64 目标镜像探针失败、Windows 容器模式、同名容器所有权冲突、TZ 不等于 `Asia/Shanghai`、镜像或运行容器时间不是 24 小时制 `CST +0800`、清单错误、镜像标签错误、远程数据库配置不完整或任一服务不健康时明确失败。
112
118
  - 禁止 `docker pull`、服务器 `docker build`、镜像 fallback、自动回滚、自动安装和静默跳过。
113
119
  - 脚本只操作当前 `PROJECT_NAME` 的 Compose 项目和 frontend、backend 服务,不清理其他项目资源。
114
120
  - 旧部署资产由工程师根据实际状态单独迁移;新脚本不得猜测并删除。
121
+ - 宿主机端口绑定失败时,先用只读命令确认监听进程、容器和项目归属;不得自动 kill、stop 或删除。仅在确认属于当前项目后正常停止旧进程,再重新执行整栈部署。
@@ -84,6 +84,9 @@ test('生成统一 Docker 交付文件并通过确定性检查', async () => {
84
84
  assert.equal((compose.match(/restart: unless-stopped/g) ?? []).length, 2);
85
85
  assert.match(compose, /"\$\{DOCKER_BIND_IP\}:\$\{FRONTEND_PORT\}:\$\{FRONTEND_PORT\}"/);
86
86
  assert.match(compose, /"\$\{DOCKER_BIND_IP\}:\$\{BACKEND_PORT\}:\$\{BACKEND_PORT\}"/);
87
+ assert.match(compose, /BACKEND_URL: "\$\{BACKEND_URL:\?BACKEND_URL is required\}"/);
88
+ assert.equal((compose.match(/BACKEND_PORT: "\$\{BACKEND_PORT:\?BACKEND_PORT is required\}"/g) ?? []).length, 2);
89
+ assert.doesNotMatch(compose, /^\s+(?:HOST|PORT):/m);
87
90
  const nginx = await readFile(join(root, 'scripts/deployment/frontend-container.conf.template'), 'utf8');
88
91
  assert.match(nginx, /location \$\{VITE_API_BASE_URL\}\/ \{/);
89
92
  assert.match(nginx, /proxy_pass http:\/\/backend:\$\{BACKEND_PORT\};/);
@@ -95,6 +98,7 @@ test('生成统一 Docker 交付文件并通过确定性检查', async () => {
95
98
  assert.match(backendDockerfile, /WORKDIR \/app\n/);
96
99
  assert.match(backendDockerfile, /COPY --from=build \/runtime \.\/src\/backend\//);
97
100
  assert.match(backendDockerfile, /CMD \["node", "src\/backend\/dist\/main\.js"\]/);
101
+ assert.doesNotMatch(backendDockerfile, /^ENV\s+(?:HOST|PORT)=/m);
98
102
  const check = await invoke(root, '--check');
99
103
  assert.match(check.stdout, /verified unified Docker delivery files/);
100
104
  assert.match(await readFile(join(root, 'deploy.sh'), 'utf8'), /env COPYFILE_DISABLE=1 tar --no-xattrs -czf "\$candidate"/);
@@ -81,12 +81,15 @@ for (const [pattern, message] of [
81
81
  [/"\$1" == status/, 'Missing status command'],
82
82
  [/docker image inspect "\$FRONTEND_BASE_IMAGE"/, 'Frontend local base image must be checked'],
83
83
  [/docker image inspect "\$BACKEND_BASE_IMAGE"/, 'Backend local base image must be checked'],
84
+ [/docker run --rm --pull=never --platform "\$DOCKER_PLATFORM" --entrypoint uname "\$image" -m/, 'Image platforms must be verified by selecting the local target-platform child without pulling'],
84
85
  [/docker build --pull=false --platform "\$DOCKER_PLATFORM"/g, 'Docker builds must use the configured target platform and disable pull'],
85
86
  [/docker save -o "\$package_root\/frontend-image\.tar"/, 'Frontend image must be exported separately'],
86
87
  [/docker save -o "\$package_root\/backend-image\.tar"/, 'Backend image must be exported separately'],
87
88
  [/env COPYFILE_DISABLE=1 tar --no-xattrs -czf "\$candidate"/, 'Delivery archive must exclude macOS metadata and extended attributes'],
88
89
  [/docker version --format '\{\{\.Server\.Os\}\}\/\{\{\.Server\.Arch\}\}'/, 'Deploy must read the Docker Server platform'],
89
90
  [/Windows container mode is unsupported; switch Docker Desktop to Linux containers/, 'Deploy must reject Windows container mode explicitly'],
91
+ [/\[\[ "\$EXECUTION_KERNEL" == Darwin && "\$actual" == linux\/arm64 && "\$DOCKER_PLATFORM" == linux\/amd64 \]\]/, 'Deploy must allow only the confirmed macOS arm64-to-amd64 emulation exception'],
92
+ [/only macOS with linux\/arm64 Docker Server may run linux\/amd64 through verified emulation/, 'Deploy must reject every other Docker Server target mismatch explicitly'],
90
93
  [/assert_stack_container_ownership/, 'Deploy must validate fixed container name ownership'],
91
94
  [/assert_image_cst_time "\$frontend_image"/, 'Frontend built image CST time must be checked'],
92
95
  [/assert_image_cst_time "\$backend_image"/, 'Backend built image CST time must be checked'],
@@ -97,14 +100,19 @@ for (const [pattern, message] of [
97
100
  [/compose ps frontend backend/, 'Status must inspect both services'],
98
101
  ]) assert(pattern.test(deploy), message);
99
102
  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');
103
+ assert(!/docker image inspect --platform/.test(deploy), 'Multi-platform image index metadata must not be used for target-platform validation');
100
104
  const deployStackIndex = deploy.indexOf('deploy_stack() {');
101
105
  const serverPlatformCheckIndex = deploy.indexOf(' assert_server_platform\n', deployStackIndex);
102
106
  const ownershipCheckIndex = deploy.indexOf(' assert_stack_container_ownership\n', deployStackIndex);
103
107
  const firstImageLoadIndex = deploy.indexOf(' docker load -i ', deployStackIndex);
108
+ const secondImageLoadIndex = deploy.indexOf(' docker load -i "$DEPLOY_ROOT/backend-image.tar"', firstImageLoadIndex);
109
+ const loadedFrontendPlatformCheckIndex = deploy.indexOf(' assert_image_platform "${PROJECT_NAME}_frontend:latest"', secondImageLoadIndex);
110
+ const loadedBackendPlatformCheckIndex = deploy.indexOf(' assert_image_platform "${PROJECT_NAME}_backend:latest"', loadedFrontendPlatformCheckIndex);
104
111
  const composeUpIndex = deploy.indexOf(' compose up -d --force-recreate --no-build --pull never --wait frontend backend', deployStackIndex);
105
112
  const runningTimeCheckIndex = deploy.indexOf(' assert_container_cst_time "${PROJECT_NAME}_frontend"', deployStackIndex);
106
113
  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');
114
+ assert(secondImageLoadIndex > firstImageLoadIndex && loadedFrontendPlatformCheckIndex > secondImageLoadIndex && loadedBackendPlatformCheckIndex > loadedFrontendPlatformCheckIndex && composeUpIndex > loadedBackendPlatformCheckIndex, 'Both loaded target images must be executed without pulling before Compose changes state');
115
+ assert(runningTimeCheckIndex > composeUpIndex, 'Running container CST time must be checked after Compose health wait');
108
116
  const frontendImageTimeIndex = deploy.indexOf(' assert_image_cst_time "$frontend_image"');
109
117
  const firstImageSaveIndex = deploy.indexOf(' docker save -o "$package_root/frontend-image.tar"');
110
118
  assert(frontendImageTimeIndex >= 0 && firstImageSaveIndex > frontendImageTimeIndex, 'Built image CST time must be checked before Docker save');
@@ -130,6 +138,9 @@ assert(compose.includes('"${DOCKER_BIND_IP}:${FRONTEND_PORT}:${FRONTEND_PORT}"')
130
138
  assert(compose.includes('"${DOCKER_BIND_IP}:${BACKEND_PORT}:${BACKEND_PORT}"'), 'Backend port mapping is invalid');
131
139
  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');
132
140
  assert(compose.includes('BACKEND_PORT: "${BACKEND_PORT:?BACKEND_PORT is required}"'), 'Backend port is not passed to Nginx');
141
+ assert(compose.includes('BACKEND_URL: "${BACKEND_URL:?BACKEND_URL is required}"'), 'Backend URL is not passed to the backend with its contract name');
142
+ assert((compose.match(/BACKEND_PORT: "\$\{BACKEND_PORT:\?BACKEND_PORT is required\}"/g) ?? []).length === 2, 'BACKEND_PORT must be passed to both frontend Nginx and backend with its contract name');
143
+ assert(!/^\s+(?:HOST|PORT):/m.test(compose), 'Legacy backend HOST or PORT environment mapping is forbidden');
133
144
  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');
134
145
  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');
135
146
 
@@ -157,6 +168,11 @@ async function assertImageArchive(name, expectedTag) {
157
168
  const { stdout: configText } = await run('tar', ['-xOf', archive, parsed[0].Config], { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024 });
158
169
  const config = JSON.parse(configText);
159
170
  assert(`${config.os}/${config.architecture}` === env.get('DOCKER_PLATFORM'), `${name} platform must equal DOCKER_PLATFORM`);
171
+ if (name === 'backend-image.tar') {
172
+ const imageEnv = config.config?.Env ?? [];
173
+ assert(Array.isArray(imageEnv), 'backend-image.tar environment metadata is invalid');
174
+ assert(!imageEnv.some((entry) => /^(?:HOST|PORT)=/.test(entry)), 'backend-image.tar must not contain legacy HOST or PORT environment variables');
175
+ }
160
176
  }
161
177
  await assertImageArchive('frontend-image.tar', `${env.get('PROJECT_NAME')}_frontend:latest`);
162
178
  await assertImageArchive('backend-image.tar', `${env.get('PROJECT_NAME')}_backend:latest`);
@@ -92,6 +92,15 @@ if (args[0] === 'build') {
92
92
  process.exit(0);
93
93
  }
94
94
  if (args[0] === 'run') {
95
+ const entrypointIndex = args.indexOf('--entrypoint');
96
+ if (entrypointIndex >= 0 && args[entrypointIndex + 1] === 'uname') {
97
+ const image = args[entrypointIndex + 2];
98
+ if (process.env.MISSING_PLATFORM_IMAGE === image) process.exit(1);
99
+ const configuredPlatform = process.env.IMAGE_PLATFORM || 'linux/amd64';
100
+ const machine = process.env.IMAGE_MACHINE || (configuredPlatform === 'linux/arm64' ? 'aarch64' : 'x86_64');
101
+ process.stdout.write(machine + '\\n');
102
+ process.exit(0);
103
+ }
95
104
  const image = args[args.indexOf('-c') - 1];
96
105
  if (process.env.FAIL_IMAGE_TIME && image.includes(process.env.FAIL_IMAGE_TIME)) process.exit(1);
97
106
  process.stdout.write((process.env.IMAGE_TIME || '19 CST +0800') + '\\n');
@@ -205,6 +214,18 @@ async function rewriteImageArchivePlatform(archive, platform) {
205
214
  await run('tar', ['-cf', archive, '-C', temporary, 'manifest.json', configName]);
206
215
  }
207
216
 
217
+ async function rewriteBackendImageEnv(archive, imageEnv) {
218
+ const temporary = await mkdtemp(join(tmpdir(), 'wdyy-image-env-'));
219
+ created.push(temporary);
220
+ await run('tar', ['-xf', archive, '-C', temporary]);
221
+ const manifest = JSON.parse(await readFile(join(temporary, 'manifest.json'), 'utf8'));
222
+ const configName = manifest[0].Config;
223
+ const config = JSON.parse(await readFile(join(temporary, configName), 'utf8'));
224
+ config.config = { ...(config.config ?? {}), Env: imageEnv };
225
+ await writeFile(join(temporary, configName), `${JSON.stringify(config)}\n`);
226
+ await run('tar', ['-cf', archive, '-C', temporary, 'manifest.json', configName]);
227
+ }
228
+
208
229
  test.after(async () => {
209
230
  await Promise.all(created.map((path) => rm(path, { recursive: true, force: true })));
210
231
  });
@@ -228,12 +249,16 @@ test('build 使用本地基础镜像并生成一个含双镜像归档的压缩
228
249
  assert.match(compose, /container_name: "\$\{PROJECT_NAME:\?PROJECT_NAME is required\}_frontend"/);
229
250
  assert.match(compose, /container_name: "\$\{PROJECT_NAME:\?PROJECT_NAME is required\}_backend"/);
230
251
  assert.equal((compose.match(/restart: unless-stopped/g) ?? []).length, 2);
252
+ assert.match(compose, /BACKEND_URL: "\$\{BACKEND_URL:\?BACKEND_URL is required\}"/);
253
+ assert.equal((compose.match(/BACKEND_PORT: "\$\{BACKEND_PORT:\?BACKEND_PORT is required\}"/g) ?? []).length, 2);
254
+ assert.doesNotMatch(compose, /^\s+(?:HOST|PORT):/m);
231
255
  const log = await readFile(item.log, 'utf8');
232
256
  assert.match(log, /image inspect nginx:stable/);
233
257
  assert.match(log, /image inspect node:24-bookworm-slim/);
234
258
  assert.equal((log.match(/build --pull=false --platform linux\/amd64/g) ?? []).length, 2);
235
259
  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);
260
+ assert.equal((log.match(/^run --rm --pull=never --platform linux\/amd64 --entrypoint uname /gm) ?? []).length, 4);
261
+ assert.equal((log.match(/^run --rm --pull=never --platform linux\/amd64 --entrypoint sh /gm) ?? []).length, 2);
237
262
  assert.doesNotMatch(log, /(^|\s)pull(\s|$)/m);
238
263
  assert.match(log, /save -o .*frontend-image\.tar example_project_frontend:latest/);
239
264
  assert.match(log, /save -o .*backend-image\.tar example_project_backend:latest/);
@@ -241,12 +266,29 @@ test('build 使用本地基础镜像并生成一个含双镜像归档的压缩
241
266
 
242
267
  test('基础镜像平台不匹配时在 build 前失败', async () => {
243
268
  const item = await fixture();
244
- await assert.rejects(buildProject(item, { IMAGE_PLATFORM: 'linux/arm64' }), /image platform mismatch for nginx:stable: expected linux\/amd64, got linux\/arm64/);
269
+ await assert.rejects(buildProject(item, { IMAGE_MACHINE: 'aarch64' }), /image platform mismatch for nginx:stable: expected linux\/amd64 \(x86_64\), got aarch64/);
245
270
  const log = await readFile(item.log, 'utf8');
246
271
  assert.doesNotMatch(log, /^build /m);
247
272
  assert.equal((await readdir(join(item.root, 'deploy'))).length, 0);
248
273
  });
249
274
 
275
+ test('多平台索引默认元数据不同但目标子镜像可运行时继续构建', async () => {
276
+ const item = await fixture();
277
+ const result = await buildProject(item, { INSPECT_PLATFORM: 'linux/arm64', IMAGE_PLATFORM: 'linux/amd64', IMAGE_MACHINE: 'x86_64' });
278
+ assert.match(result.stdout, /delivery archive created/);
279
+ const log = await readFile(item.log, 'utf8');
280
+ assert.doesNotMatch(log, /image inspect --platform/);
281
+ assert.match(log, /^run --rm --pull=never --platform linux\/amd64 --entrypoint uname nginx:stable -m$/m);
282
+ });
283
+
284
+ test('目标平台子镜像缺失时在 build 前失败且不拉取', async () => {
285
+ const item = await fixture();
286
+ await assert.rejects(buildProject(item, { MISSING_PLATFORM_IMAGE: 'nginx:stable' }), /image platform is unavailable for nginx:stable: linux\/amd64/);
287
+ const log = await readFile(item.log, 'utf8');
288
+ assert.doesNotMatch(log, /^build /m);
289
+ assert.doesNotMatch(log, /(^|\s)pull(\s|$)/m);
290
+ });
291
+
250
292
  test('macOS arm64 与 Windows WSL2 使用 Linux engine 生成并部署单架构交付包', async () => {
251
293
  const item = await fixture();
252
294
  await setTargetPlatform(item.root, 'linux/arm64');
@@ -322,6 +364,44 @@ test('服务器无参数执行时从脚本目录整体加载并部署前后端',
322
364
  assert.match(await readFile(join(extracted, 'logs/deploy.log'), 'utf8'), /CST \+0800 operation=deploy result=success/);
323
365
  });
324
366
 
367
+ test('macOS arm64 Docker Desktop 可加载并运行 linux/amd64 交付包', async () => {
368
+ const item = await fixture();
369
+ await buildProject(item);
370
+ const extracted = await extractArchive(item);
371
+ await writeFile(item.log, '');
372
+ await run(join(extracted, 'deploy.sh'), [], {
373
+ env: { ...item.environment, HOST_KERNEL: 'Darwin', SERVER_PLATFORM: 'linux/arm64', IMAGE_PLATFORM: 'linux/amd64', IMAGE_MACHINE: 'x86_64' },
374
+ });
375
+ const log = await readFile(item.log, 'utf8');
376
+ const secondLoadIndex = log.lastIndexOf('load -i ');
377
+ const frontendProbeIndex = log.indexOf('run --rm --pull=never --platform linux/amd64 --entrypoint uname example_project_frontend:latest -m', secondLoadIndex);
378
+ const backendProbeIndex = log.indexOf('run --rm --pull=never --platform linux/amd64 --entrypoint uname example_project_backend:latest -m', frontendProbeIndex);
379
+ const composeIndex = log.indexOf('compose --env-file ', backendProbeIndex);
380
+ assert(secondLoadIndex >= 0 && frontendProbeIndex > secondLoadIndex && backendProbeIndex > frontendProbeIndex && composeIndex > backendProbeIndex);
381
+ assert.match(log, /compose .* up -d --force-recreate --no-build --pull never --wait frontend backend/);
382
+ assert.doesNotMatch(log, /(^|\s)pull(\s|$)/m);
383
+ });
384
+
385
+ test('macOS arm64 的 amd64 目标镜像探针失败时不启动 Compose', async () => {
386
+ const item = await fixture();
387
+ await buildProject(item);
388
+ const extracted = await extractArchive(item);
389
+ await writeFile(item.log, '');
390
+ await assert.rejects(run(join(extracted, 'deploy.sh'), [], {
391
+ env: {
392
+ ...item.environment,
393
+ HOST_KERNEL: 'Darwin',
394
+ SERVER_PLATFORM: 'linux/arm64',
395
+ IMAGE_PLATFORM: 'linux/amd64',
396
+ MISSING_PLATFORM_IMAGE: 'example_project_frontend:latest',
397
+ },
398
+ }), /image platform is unavailable for example_project_frontend:latest: linux\/amd64/);
399
+ const log = await readFile(item.log, 'utf8');
400
+ assert.equal((log.match(/^load -i /gm) ?? []).length, 2);
401
+ assert.doesNotMatch(log, /^compose .* up /m);
402
+ assert.doesNotMatch(log, /(^|\s)pull(\s|$)/m);
403
+ });
404
+
325
405
  test('服务器平台无法读取、不受支持或不匹配时在加载镜像前失败', async () => {
326
406
  const item = await fixture();
327
407
  await buildProject(item);
@@ -329,9 +409,14 @@ test('服务器平台无法读取、不受支持或不匹配时在加载镜像
329
409
  [{ FAIL_SERVER_PLATFORM_READ: '1' }, /Docker Server platform is unavailable/],
330
410
  [{ SERVER_PLATFORM: 'windows/amd64' }, /Windows container mode is unsupported; switch Docker Desktop to Linux containers/],
331
411
  [{ SERVER_PLATFORM: 'linux/arm64' }, /Docker Server platform mismatch: expected linux\/amd64, got linux\/arm64/],
412
+ [{ HOST_KERNEL: 'Darwin', SERVER_PLATFORM: 'linux/amd64', IMAGE_PLATFORM: 'linux/arm64' }, /Docker Server platform mismatch: expected linux\/arm64, got linux\/amd64/],
332
413
  ];
333
414
  for (const [extraEnvironment, expectedError] of cases) {
334
415
  const extracted = await extractArchive(item);
416
+ if (extraEnvironment.IMAGE_PLATFORM === 'linux/arm64') {
417
+ await setTargetPlatform(extracted, 'linux/arm64');
418
+ await rewriteManifest(extracted);
419
+ }
335
420
  await writeFile(item.log, '');
336
421
  await assert.rejects(run(join(extracted, 'deploy.sh'), [], { env: { ...item.environment, ...extraEnvironment } }), expectedError);
337
422
  const log = await readFile(item.log, 'utf8');
@@ -477,6 +562,26 @@ test('validator 拒绝容器名、重启策略或端口单一来源被破坏', a
477
562
  await assert.rejects(run(process.execPath, [validator, portPackage]), /Frontend port mapping is invalid/);
478
563
  });
479
564
 
565
+ test('validator 拒绝后端监听变量缺失或映射为旧名称', async () => {
566
+ const item = await fixture();
567
+ await buildProject(item);
568
+
569
+ const missingUrlPackage = await extractArchive(item);
570
+ let compose = await readFile(join(missingUrlPackage, 'docker-compose.yml'), 'utf8');
571
+ await writeFile(join(missingUrlPackage, 'docker-compose.yml'), compose.replace(' BACKEND_URL: "${BACKEND_URL:?BACKEND_URL is required}"\n', ''));
572
+ await rewriteManifest(missingUrlPackage);
573
+ await assert.rejects(run(process.execPath, [validator, missingUrlPackage]), /Backend URL is not passed to the backend with its contract name/);
574
+
575
+ const legacyPackage = await extractArchive(item);
576
+ compose = await readFile(join(legacyPackage, 'docker-compose.yml'), 'utf8');
577
+ compose = compose
578
+ .replace(' BACKEND_URL: "${BACKEND_URL:?BACKEND_URL is required}"', ' HOST: "${BACKEND_URL}"')
579
+ .replace(' BACKEND_PORT: "${BACKEND_PORT:?BACKEND_PORT is required}"\n TZ:', ' PORT: "${BACKEND_PORT}"\n TZ:');
580
+ await writeFile(join(legacyPackage, 'docker-compose.yml'), compose);
581
+ await rewriteManifest(legacyPackage);
582
+ await assert.rejects(run(process.execPath, [validator, legacyPackage]), /Backend URL is not passed|BACKEND_PORT must be passed|Legacy backend HOST or PORT/);
583
+ });
584
+
480
585
  test('validator 拒绝冲突的 API 路径和缺失的代理注入', async () => {
481
586
  const item = await fixture();
482
587
  await buildProject(item);
@@ -513,6 +618,15 @@ test('validator 拒绝镜像归档平台与 DOCKER_PLATFORM 不一致', async ()
513
618
  await assert.rejects(run(process.execPath, [validator, extracted]), /backend-image\.tar platform must equal DOCKER_PLATFORM/);
514
619
  });
515
620
 
621
+ test('validator 拒绝后端镜像包含旧 HOST 或 PORT 环境变量', async () => {
622
+ const item = await fixture();
623
+ await buildProject(item);
624
+ const extracted = await extractArchive(item);
625
+ await rewriteBackendImageEnv(join(extracted, 'backend-image.tar'), ['NODE_ENV=production', 'PORT=3000']);
626
+ await rewriteManifest(extracted);
627
+ await assert.rejects(run(process.execPath, [validator, extracted]), /backend-image\.tar must not contain legacy HOST or PORT environment variables/);
628
+ });
629
+
516
630
  test('build 在任何 Docker 操作前拒绝环境文件命令语法', async () => {
517
631
  const item = await fixture();
518
632
  await writeFile(join(item.root, '.env'), envText.replace('DB_PASSWORD=secret', 'DB_PASSWORD=$(id)'), { mode: 0o600 });
@@ -10,7 +10,6 @@ FROM ${BACKEND_BASE_IMAGE} AS runtime
10
10
  WORKDIR /app
11
11
  ARG BACKEND_PORT
12
12
  ENV NODE_ENV=production
13
- ENV PORT=${BACKEND_PORT}
14
13
  LABEL org.opencontainers.image.title="wdyy-backend"
15
14
  COPY --from=build /runtime ./src/backend/
16
15
  EXPOSE ${BACKEND_PORT}
@@ -8,6 +8,7 @@ STAGING_ROOT=
8
8
  DEPLOY_ROOT=
9
9
  OPERATION=
10
10
  CHECKSUM_TOOL=
11
+ EXECUTION_KERNEL=
11
12
 
12
13
  fail() {
13
14
  echo "$*" >&2
@@ -63,6 +64,7 @@ assert_execution_host() {
63
64
  if [[ "$kernel" == Linux ]] && { [[ -n "${WSL_DISTRO_NAME:-}" ]] || grep -qi microsoft /proc/sys/kernel/osrelease 2>/dev/null; }; then
64
65
  [[ ! "$SCRIPT_DIR" =~ ^/mnt/[A-Za-z](/|$) ]] || fail "Windows Docker Desktop execution must use the WSL2 Linux filesystem, not $SCRIPT_DIR"
65
66
  fi
67
+ EXECUTION_KERNEL="$kernel"
66
68
  }
67
69
 
68
70
  reset_configuration() {
@@ -140,9 +142,19 @@ validate_configuration() {
140
142
  }
141
143
 
142
144
  assert_image_platform() {
143
- local image="$1" actual
144
- actual="$(docker image inspect --platform "$DOCKER_PLATFORM" --format '{{.Os}}/{{.Architecture}}' "$image" 2>/dev/null)" || fail "image platform is unavailable for $image: $DOCKER_PLATFORM"
145
- [[ "$actual" == "$DOCKER_PLATFORM" ]] || fail "image platform mismatch for $image: expected $DOCKER_PLATFORM, got $actual"
145
+ local image="$1" actual expected
146
+ actual="$(docker run --rm --pull=never --platform "$DOCKER_PLATFORM" --entrypoint uname "$image" -m 2>/dev/null)" || fail "image platform is unavailable for $image: $DOCKER_PLATFORM"
147
+ actual="${actual%$'\r'}"
148
+ case "$DOCKER_PLATFORM" in
149
+ linux/amd64)
150
+ expected=x86_64
151
+ [[ "$actual" == "$expected" ]] || fail "image platform mismatch for $image: expected $DOCKER_PLATFORM ($expected), got ${actual:-empty}"
152
+ ;;
153
+ linux/arm64)
154
+ expected='aarch64 or arm64'
155
+ [[ "$actual" == aarch64 || "$actual" == arm64 ]] || fail "image platform mismatch for $image: expected $DOCKER_PLATFORM ($expected), got ${actual:-empty}"
156
+ ;;
157
+ esac
146
158
  }
147
159
 
148
160
  assert_server_platform() {
@@ -151,7 +163,11 @@ assert_server_platform() {
151
163
  [[ -n "$actual" ]] || fail "Docker Server platform is empty"
152
164
  [[ "$actual" != windows/* ]] || fail "Windows container mode is unsupported; switch Docker Desktop to Linux containers"
153
165
  [[ "$actual" =~ ^linux/(amd64|arm64)$ ]] || fail "unsupported Docker Server platform: $actual"
154
- [[ "$actual" == "$DOCKER_PLATFORM" ]] || fail "Docker Server platform mismatch: expected $DOCKER_PLATFORM, got $actual"
166
+ [[ "$actual" == "$DOCKER_PLATFORM" ]] && return 0
167
+ if [[ "$EXECUTION_KERNEL" == Darwin && "$actual" == linux/arm64 && "$DOCKER_PLATFORM" == linux/amd64 ]]; then
168
+ return 0
169
+ fi
170
+ fail "Docker Server platform mismatch: expected $DOCKER_PLATFORM, got $actual; only macOS with linux/arm64 Docker Server may run linux/amd64 through verified emulation"
155
171
  }
156
172
 
157
173
  assert_container_ownership() {
@@ -29,8 +29,8 @@ services:
29
29
  platform: "${DOCKER_PLATFORM:?DOCKER_PLATFORM is required}"
30
30
  env_file: ./.env
31
31
  environment:
32
- HOST: "${BACKEND_URL}"
33
- PORT: "${BACKEND_PORT}"
32
+ BACKEND_URL: "${BACKEND_URL:?BACKEND_URL is required}"
33
+ BACKEND_PORT: "${BACKEND_PORT:?BACKEND_PORT is required}"
34
34
  TZ: "${TZ:?TZ is required}"
35
35
  ports:
36
36
  - "${DOCKER_BIND_IP}:${BACKEND_PORT}:${BACKEND_PORT}"
@@ -38,7 +38,7 @@ services:
38
38
  - ./logs:/app/logs
39
39
  restart: unless-stopped
40
40
  healthcheck:
41
- test: ["CMD-SHELL", "node -e \"const http=require('http');const r=http.get({host:'127.0.0.1',port:process.env.BACKEND_PORT,path:'/health'},x=>process.exit(x.statusCode===200?0:1));r.on('error',()=>process.exit(1));r.setTimeout(2500,()=>{r.destroy();process.exit(1)})\""]
41
+ test: ["CMD-SHELL", "node -e \"const http=require('http');const r=http.get({host:'127.0.0.1',port:process.env.BACKEND_PORT,path:'/${API_PREFIX}/health'},x=>process.exit(x.statusCode===200?0:1));r.on('error',()=>process.exit(1));r.setTimeout(2500,()=>{r.destroy();process.exit(1)})\""]
42
42
  interval: 10s
43
43
  timeout: 3s
44
44
  retries: 6
@@ -26,7 +26,7 @@ description: 为 NestJS 和 Vue 项目实现仅写入项目根目录 ./logs 的
26
26
  ## 执行步骤
27
27
 
28
28
  1. 定义 `timestamp`、`level`、`service`、`instanceId`、`env`、`traceId`、`userId`、`method`、`path`、`query`、`body`、`response`、`statusCode`、`result`、`durationMs`、`errorCode`、`message`、`params` 的最小 JSON 契约。`timestamp` 必须为服务器本地时间 `YYYY-MM-DD HH:mm:ss`,精确到秒。
29
- 2. 在入口生成或透传 traceId;在内部 REST 调用中继续传递。
29
+ 2. 在入口生成或透传 traceId;在内部 REST 调用中继续传递。Vue 的 Axios 请求拦截器生成 `x-trace-id` 时,优先调用 `crypto.randomUUID()`;HTTP IP 等非安全上下文中该 API 不可用时,必须降级为“时间戳 + 随机数”生成的非空 traceId,且生成过程不得抛异常或阻断任何请求(包括登录)。可复用实现见 [前端异常上报模板](templates/frontend-error-report.template.ts)。
30
30
  3. 统一封装 logger;业务代码不得直接使用 `console.log`。
31
31
  4. HTTP 请求日志记录完整的原始 `query`(查询参数)和 `body`(请求体);授权请求可记录实际用户、模式、表和权限等入参。服务端返回成功时记录 `result: success`;返回失败时记录 `result: failure`,并在 `response` 中记录完整失败内容。HTTP `statusCode` 为 100–399 时为成功,400–599 时为失败。
32
32
  5. 日志不执行脱敏、数组截断或大对象裁剪;`body` 必须保留完整原始入参。
@@ -43,12 +43,14 @@ description: 为 NestJS 和 Vue 项目实现仅写入项目根目录 ./logs 的
43
43
 
44
44
  - 业务代码直接调用 console,或 `body` 未保留完整原始入参。
45
45
  - 请求日志缺失 traceId、状态码或耗时。
46
+ - 前端仅调用 `crypto.randomUUID()`,使非安全 HTTP 上下文的请求在发送前失败。
46
47
  - 日志路径可被环境变量改变、生成了日志子目录、同秒文件覆盖既有文件,或前端异常未进入统一后端日志。
47
48
 
48
49
  ## Verification
49
50
 
50
51
  - [ ] 正常、异常和 HTTP 请求日志均为可解析 JSON。
51
52
  - [ ] traceId 跨入口与内部调用可追踪。
53
+ - [ ] 在 HTTPS 与 HTTP 非安全上下文中,Axios 均能为请求写入非空 `x-trace-id`;登录及其他 API 请求不会因 traceId 生成失败而中断。
52
54
  - [ ] 完整 HTTP 入参、服务端成功/失败结果映射、失败响应内容、固定 `./logs` 路径、同秒递增命名与轮转阈值均有测试覆盖。
53
55
  - [ ] 前端异常上报经过后端身份校验。
54
56
  - [ ] 日志直接写入项目根目录 `./logs`,且容器替换后日志仍保留。
@@ -8,6 +8,7 @@
8
8
  - 日志仅记录关键业务事件、请求和异常;`params`、`query`、`body` 和 `response` 均保留完整原始值,不执行脱敏、数组截断或大对象裁剪。授权请求可记录实际用户、模式、表和权限等入参。
9
9
  - HTTP 日志必须保留 `statusCode`。服务端成功返回时 `result` 为 `success`;失败返回时 `result` 为 `failure`,并在 `response` 中保留完整失败内容。100–399 为成功,400–599 为失败。
10
10
  - HTTP 入口和内部服务调用必须透传 traceId。
11
+ - Vue Axios 请求拦截器必须为每个请求写入非空 `x-trace-id`。优先使用 `globalThis.crypto?.randomUUID()`;若其不存在或不可调用(例如通过 HTTP IP 地址访问的非安全上下文),使用时间戳加随机数生成 traceId。降级仅用于请求追踪,不可将其视为加密随机值;生成逻辑不得抛异常或阻止登录、仪表盘、患者管理等 API 请求发出。
11
12
  - 每条日志必须包含 `instanceId`;所有进程共享同一 `./logs`,通过排他创建和同秒递增后缀避免覆盖。
12
13
  - Vue 未处理异常和 Promise 拒绝必须上报后端;后端校验请求身份后写入统一日志。
13
14
  - 容器必须将宿主机项目根目录 `./logs` 挂载到应用根目录 `/app/logs`,替换容器不得删除历史日志。
@@ -6,6 +6,25 @@ type FrontendErrorReport = {
6
6
  occurredAt: string;
7
7
  };
8
8
 
9
+ /**
10
+ * 为 HTTP 请求生成追踪标识。HTTP 非安全上下文可能不提供 crypto.randomUUID,
11
+ * 因此追踪标识必须有非加密用途的回退实现,不能阻断请求。
12
+ */
13
+ export function createTraceId(): string {
14
+ const cryptoApi = globalThis.crypto;
15
+ if (typeof cryptoApi?.randomUUID === 'function') {
16
+ return cryptoApi.randomUUID();
17
+ }
18
+
19
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
20
+ }
21
+
22
+ // Axios 请求拦截器示例:
23
+ // api.interceptors.request.use((config) => {
24
+ // config.headers.set('x-trace-id', createTraceId());
25
+ // return config;
26
+ // });
27
+
9
28
  export async function reportFrontendError(report: FrontendErrorReport): Promise<void> {
10
29
  const response = await fetch('/api/client-errors', {
11
30
  method: 'POST',
package/README.md CHANGED
@@ -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 生成的标准流程为:在 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 容器模式。
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` 即校验、加载并整体部署前后端;macOS/arm64 Docker Desktop 可通过禁止拉取的实际目标镜像探针验证并运行 `linux/amd64` 包,而不是仅按 Server 原生架构拒绝。另可执行 `./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
 
@@ -66,7 +66,7 @@ wdyy update-skill --all
66
66
 
67
67
  ### 使用发布脚本
68
68
 
69
- 推荐从项目根目录手动运行 `release_npm.sh`。脚本会检查工作区、组织权限、lint 与打包内容,自动执行 `npm version <patch|minor|major>`、`npm publish --access public`;发布成功后等待 5 秒,再一次查询刚发布的确切版本进行 registry 核验。它不会执行登录、浏览器认证或处理认证凭据。
69
+ 推荐从项目根目录手动运行 `release_npm.sh`。脚本会检查工作区、组织权限、lint 与打包内容,自动执行 `npm version <patch|minor|major>`、`npm publish --access public`;发布成功后等待 15 秒,再一次查询刚发布的确切版本进行 registry 核验。它不会执行登录、浏览器认证或处理认证凭据。
70
70
 
71
71
  首次运行或登录失效时,先手工登录;脚本检测到未登录也会提示并退出:
72
72
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wdyy/skills",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "企业内部开发 Skill 与项目初始化命令",
5
5
  "type": "module",
6
6
  "bin": {