From 4df047708b40011a2c68dbfaa23ac214360e481e Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Mon, 10 Aug 2026 17:04:29 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E6=95=9B=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E9=9D=99=E6=80=81=E5=8F=91=E5=B8=83=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/deploy-publish-check/SKILL.md | 135 ---- .../project-zip-package-requirements.md | 39 - .opencode/skills/dev-build-test/SKILL.md | 4 +- .../skills/marketing-launch-story/SKILL.md | 2 +- .../skills/youth-ai-product-course/SKILL.md | 6 +- .../20260810-static-release-only-a91c.md | 75 ++ README.md | 6 +- electron/api/context.ts | 6 +- electron/api/routes/device-preview.ts | 30 +- electron/api/routes/opencode.ts | 63 -- electron/api/routes/works.ts | 97 +-- electron/main/index.ts | 12 +- electron/opencode/superpowers.ts | 7 +- electron/opencode/works-publish-file.ts | 85 -- .../opencode/works-square-deploy-check.ts | 735 ------------------ electron/services/project-progress-sync.ts | 14 +- electron/services/works-cloud-deployment.ts | 481 ------------ electron/services/works-submission-binding.ts | 179 +++++ shared/works-cloud-deployment.ts | 24 - shared/works-square-deploy-check.ts | 64 -- shared/works-submission-binding.ts | 19 + src/lib/skill-display.ts | 4 - src/lib/works-deploy-check.ts | 10 - src/lib/works-publish-file.ts | 24 - src/lib/works-publish-helpers.ts | 76 -- src/lib/works-square.ts | 55 +- src/pages/Makelore/index.tsx | 11 - tests/unit/device-preview-routes.test.ts | 75 +- tests/unit/opencode-manager.test.ts | 4 + tests/unit/opencode-routes.test.ts | 216 +---- tests/unit/project-progress-sync.test.ts | 67 +- tests/unit/works-cloud-deployment.test.ts | 227 ------ tests/unit/works-publish-helpers.test.ts | 24 - tests/unit/works-routes.test.ts | 155 +--- tests/unit/works-square-deploy-check.test.ts | 461 ----------- tests/unit/works-square.test.ts | 53 +- tests/unit/works-submission-binding.test.ts | 130 ++++ 37 files changed, 604 insertions(+), 3071 deletions(-) delete mode 100644 .opencode/skills/deploy-publish-check/SKILL.md delete mode 100644 .opencode/skills/deploy-publish-check/references/project-zip-package-requirements.md create mode 100644 .project-docs/30-worklog/tasks/20260810-static-release-only-a91c.md delete mode 100644 electron/opencode/works-publish-file.ts delete mode 100644 electron/opencode/works-square-deploy-check.ts delete mode 100644 electron/services/works-cloud-deployment.ts create mode 100644 electron/services/works-submission-binding.ts delete mode 100644 shared/works-cloud-deployment.ts delete mode 100644 shared/works-square-deploy-check.ts create mode 100644 shared/works-submission-binding.ts delete mode 100644 src/lib/works-deploy-check.ts delete mode 100644 src/lib/works-publish-file.ts delete mode 100644 src/lib/works-publish-helpers.ts delete mode 100644 tests/unit/works-cloud-deployment.test.ts delete mode 100644 tests/unit/works-publish-helpers.test.ts delete mode 100644 tests/unit/works-square-deploy-check.test.ts create mode 100644 tests/unit/works-submission-binding.test.ts diff --git a/.opencode/skills/deploy-publish-check/SKILL.md b/.opencode/skills/deploy-publish-check/SKILL.md deleted file mode 100644 index d98843f..0000000 --- a/.opencode/skills/deploy-publish-check/SKILL.md +++ /dev/null @@ -1,135 +0,0 @@ ---- -name: deploy-publish-check -description: 当 NianCode 发布部署角色检查学习者软件项目的构建准备、Docker 部署、项目 zip 打包、根目录 works-publish.json、部署与上传页面交接、发布步骤、回滚计划、作品广场提交准备和部署报告时使用。 ---- - -# 部署发布检查 - -使用这个 skill,判断学习者项目是否已经准备好通过 Docker 发布。 - -## 给学生看的说话规则 - -必须使用 `youth-plain-language`。讲 Docker、zip、上传接口时,先说明它们在作品发布里分别做什么,再给具体下一步。 - -## 工作流程 - -1. 确认发布目标,以及是否明确允许外部发布。 -2. 检查代码、Demo、素材、宣传文案和构建命令。 -3. 创建或维护 `Dockerfile`,确保项目可以构建为 Docker 镜像。 -4. 创建或维护 `docker-compose.yml`,确保项目可以通过 Docker Compose 启动。 -5. 检查 `niancode.yml` 使用 `runtime: compose`,并声明公开服务和 `8080` 端口;同时确认公开服务进程监听容器内 `0.0.0.0:8080`。 -6. 在合适时运行 Docker Compose 配置检查、构建、启动或冒烟检查。 -7. 发布获准时,把当前项目打成 `.zip` 包,并检查 zip 根目录包含 `niancode.yml`、`docker-compose.yml` 和 compose 构建引用到的 Dockerfile。 -8. 执行结构化机器检查:manifest、Compose services/public service/端口/build context、volume、禁止的运行时安装/构建、Dockerfile 构建阶段、Node/Python 锁文件、Vite `base: "./"`、构建入口资源路径、浏览器存储降级、环境变量安全默认值、密钥/缓存/路径/大小/文件数量;游戏或画布项目额外检查根节点全屏和响应式画布布局。 -9. 如果本机有 Docker,运行 `docker compose config`、`build --pull=false`、`up -d`、动态端口检查、HTTP smoke、日志检查和清理;解压到全新目录后再重复一遍。如果本机没有 Docker,逐项写 `status: BLOCKED`、`execution: unavailable` 和原因,不要把未执行写成 PASS,也不要要求用户去服务器接管。 -10. 如果本机有浏览器,在不带 `allow-same-origin` 的 sandbox iframe 中执行浏览器 smoke;任何白屏、未捕获 `SecurityError` 或启动失败都标为 BLOCKED。游戏或画布项目必须再执行桌面、移动和一次 resize/全屏变化 smoke,检查主画布是否达到最大等比尺寸。浏览器不可用时同样写 `execution: unavailable`,不能伪造画布证据。 -11. 在项目根目录创建或更新 `works-publish.json`,写清作品信息、版本信息和 zip 路径。 -12. 在项目根目录创建 `works-deploy-check.json`,绑定当前 zip 的 SHA-256,并用 `PASS`、`SKIPPED`、`BLOCKED` 记录所有必填机器检查项;真实执行成功写 PASS,环境能力缺失写 SKIPPED,或在明确云端交接时写 BLOCKED + `execution: unavailable`,确定性失败写 BLOCKED;总状态按 `BLOCKED > SKIPPED > PASS` 聚合。 -13. 把 `works-publish.json`、`works-deploy-check.json` 和 zip 路径交给“部署与上传”页面读取;如果“构建产品”已登记 `works-cloud-deploy.json`,交给 Main 云端部署协调器,它会在静态复核通过和登录态有效时自动调用 Works Square 远端构建接口;如果没有登录态,记录下一步。 -14. 准备发布步骤和回滚步骤。 -15. 产出部署报告。 - -## 发布保护规则 - -- 在发布目标和权限明确前,不要判定为可发布。 -- 必须使用 Docker 部署。不能把本地开发服务器、直接打开 HTML 文件或手工命令当作最终部署方式。 -- 使用最新构建、冒烟检查、预览或文件检查作为证据。 -- 记录准确命令和观察结果。 -- 检查密钥、私有路径、个人数据或未完成占位内容。 -- 缺少 `Dockerfile` 或 `docker-compose.yml` 时,不能判定为可发布;应先创建或要求补齐这两个文件。 -- 交给 Main 的云端部署协调器提交时,`.zip` 包必须在根目录包含 `niancode.yml` 和 `docker-compose.yml`,并包含 compose 构建引用到的 `Dockerfile` 或其它 Dockerfile。 -- zip 根目录必须直接包含 `niancode.yml` 和 `docker-compose.yml`,不能是 `project/niancode.yml` 或其它外层目录套壳。 -- 最小 `niancode.yml` 必须声明 `runtime: compose`、`compose.file: docker-compose.yml`、`compose.public_service` 和 `compose.public_port: 8080`。 -- `docker-compose.yml` 的公开服务必须发布容器端口 `8080`,服务进程必须监听 `0.0.0.0:8080`,并使用 `127.0.0.1::8080` 动态宿主机端口;固定宿主机端口和非 loopback 发布都不能判定为可发布。 -- 不能只准备 `Dockerfile`。作品广场 builder 和 runner 会按 Docker Compose 路径构建、启动和清理项目。 -- 所有服务禁止 bind mount、Docker socket、`privileged`、host network、host pid、host ipc 和固定宿主机端口;只允许顶层声明的命名卷保存运行数据。 -- Compose `command` / `entrypoint` 不得安装依赖、同步依赖或构建项目;这些动作必须写在 Dockerfile 的 build 阶段。 -- Compose 只能使用非敏感环境变量或 `${NAME:-safe-default}` 安全默认值;不得依赖宿主机未明确注入的业务变量,不得使用 `env_file: .env`。真实 `.env*` 文件全部排除,`.env.example` 只允许无敏感示例。 -- 如果项目是游戏或画布项目,`html`、`body`、`#app`/`#game-container` 必须占满 iframe,Phaser 使用 `FIT`/`CENTER_BOTH` 或等价缩放,其他引擎必须在 viewport/全屏变化后重新布局;桌面或移动任一实际 canvas 尺寸比最大等比预期小超过 5% 就是 BLOCKED。 -- 实际成功才写 `PASS`;仅因当前环境没有 Docker、Playwright、Puppeteer 或浏览器自动化而未执行时写 `SKIPPED`,或在明确云端交接时写 `BLOCKED` + `execution: unavailable`,保留原命令和缺失能力;命令执行失败或项目检查发现问题写 `BLOCKED`。`SKIPPED` 允许上传但必须提示未验证风险;云端模式只能把明确 unavailable 的动态检查交给 Works Square 远端复核。 -- 上传文件名必须以 `.zip` 结尾;zip 原始大小最大 `50MB`,解压后总大小最大 `200MB`,文件数量最大 `2000`。 -- 打包时默认排除 `.git/`、`node_modules/`、`.venv/`、`venv/`、`.next/cache/`、`.pytest_cache/`、真实 `.env`、证书、私钥和无关本地构建产物。 -- 缺少项目根目录 `works-publish.json`、缺少必填字段或 `zip_file_path` 不是 `.zip` 时,不能判定为可发布。 -- 缺少 zip 包路径或项目根目录 `works-publish.json` 时,不能判定为可交接;应先打包并写好交接文件。 -- 缺少 `works-deploy-check.json`、schema 不支持、zip SHA-256 不匹配、静态检查失败或任一确定性必填 check 为 `BLOCKED`,不能判定为可交接;仅环境能力缺失的 `SKIPPED` 或明确 unavailable 可交接但必须提示风险并交给云端复核。 -- Works Square 远端提交失败、缺少访问令牌或缺少登录态时,不要宣称发布完成;保留交接文件,记录 `waiting_for_login`/`failed` 和原因,Main 会在登录态恢复后继续。 -- 发布部署角色负责准备 zip 包、`works-publish.json` 和 `works-deploy-check.json`;“部署与上传”页面的“构建产品”负责明确授权一次云端提交,Main 负责正式提交,不要要求用户手动填写字段、SSH 或服务器。 -- 标记为可发布前,必须包含回滚或恢复步骤。 - -## 云端部署与交接规则 - -- “部署与上传”页面的“构建产品”按钮会登记一次 `works-cloud-deploy.json` 云端部署意图;页面只展示状态,Main 负责正式提交。 -- “远程上传”在本项目中特指 Works Square 后端 HTTP 上传接口,不是 SSH、SFTP、rsync 或自备服务器部署。 -- Main 会通过受控边界先确保项目元数据存在,再调用上游接口:`POST /api/projects/{appId}/versions/upload`。 -- Agent 不接触 `accessToken`,不能在 shell、聊天或报告中打印令牌。 -- `works-cloud-deploy.json` 的 `submitted` 只表示远端已接受并返回 `version_id`;`queued`、`building`、`reviewing` 都不等于已公开。 -- 不要询问 SSH 主机、用户名、端口或目标路径。 -- 如果没有云端部署意图,完成文件后说明需要从“部署与上传”页面点击“构建产品”授权一次提交;如果暂时没有登录态,保留交接文件并等待 Main 自动重试,不要让用户去服务器接管。 - -## 根目录发布信息文件 - -- 打包完成后,必须在项目根目录创建或更新 `works-publish.json`。 -- 文件必须是 JSON 对象,字段包括:`app_id`、`title`、`summary`、`category`、`age_band`、`difficulty`、`version_name`、`change_log`、`zip_file_path`。 -- `zip_file_path` 必须指向已经生成的 `.zip` 包。 -- Main 云端部署协调器读取 `works-publish.json`;页面仍可展示文件内容和历史版本,但不再让用户手动填写这些字段。 -- `works-publish.json` 是桌面页面交接文件,不是服务端校验 zip 的必填项,也不替代 zip 根目录的 `niancode.yml` 和 `docker-compose.yml`。 -- 不要要求用户手动填写这些字段;部署工程师要根据项目事实和交付物写入文件。 -- 缺少 `works-publish.json`、缺少必填字段或 `zip_file_path` 不是 `.zip` 时,不能云端交接。 - -## zip 包结构和安全检查 - -- 创建 zip 时,让项目根目录内容直接进入 zip 根目录;不要把整个项目目录再包一层,不能是 `project/niancode.yml`。 -- 重新打开 zip,检查根目录是否直接存在 `niancode.yml` 和 `docker-compose.yml`。 -- 检查 zip 是否包含 Compose build context 实际需要的源码、资源、锁文件和 Dockerfile。 -- 检查 zip 是否排除了真实密钥、本地依赖目录、缓存目录、符号链接、绝对路径、`..` 路径和不安全 volume / build 路径。 - -## 必须输出 - -返回部署报告,包含: - -1. 发布目标 -2. 是否允许发布 -3. 准备情况 -4. `Dockerfile` 路径和关键内容说明 -5. `docker-compose.yml` 路径、服务名和端口映射 -6. `niancode.yml` 路径和 `runtime: compose` / 公开服务 / `8080` 端口说明 -7. Docker Compose 配置、构建、启动和检查命令结果 -8. Compose 包结构检查:zip 根目录、外层目录、build context、Dockerfile、排除项、大小和文件数量 -9. 项目根目录 `works-publish.json` 路径和关键字段 -10. 程序包 zip 路径 -11. `works-cloud-deploy.json` 路径、当前状态、version_id/review_status(如已返回)和 Main 自动提交说明 -12. 发布步骤 -13. 回滚步骤 -14. 作品广场提交清单(页面会读取的字段) -15. 游戏/画布适用时的逻辑分辨率、桌面/移动 viewport、实际 canvas、最大等比预期和 resize 复测 -16. 阻塞项 - -## 纠偏清单 - -出现这些情况时纠偏: - -- 发布目标不清楚,但要求发布。 -- 缺少构建/测试状态。 -- 宣传素材没有准备好。 -- 可能暴露密钥、私有路径或不安全文件。 -- 上传包缺少 `niancode.yml`、`docker-compose.yml`、compose 构建引用到的 Dockerfile 或清晰 zip 路径。 -- zip 根目录没有直接包含 `niancode.yml` 和 `docker-compose.yml`,或者把项目套成 `project/niancode.yml` 时,不能判定为可发布。 -- `works-deploy-check.json` 必须把 `compose_config`、`compose_build`、`compose_up`、`container_port`、`http_smoke`、`fresh_directory_smoke`、`sandbox_browser_smoke` 逐项写清楚;每项要有 `status`、`detail`、`command`,动态项还要有 `execution: executed` 或 `execution: unavailable`。本机环境不可用时写 BLOCKED + unavailable,不得伪造 PASS;存在云端部署意图时由 Main cloud 模式继续提交前复核。 -- 游戏或画布项目必须额外写 `game_canvas_smoke`;只有浏览器真实执行成功才提供 `canvas_evidence.logical_resolution`、`desktop_viewport`、`mobile_viewport`、`desktop_canvas`、`mobile_canvas`、`expected_max_canvas` 和 `resize_verified`。浏览器不可用时写 BLOCKED + `execution: unavailable`,普通网站不需要伪造此项。 -- 缺少 `Dockerfile` 或 `docker-compose.yml` 时,不能判定为可发布。 -- `niancode.yml` 仍使用 `runtime: docker` 或没有声明 `compose.public_service` / `compose.public_port: 8080` 时,不能判定为可发布。 -- `docker-compose.yml` 没有公开服务、没有发布容器端口 `8080`、写死宿主机端口,或不是 `127.0.0.1::8080` 这类 loopback 动态映射时,不能判定为可发布。 -- 容器内服务没有监听 `0.0.0.0:8080`,或 Compose 依赖未明确注入的宿主机环境变量、真实 `.env*`、`env_file: .env` 时,不能判定为可发布;`${NAME:-safe-default}` 才是允许的安全默认形式。 -- 游戏或画布项目根节点没有占满 iframe、主画布固定在逻辑分辨率、缺少响应式缩放/resize 重新布局、桌面或移动 canvas 比最大等比预期小超过 5%,或缺少 `game_canvas_smoke` 真实证据时,不能判定为可发布。 -- 缺少项目根目录 `works-publish.json`、缺少必填字段或 `zip_file_path` 不合法时,不能判定为可发布。 -- 缺少 zip 包路径或项目根目录 `works-publish.json` 时,不能判定为可交接。 -- 没有说明 Main 如何读取交接文件并自动提交,或远端提交失败但报告里没有失败原因。 -- zip 包超过大小/文件数量限制,或者包含真实密钥、本地依赖目录、缓存目录、不安全路径、符号链接、Docker socket、host network / privileged 配置时,不能判定为可发布。 -- 没有 Docker 构建、Docker Compose 启动或等价检查证据时,必须明确写 unavailable/阻塞原因;不能把缺少证据说成 PASS。 -- 没有回滚或恢复说明。 - -没有明确许可和清晰目标时,不要对外发布。 - -## 交接 - -如果被阻塞,把准确缺失项交回负责角色。如果已准备好,提供最终发布说明和证据。 diff --git a/.opencode/skills/deploy-publish-check/references/project-zip-package-requirements.md b/.opencode/skills/deploy-publish-check/references/project-zip-package-requirements.md deleted file mode 100644 index 4605265..0000000 --- a/.opencode/skills/deploy-publish-check/references/project-zip-package-requirements.md +++ /dev/null @@ -1,39 +0,0 @@ -# Works Square 项目 ZIP 打包契约 - -创建、检查或修复 Works Square 项目 ZIP 时使用本契约。服务端只接受可由 Docker Compose 构建和运行的安全 ZIP 包。 - -## 根目录和必需内容 - -ZIP 根目录直接包含 `niancode.yml`、`docker-compose.yml`、Compose build context 使用的源码、引用的 `Dockerfile*`、构建使用的 lock 文件,以及运行所需静态资源和配置。不能出现 `project/niancode.yml` 这种额外目录套壳。 - -## 大小、计数和路径安全 - -- ZIP 文件大小不得超过 `50 MB`。 -- 解压后总大小不得超过 `200 MB`。 -- 文件数不得超过 `2000`。 -- 禁止绝对路径、盘符路径、`..` 路径穿越、空路径片段、重复规范化路径和符号链接。 -- 每个条目必须能被普通 ZIP 解压器读取。 - -默认排除 `node_modules/`、`.git/`、`build/`、`dist/`、`.next/`、`.nuxt/`、`.vite/`、`.dart_tool/`、`.pub-cache/`、`android/.gradle/`、`ios/Pods/`、`.venv/`、`venv/`、`__pycache__/`、`*.pyc`、`.pytest_cache/`、`.DS_Store`、真实 `.env`、证书、私钥和本地密钥。优先使用白名单,只加入实际构建和运行需要的文件。 - -## niancode.yml - -根目录 UTF-8 YAML 必须声明 `runtime: compose`、`compose.file: docker-compose.yml`、存在于 Compose 的 `compose.public_service`,以及 `compose.public_port: 8080`。 - -## Docker Compose - -公开服务的容器内应用监听 `0.0.0.0:8080`,端口只写为 `127.0.0.1::8080`。禁止固定宿主机端口、非 loopback 绑定和仅写 `"8080"`。 - -拒绝 `privileged: true`、`network_mode: host`、`pid: host`、`ipc: host`、`/var/run/docker.sock` 挂载、绝对宿主机路径挂载,以及绝对或包含 `..` 的 build context / Dockerfile 路径。多服务项目只让公开服务发布端口;浏览器所需 `/api/...` 由公开服务反向代理到 Compose 内部后端。 - -## Dockerfile - -构建必须无交互,不依赖宿主机预装 Node、Python 或 Flutter,不引用本地绝对路径。基础镜像必须可获取;依赖安装尽量使用 lock 文件;语言版本约束必须与基础镜像提供的运行时一致。 - -## 本地验证链 - -依次记录 `docker compose config`、`build --pull=false`、`up -d`、`docker port`、HTTP 冒烟、`down -v`,以及重新打开 ZIP 后的结构、安全、大小和计数检查。Docker 不可用时,把未执行项标记为“未验证”或“失败/阻塞”。 - -## 页面交接 - -项目根目录 `works-publish.json` 供“部署与上传”页面读取,不是服务端 ZIP 校验必填项,也不能替代 ZIP 根目录的 Compose 文件。只有实际提交后才能记录提交成功。 diff --git a/.opencode/skills/dev-build-test/SKILL.md b/.opencode/skills/dev-build-test/SKILL.md index 89550c7..ac29b43 100644 --- a/.opencode/skills/dev-build-test/SKILL.md +++ b/.opencode/skills/dev-build-test/SKILL.md @@ -64,7 +64,7 @@ description: 当 NianCode 开发工程师角色为学习者软件项目设计数 5. 结果 6. UI/UX 检查结果 7. 剩余风险 -8. 给市场运营/部署工程师的事实说明 +8. 给市场运营和一键提交前的事实说明 ## 纠偏清单 @@ -80,4 +80,4 @@ description: 当 NianCode 开发工程师角色为学习者软件项目设计数 ## 交接 -交接给市场运营时,提供真实功能事实和限制。交接给部署工程师时,提供构建/测试状态和实际运行的命令。 +交接给市场运营时,提供真实功能事实和限制。准备提交审核时,提供构建/测试状态和实际运行的命令;正式安全打包与云端验收由 Makelore 完成。 diff --git a/.opencode/skills/marketing-launch-story/SKILL.md b/.opencode/skills/marketing-launch-story/SKILL.md index 98c8d7a..de4ee1a 100644 --- a/.opencode/skills/marketing-launch-story/SKILL.md +++ b/.opencode/skills/marketing-launch-story/SKILL.md @@ -63,4 +63,4 @@ description: 当 NianCode 市场运营角色准备学习者项目的宣传页、 ## 交接 -交接给部署工程师时,提供最终对外文案、所需素材和缺失证据。 +交接给运营审核时,提供最终对外文案、所需素材和缺失证据;用户不需要准备部署文件或服务器信息。 diff --git a/.opencode/skills/youth-ai-product-course/SKILL.md b/.opencode/skills/youth-ai-product-course/SKILL.md index 695fd74..e159987 100644 --- a/.opencode/skills/youth-ai-product-course/SKILL.md +++ b/.opencode/skills/youth-ai-product-course/SKILL.md @@ -36,8 +36,8 @@ NianCode 是面向 10–16 岁学习者的教学工具。目标不只是把代 - 美术设计师 -> 基于原型的视觉方向、色彩/字体/布局规则、响应式和可访问性说明 - 开发工程师 -> 实现内容、数据/接口说明、测试或验证结果 - 市场运营 -> 宣传定位、目标受众、展示文案、讲解提纲、证据清单或证据缺口 -- 部署工程师 -> `部署报告.md`、`Dockerfile`、`docker-compose.yml`、`niancode.yml`、全部已验证的 Compose 与 ZIP 证据矩阵、安全的 Compose ZIP、根目录 `works-publish.json`、部署与上传页面交接说明、发布步骤和回滚步骤 -- 游戏发布官 -> `RELEASE_CHECKLIST.md`、构建与试玩记录、`Dockerfile`、`docker-compose.yml`、`niancode.yml`、全部已验证的 Compose 与 ZIP 证据矩阵、安全的 Compose ZIP、根目录 `works-publish.json`、部署与上传页面交接说明、发布步骤和回滚步骤 +- 发布准备 -> 提交前构建结果、浏览器试玩记录、已知限制和修改说明;正式打包与云端验收由 Makelore 一键提交链路完成 +- 游戏发布官 -> `RELEASE_CHECKLIST.md`、玩法与触控试玩记录、桌面/移动视口检查和待运营核对项 如果上游产物缺失,不阻止当前工作。先检查项目事实;能安全推进时创建轻量草稿并明确记录假设,只有缺失决定会实质改变结果时才询问学习者。聊天总结和跨会话消息不能替代项目文件。 @@ -52,7 +52,7 @@ NianCode 是面向 10–16 岁学习者的教学工具。目标不只是把代 - 美术设计师:基于原型、可落地的视觉规则,而不是只有风格形容词。 - 开发工程师:改动文件、实现事实,以及尽可能提供测试/检查输出。 - 市场运营:宣传说法要绑定产品事实,缺证据的内容要标注。 -- 部署工程师和游戏发布官:保留 `Dockerfile`、`docker-compose.yml`、`runtime: compose` 的 `niancode.yml`、发布目标与外部发布权限、安全的 Compose ZIP、根目录 `works-publish.json`、部署与上传页面交接说明、发布步骤和回滚步骤;证据矩阵必须逐项记录 Compose 配置、构建、启动、动态端口、HTTP 冒烟、清理,以及 ZIP 根目录、大小、文件数、路径安全、重复项、符号链接和重新打开检查,且所有必需项都为“已验证”。 +- 发布准备:小游戏和小程序保留真实 `npm run build`、浏览器试玩、移动视口与已知限制证据;用户只从项目配置点击一次“提交审核”,Makelore Main 负责安全打包,云端 Builder 和浏览器门禁负责最终验收。不得要求学习者准备 Docker、Compose、ZIP 或服务器登录信息。 如果证据缺失,要在本角色负责的项目产物中明确记录缺口、影响和建议补充方式。 diff --git a/.project-docs/30-worklog/tasks/20260810-static-release-only-a91c.md b/.project-docs/30-worklog/tasks/20260810-static-release-only-a91c.md new file mode 100644 index 0000000..e5b950a --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260810-static-release-only-a91c.md @@ -0,0 +1,75 @@ +# Task: 客户端静态发布链路唯一化 + +## Identity + +- Task ID: 20260810-static-release-only-a91c +- Mode: Feature +- Branch: codex/20260810-static-release-only-a91c-static-release-only +- Worktree: D:\Datas\OthersProjects\makelore-static-release-only-a91c +- Base commit: 2e737dee316058bbc3bb8a98ae8f50223871bd7d +- Owner: codex +- Status: Ready for Integration + +## Scope + +- 将客户端发布入口收敛为唯一静态链路:项目配置一键提交,由 Main 安全打包并上传服务端 Builder。 +- 为作品广场与真机预览接入 `play_url`,同时保留一个客户端版本的 `runtime_url` 兼容回退。 +- 删除 Compose deploy-check skill、提示词、辅助文件、直传 ZIP 路由以及自动 watcher/arm/upload 协调链。 +- 将旧 `works-cloud-deployment` 收敛为最小提交版本绑定存储,并迁移已经落盘的旧状态。 +- 更新聚焦测试、README 与任务记录;不修改一般 OpenCode、AI 设计、编辑器 Dockerfile 语法能力或服务端实现。 + +## Intent And Constraints + +- `ProjectPublishAction -> publishWorksProjectSource -> createStaticProjectPackage -> 服务端上传` 是唯一用户发布路径,不允许重新引入手工 ZIP、Docker、Compose 或服务器登录步骤。 +- Renderer 不持有发布 Token、归档路径或自动部署状态;版本绑定只在 Main 成功收到上传结果后落盘。 +- 已发布作品与设备预览优先使用服务端 `play_url`;`runtime_url` 只做一个客户端版本的兼容回退,且继续执行同源 HTTPS 和版本一致性检查。 +- 已经 `submitted` 的旧绑定必须保留;旧 `armed`、`waiting_for_package`、`waiting_for_login`、`uploading`、`failed` 不再恢复后台任务,而是确定性迁移为可理解、可重试的终止状态。 +- 为兼容已安装客户端,落盘文件名暂时保留 `works-cloud-deploy.json`,但内容升级为 schema v2,代码接口和领域名统一为 submission binding。 +- `project-packager` 继续排除历史 Docker/Compose/发布协调文件,避免把遗留控制文件上传到静态 Release;这不是重新支持 Compose。 +- 只在独立 feature worktree 修改任务拥有的文件,不覆盖主工作区或其他任务 WIP;本任务不直接合并 `main`。 + +## Plan + +1. 固定静态发布单入口和最小版本绑定契约,设计旧状态迁移结果。 +2. 接入 `play_url` 安全投影、广场卡片和真机预览优先级,保留 `runtime_url` 兼容回退。 +3. 删除旧 skill、提示词、helper、协调器、路由和对应测试,保留静态打包上传路径。 +4. 补充绑定迁移、路由、预览与一键提交回归,运行 typecheck、lint、构建和 Electron E2E 收集。 +5. 更新 README、任务记录和文档门禁,提交独立 feature commit,等待集成任务合入。 + +## Outcome + +- 新增 `WorksSubmissionBindingRecord` schema v2 和 Main-owned 最小存储,只提供 `recordSubmitted`、`get`、启动迁移,不再监听构建产物、等待登录或代替用户上传。 +- 为兼容现有安装数据继续读取 `works-cloud-deploy.json`:旧 `submitted` 记录保留 app/version/review/hash 绑定并升级;五类旧中间/失败状态统一写回 `legacy_retired`、`LEGACY_AUTO_DEPLOY_RETIRED` 和“在项目配置中点击提交审核重新提交”的下一步。 +- 保留并验证 `ProjectPublishAction -> createStaticProjectPackage -> /api/projects/{app}/versions/upload` 一键静态路径;上传成功后仅记录精确提交版本,记录失败不反向伪造上传失败。 +- `ProjectPublic` 和 Host 安全投影新增 `play_url`。广场卡片与真机预览优先使用 `play_url`,只有字段缺失时才读取 `runtime_url`;预览仍限制 Works Square 同源 HTTPS、精确版本和短时 owner preview。 +- 删除旧 Compose deploy-check 内置 skill、机器检查器、`works-publish`/deploy-check helper、自动部署协调器、Renderer arm/fetch API、手工 ZIP 上传 Host 路由,以及 `/works-publish`、`/works-deploy-check`、`/works-cloud-deploy` OpenCode 路由。 +- 内置 skill 清单把 `deploy-publish-check` 标记为 retired,客户端启动时会清理已安装副本;课程、开发和营销提示统一改为“用户一键提交、平台安全打包与云端验收”,不再要求 Dockerfile、Compose、ZIP 或服务器信息。 +- 项目进度同步改用已提交版本绑定识别服务端项目,不再依赖 AI 生成的 `works-publish.json`。README 已同步静态 Release、`play_url` 和兼容边界。 +- 保留一般 OpenCode/Monaco 的 Dockerfile 语法支持、AI 设计能力、会话运行时清理和打包器对历史控制文件的安全排除;这些能力不属于旧 Compose 发布协调链。 + +## Verification + +- `pnpm run typecheck`:通过。 +- `pnpm exec vitest run tests/unit/works-submission-binding.test.ts tests/unit/device-preview-routes.test.ts tests/unit/project-progress-sync.test.ts tests/unit/works-routes.test.ts tests/unit/works-square.test.ts tests/unit/opencode-routes.test.ts tests/unit/project-packager.test.ts`:7 files / 185 tests passed;覆盖五类旧状态归一、旧 submitted 保留、`play_url` 优先与 `runtime_url` 回退、旧路由下线和静态上传版本绑定。 +- `pnpm exec vitest run tests/unit/project-publish-action.test.tsx tests/unit/works-project-publish.test.ts tests/unit/device-preview-page.test.tsx`:3 files / 31 tests passed;覆盖客户端一键提交动作、静态提交视图与真机预览 UI。 +- `pnpm exec vitest run tests/unit/opencode-manager.test.ts -t "removes retired bundled skills"`:1 passed;确认已安装的旧 deploy-check skill 会被清理。 +- 对本任务全部变更 TS/TSX 文件执行 scoped ESLint:0 errors;仅 `src/pages/Makelore/index.tsx` 有 4 条仓库既有 Fast Refresh warning。 +- `pnpm run lint:check`:仓库级尝试未通过;错误位于本任务未修改的 `src/components/layout/MainLayout.tsx`(effect 内同步 setState)和 `tests/unit/chat-command-dialogs.test.tsx`(未使用 `rerender`),另有 6 条既有 warning。本任务变更文件无 lint error。 +- `pnpm run build:vite`:Renderer、Electron Main、Preload 均构建成功;仅有仓库既有 dynamic-import/chunk-size 提示。 +- `pnpm exec playwright test --list --reporter=line`:成功收集 13 个 Electron E2E 文件、21 条用例,当前仓库不存在“缺少 Electron E2E fixture”问题。 +- `git diff --check`:通过;仅有 Git 的 LF/CRLF 工作区提示。 + +## Follow-ups + +- 客户端与服务端 OSS immutable Release / CDN 变更需要成组集成后,使用真实账号执行“创建小游戏/小程序 -> 一键提交 -> Builder -> 自动浏览器验收 -> 运营审核发布 -> App `play_url` 播放”的生产整链验收。 +- 下一个客户端兼容版本确认服务端和存量数据均稳定提供 `play_url` 后,删除 `runtime_url` 类型字段、读取回退和对应测试。 +- 仓库级 lint 的两个既有 error 应由独立维护任务处理,避免在发布链路任务中混入无关 UI/测试清理。 + +## Promotion Candidates + +- Target: `.project-docs/20-architecture/system-overview.md`、`.project-docs/40-domain/business-rules.md` 与 README 当前产品状态。 + - Proposal: 客户端发布唯一链路是 Main 安全生成静态包并提交服务端 Builder;客户端只持久化成功提交的精确版本绑定,不再拥有 Compose runner、自动 watcher/arm/upload 或手工 ZIP 入口。 + - Evidence: 本任务删除 3,000 余行旧协调代码与测试,新增 schema v2 迁移及 216 项聚焦回归,typecheck、Vite 构建和 Electron E2E 收集通过。 + - Future impact: 后续发布功能必须建立在 immutable static Release、`play_url` 和服务端审核发布状态之上;不得重新要求非专业用户准备 Docker/Compose/ZIP/服务器凭证。 + - Semantic conflicts: 落盘文件名 `works-cloud-deploy.json` 仅为已安装数据兼容,不代表仍存在 cloud deployment coordinator;`runtime_url` 仅为一个客户端版本的临时兼容字段。 + - Human confirmation required: 是;用户已明确要求一步到位移除旧 runtime-cleaner/runner 方向并统一到 Builder -> OSS -> CDN -> App,canonical promotion 仍由 Integration Gate 执行。 diff --git a/README.md b/README.md index e166572..0e42f1a 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Makelore 是一个面向软件与视觉创作的 AI 桌面工作台。当前版 - 桌面技术栈:Electron、React 19、Vite、TypeScript、Zustand、Tailwind CSS。 - AI 编程运行时:Electron Main 只启动安装目录 `resources/opencode-ai/bin/` 中固定版本的 OpenCode;Renderer 不直接启动或调用运行时。首选回环端口被不健康进程占用时,Main 会改用系统分配的临时端口,并把实际 URL 贯穿到所有运行时请求。 - 共享开发浏览器:AI 编程右侧提供项目级浏览器,用户与 Agent 查看并调试同一实时页面、Console 和 Network,支持本地与公网开发地址。 -- 真机预览:AI 编程项目空间可读取当前服务端构建状态;可运行版本生成同源 HTTPS 二维码,供手机直接测试触控、屏幕和移动网络环境。 +- 真机预览:AI 编程项目空间可读取当前静态 Release 状态;待审版本使用短时预览地址,已发布版本使用同源 HTTPS `play_url`,供手机直接测试触控、屏幕和移动网络环境。 - 后端边界:Renderer 通过 Main 所有的 Host API 访问认证、模型、同步、更新、语音、图像与运行时能力。 - AI 绘画:每个设计项目固定一个设计 Agent。Agent 先通过持续对话收敛 Brief,用户确认服务端 Quote 后才创建图片或视频任务;任务进度通过 Agent Gateway 实时推送并在统一列表中展示,断流时使用低频 REST 同步。生产环境使用 Works Square 云端 Workspace 契约,上游不可用时明确报错。 - 视觉系统:单一浅色主题,品牌蓝 `#3A5578`、星火橙 `#F26A3D`、白色画布与低饱和蓝灰层级。 @@ -96,8 +96,8 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode ### 真机预览 - 真机预览属于 Makelore Code 的项目级能力,不是新的顶层 AI 模块,也不复用 `/deliverables`,不恢复发布上传或云部署工作台。 -- Renderer 只读取 Main-owned Host API 的项目预览快照;Main 绑定当前项目、管理登录续签、核对目标版本,并只放行 Works Square 同源 HTTPS Runtime。二维码不包含账号 Token,也不生成本机回环地址。 -- 构建成功且进入待审的版本使用短时、只读、精确 Release 绑定的 Owner preview ticket;已发布版本可使用核对过版本号的同源 Runtime。构建中、旧版本或失败状态不会伪装成可扫码。 +- Renderer 只读取 Main-owned Host API 的项目预览快照;Main 绑定当前项目、管理登录续签、核对目标版本,并只放行 Works Square 同源 HTTPS 静态 Release 地址。二维码不包含账号 Token,也不生成本机回环地址。 +- 构建成功且进入待审的版本使用短时、只读、精确 Release 绑定的 Owner preview ticket;已发布版本优先使用 `play_url`,`runtime_url` 仅保留一个客户端兼容版本。构建中、旧版本或失败状态不会伪装成可扫码。 ### 项目联系人与会话 diff --git a/electron/api/context.ts b/electron/api/context.ts index 8f875fd..06dbf5b 100644 --- a/electron/api/context.ts +++ b/electron/api/context.ts @@ -2,7 +2,7 @@ import type { BrowserWindow } from 'electron'; import type { OpencodeManager } from '../opencode/manager'; import type { OpencodeProjectStore } from '../opencode/project-store'; import type { HostEventBus } from './event-bus'; -import type { createWorksCloudDeployment } from '../services/works-cloud-deployment'; +import type { createWorksSubmissionBindingStore } from '../services/works-submission-binding'; import type { DesignWorkspaceModule } from '../image-workspace/module'; import type { AgentBrowserBounds, @@ -12,7 +12,7 @@ import type { AgentBrowserSnapshot, } from '../../shared/agent-browser'; -export type WorksCloudDeploymentCoordinator = ReturnType; +export type WorksSubmissionBindingStore = ReturnType; export interface AgentBrowserService { getSnapshot(projectPath?: string): Promise | AgentBrowserSnapshot; @@ -64,6 +64,6 @@ export interface HostApiContext { eventBus: HostEventBus; mainWindow: BrowserWindow | null; agentBrowser?: AgentBrowserService; - worksCloudDeployment?: WorksCloudDeploymentCoordinator; + worksSubmissionBinding?: WorksSubmissionBindingStore; imageWorkspace?: DesignWorkspaceModule; } diff --git a/electron/api/routes/device-preview.ts b/electron/api/routes/device-preview.ts index d6c8c5f..561fc10 100644 --- a/electron/api/routes/device-preview.ts +++ b/electron/api/routes/device-preview.ts @@ -5,7 +5,7 @@ import { hasRendererCapability } from '../renderer-capability'; import { WORKS_SQUARE_CONFIG } from '../works-config'; import { getValidWorksSquareAccessToken } from '../../services/works-square-session'; import { proxyAwareFetch } from '../../utils/proxy-fetch'; -import type { WorksCloudDeploymentRecord } from '../../../shared/works-cloud-deployment'; +import type { WorksSubmissionBindingRecord } from '../../../shared/works-submission-binding'; import type { DevicePreviewSnapshot } from '../../../shared/device-preview'; type RemoteProjectSnapshot = { @@ -66,7 +66,7 @@ function projectFromPayload(payload: unknown): RemoteProjectSnapshot | null { return { appId: readString(project.app_id), playable: typeof project.playable === 'boolean' ? project.playable : null, - runtimeUrl: readString(project.runtime_url) ?? readString(project.play_url), + runtimeUrl: readString(project.play_url) ?? readString(project.runtime_url), runtimeVersionName: readString(project.version_name), latestVersionId: readString(latestVersion?.id) ?? readString(latestVersion?.version_id), latestVersionName: readString(latestVersion?.version_name), @@ -214,7 +214,7 @@ async function createOwnedReleasePreview( function localSnapshot( projectId: string, - deployment: WorksCloudDeploymentRecord | null, + deployment: WorksSubmissionBindingRecord | null, ): Omit { return { projectId, @@ -226,19 +226,13 @@ function localSnapshot( }; } -function buildingMessage(status: WorksCloudDeploymentRecord['status']): string { - if (status === 'waiting_for_login') return '正在等待登录状态恢复,恢复后会继续提交预览版本。'; - if (status === 'uploading') return '预览包正在上传到服务端。'; - return '正在等待新的预览包与安全检查完成。'; -} - function normalizedReviewStatus(value: string | null): string | null { return value?.trim().toLowerCase() || null; } async function resolveDevicePreview( projectId: string, - deployment: WorksCloudDeploymentRecord | null, + deployment: WorksSubmissionBindingRecord | null, ): Promise { if (!deployment) { return { @@ -258,19 +252,11 @@ async function resolveDevicePreview( const base = localSnapshot(projectId, deployment); - if (deployment.status === 'failed') { + if (deployment.status === 'legacy_retired') { return { ...base, state: 'unavailable', - message: deployment.error || '预览版本生成失败,请重新生成。', - }; - } - - if (deployment.status !== 'submitted') { - return { - ...base, - state: 'building', - message: buildingMessage(deployment.status), + message: deployment.message || '旧版自动部署任务已停用,请重新提交审核。', }; } @@ -438,8 +424,8 @@ export async function handleDevicePreviewRoutes( return true; } - const deployment = ctx.worksCloudDeployment - ? await ctx.worksCloudDeployment.get(projectId) + const deployment = ctx.worksSubmissionBinding + ? await ctx.worksSubmissionBinding.get(projectId) : null; const preview = await resolveDevicePreview(projectId, deployment); sendJson(res, 200, { success: true, preview }); diff --git a/electron/api/routes/opencode.ts b/electron/api/routes/opencode.ts index ae90f7e..93add09 100644 --- a/electron/api/routes/opencode.ts +++ b/electron/api/routes/opencode.ts @@ -14,8 +14,6 @@ import { type RevertOpencodeSessionMessageInput, type SendOpencodeSessionMessageInput, } from '../../opencode/client'; -import { readWorksPublishFile } from '../../opencode/works-publish-file'; -import { readWorksDeployCheck } from '../../opencode/works-square-deploy-check'; import { buildOpencodeRuntimeConfigSummaryFromNianCodeProviders, type OpencodeRuntimeConfigSummary, @@ -1128,67 +1126,6 @@ export async function handleOpencodeRoutes( return true; } - const worksPublishMatch = url.pathname.match(/^\/api\/opencode\/projects\/([^/]+)\/works-publish$/); - if (worksPublishMatch && req.method === 'GET') { - try { - const projectId = decodeURIComponent(worksPublishMatch[1] ?? ''); - if (!projectId) throw new Error('Missing project id'); - const project = await findProjectById(ctx, projectId); - if (!project) throw new Error('Project not found'); - sendJson(res, 200, await readWorksPublishFile(project.path)); - } catch (error) { - sendJson(res, 500, { - status: 'invalid', - error: error instanceof Error ? error.message : String(error), - }); - } - return true; - } - - const worksDeployCheckMatch = url.pathname.match(/^\/api\/opencode\/projects\/([^/]+)\/works-deploy-check$/); - if (worksDeployCheckMatch && req.method === 'GET') { - try { - const projectId = decodeURIComponent(worksDeployCheckMatch[1] ?? ''); - if (!projectId) throw new Error('Missing project id'); - const project = await findProjectById(ctx, projectId); - if (!project) throw new Error('Project not found'); - const publish = await readWorksPublishFile(project.path); - sendJson( - res, - 200, - await readWorksDeployCheck(project.path, publish.status === 'ready' ? publish.publish : null), - ); - } catch (error) { - sendJson(res, 500, { - status: 'invalid', - filePath: '', - error: error instanceof Error ? error.message : String(error), - }); - } - return true; - } - - const worksCloudDeployMatch = url.pathname.match(/^\/api\/opencode\/projects\/([^/]+)\/works-cloud-deploy$/); - if (worksCloudDeployMatch && (req.method === 'GET' || req.method === 'POST')) { - try { - const projectId = decodeURIComponent(worksCloudDeployMatch[1] ?? ''); - if (!projectId) throw new Error('Missing project id'); - if (!ctx.worksCloudDeployment) throw new Error('Cloud deployment coordinator is unavailable'); - if (req.method === 'POST') { - const deployment = await ctx.worksCloudDeployment.arm(projectId); - sendJson(res, 202, { success: true, deployment }); - } else { - sendJson(res, 200, { success: true, deployment: await ctx.worksCloudDeployment.get(projectId) }); - } - } catch (error) { - sendJson(res, 500, { - success: false, - error: error instanceof Error ? error.message : String(error), - }); - } - return true; - } - if (url.pathname === '/api/opencode/projects/remove' && req.method === 'POST') { try { const body = await parseJsonBody<{ projectId?: string }>(req); diff --git a/electron/api/routes/works.ts b/electron/api/routes/works.ts index 42f2773..9f1d4d5 100644 --- a/electron/api/routes/works.ts +++ b/electron/api/routes/works.ts @@ -2,13 +2,11 @@ import type { IncomingMessage, ServerResponse } from 'http'; import { randomUUID } from 'node:crypto'; import { lstat, mkdir, mkdtemp, open, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { basename, extname, isAbsolute, join, normalize, resolve } from 'node:path'; +import { join } from 'node:path'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; import { proxyAwareFetch } from '../../utils/proxy-fetch'; import { WORKS_SQUARE_CONFIG } from '../works-config'; -import { readWorksDeployCheck } from '../../opencode/works-square-deploy-check'; -import { readWorksPublishFile } from '../../opencode/works-publish-file'; import { createStaticProjectPackage, ProjectPackageError, @@ -21,14 +19,6 @@ type CreateProjectInput = { project?: unknown; }; -type UploadProjectVersionInput = { - accessToken?: unknown; - projectId?: unknown; - versionName?: unknown; - changeLog?: unknown; - zipFilePath?: unknown; -}; - type PublishProjectSourceInput = { projectId?: unknown; project?: unknown; @@ -62,10 +52,6 @@ function readOptionalString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value.trim() : undefined; } -function resolveProjectFilePath(projectPath: string, value: string): string { - return isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) ? value : resolve(projectPath, value); -} - function readRequiredHeader(req: IncomingMessage, name: string): string { const value = req.headers[name.toLowerCase()]; const firstValue = Array.isArray(value) ? value[0] : value; @@ -209,6 +195,7 @@ function projectSafeProject(value: unknown): Record | null { 'difficulty', 'status', 'updated_at', + 'play_url', 'runtime_url', 'creator_name', 'buddy_name', @@ -629,76 +616,6 @@ async function handleGetMyProjectStatus( sendJson(res, response.status, { success: true, status: statusPayload }); } -async function createArchiveFormData( - body: UploadProjectVersionInput, - ctx: HostApiContext, - appId: string, -): Promise { - const versionName = readRequiredString(body.versionName, 'versionName'); - const changeLog = readRequiredString(body.changeLog, 'changeLog'); - const zipFilePath = readRequiredString(body.zipFilePath, 'zipFilePath'); - const projectId = readRequiredString(body.projectId, 'projectId'); - const project = (await ctx.opencodeProjectStore.listProjects()).find((item) => item.id === projectId); - if (!project) throw new Error('Project not found'); - const publish = await readWorksPublishFile(project.path); - if (publish.status !== 'ready') throw new Error('BLOCKED: works-publish.json is missing or invalid'); - if (publish.publish.app_id !== appId) throw new Error('BLOCKED: upload app id does not match project publish data'); - const resolvedZipPath = resolveProjectFilePath(project.path, zipFilePath); - const publishZipPath = resolveProjectFilePath(project.path, publish.publish.zip_file_path); - if (normalize(publishZipPath).toLowerCase() !== normalize(resolvedZipPath).toLowerCase()) { - throw new Error('BLOCKED: upload zip path does not match project publish data'); - } - const deployCheck = await readWorksDeployCheck(project.path, publish.publish); - if (deployCheck.status !== 'pass' && deployCheck.status !== 'warning') { - throw new Error(`BLOCKED: ${deployCheck.error || 'deployment checks did not pass'}`); - } - if (extname(resolvedZipPath).toLowerCase() !== '.zip') { - throw new Error('zipFilePath must point to a .zip file'); - } - - const archiveStat = await stat(resolvedZipPath); - if (!archiveStat.isFile()) { - throw new Error('zipFilePath must point to a file'); - } - - const archiveBytes = await readFile(resolvedZipPath); - const archiveBlob = new Blob([new Uint8Array(archiveBytes)], { type: 'application/zip' }); - const form = new FormData(); - form.set('version_name', versionName); - form.set('change_log', changeLog); - form.set('archive', archiveBlob, basename(resolvedZipPath)); - return form; -} - -async function handleUploadProjectVersion( - req: IncomingMessage, - res: ServerResponse, - appId: string, - ctx: HostApiContext, -): Promise { - const body = await parseJsonBody(req); - const accessToken = readRequiredString(body.accessToken, 'accessToken'); - const form = await createArchiveFormData(body, ctx, appId); - - const response = await proxyAwareFetch( - createWorksUrl(`/api/projects/${encodeURIComponent(appId)}/versions/upload`).toString(), - { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - }, - body: form, - }, - ); - - if (!response.ok) { - await sendUpstreamError(res, response, `Works Square upload failed (${response.status})`); - return; - } - - sendJson(res, response.status, { success: true, upload: await readResponsePayload(response) }); -} - const SOURCE_PUBLISH_CHANGE_LOG = '通过 Makelore 一键提交'; const RETRYABLE_SOURCE_UPLOAD_STATUSES = new Set([408, 502, 503, 504]); const VERSION_FILE_MAX_BYTES = 64 * 1024; @@ -924,9 +841,9 @@ async function handlePublishProjectSource( ); return; } - if (ctx.worksCloudDeployment) { + if (ctx.worksSubmissionBinding) { try { - await ctx.worksCloudDeployment.recordSubmitted(projectId, { + await ctx.worksSubmissionBinding.recordSubmitted(projectId, { appId, versionId: uploadPayload.version_id, versionName, @@ -1115,12 +1032,6 @@ export async function handleWorksRoutes( return true; } - const uploadMatch = url.pathname.match(/^\/api\/works\/projects\/([^/]+)\/versions\/upload$/); - if (uploadMatch && req.method === 'POST') { - await handleUploadProjectVersion(req, res, decodeURIComponent(uploadMatch[1]), ctx); - return true; - } - sendJson(res, 404, { success: false, error: `No route for ${req.method} ${url.pathname}` }); return true; } catch (error) { diff --git a/electron/main/index.ts b/electron/main/index.ts index a72b11d..2c731fc 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -69,7 +69,7 @@ import { HostEventBus } from '../api/event-bus'; import { AgentBrowserModule, ElectronAgentBrowserAdapter } from '../agent-browser'; import { browserOAuthManager } from '../utils/browser-oauth'; import { createProjectProgressSync } from '../services/project-progress-sync'; -import { createWorksCloudDeployment } from '../services/works-cloud-deployment'; +import { createWorksSubmissionBindingStore } from '../services/works-submission-binding'; import { consumeWorksSquareStartupRuntimeCleanupRequired, getWorksSquareSessionRestoreStatus, @@ -164,7 +164,7 @@ let opencodeProjectStore!: OpencodeProjectStore; let hostEventBus!: HostEventBus; let hostApiServer: Server | null = null; let projectProgressSync: ReturnType | null = null; -let worksCloudDeployment: ReturnType | null = null; +let worksSubmissionBinding: ReturnType | null = null; let agentBrowser: AgentBrowserModule | null = null; let imageWorkspaceModule: DesignWorkspaceModule | null = null; const mainWindowFocusState = createMainWindowFocusState(); @@ -447,7 +447,7 @@ async function initialize(): Promise { if (!isE2EMode) { projectProgressSync = createProjectProgressSync(opencodeProjectStore); } - worksCloudDeployment = createWorksCloudDeployment(opencodeProjectStore); + worksSubmissionBinding = createWorksSubmissionBindingStore(opencodeProjectStore); const localImageWorkspaceEnabled = isLocalImageWorkspaceDevelopmentEnabled({ isPackaged: app.isPackaged, @@ -483,7 +483,7 @@ async function initialize(): Promise { agentBrowser = new AgentBrowserModule(new ElectronAgentBrowserAdapter(window)); if (!isE2EMode) { void projectProgressSync?.start(); - void worksCloudDeployment.start(); + void worksSubmissionBinding.start(); } // Create system tray @@ -500,7 +500,7 @@ async function initialize(): Promise { eventBus: hostEventBus, mainWindow: window, agentBrowser, - worksCloudDeployment: worksCloudDeployment ?? undefined, + worksSubmissionBinding: worksSubmissionBinding ?? undefined, imageWorkspace, }); @@ -701,7 +701,6 @@ if (gotTheLock) { hostEventBus.closeAll(); hostApiServer?.close(); projectProgressSync?.stop(); - worksCloudDeployment?.stop(); const stopOpencodePromise = opencodeManager.stop().catch((err) => { logger.warn('opencodeManager.stop() error during quit:', err); @@ -736,7 +735,6 @@ if (gotTheLock) { const emergencyRuntimeCleanup = (reason: string, error: unknown): void => { logger.error(`${reason}:`, error); projectProgressSync?.stop(); - worksCloudDeployment?.stop(); try { void agentBrowser?.dispose().catch(() => { /* ignore */ }); } catch { diff --git a/electron/opencode/superpowers.ts b/electron/opencode/superpowers.ts index 5a79b80..eb506f9 100644 --- a/electron/opencode/superpowers.ts +++ b/electron/opencode/superpowers.ts @@ -35,7 +35,6 @@ const SUPERPOWERS_BUNDLES_DIR = 'superpowers-bundles'; const SUPERPOWERS_ACTIVE_MANIFEST = 'superpowers-active.json'; export const BUNDLED_COURSE_SKILL_IDS = [ 'agent-browser', - 'deploy-publish-check', 'designer-design-spec', 'dev-build-test', 'game-assets', @@ -48,7 +47,11 @@ export const BUNDLED_COURSE_SKILL_IDS = [ 'youth-plain-language', 'youth-ai-product-course', ] as const; -const RETIRED_COURSE_SKILL_IDS = ['course-stage-review', 'student-growth-logger'] as const; +const RETIRED_COURSE_SKILL_IDS = [ + 'course-stage-review', + 'deploy-publish-check', + 'student-growth-logger', +] as const; export function resolveBundledSuperpowersDir(input: BundledSuperpowersPathInput): string { const resourcesDir = input.isPackaged diff --git a/electron/opencode/works-publish-file.ts b/electron/opencode/works-publish-file.ts deleted file mode 100644 index befa0da..0000000 --- a/electron/opencode/works-publish-file.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { constants } from 'node:fs'; -import { access, readFile } from 'node:fs/promises'; -import { extname, join } from 'node:path'; - -export const WORKS_PUBLISH_FILE_NAME = 'works-publish.json'; - -export type WorksPublishData = { - app_id: string; - title: string; - summary: string; - category: string; - age_band: string; - difficulty: string; - version_name: string; - change_log: string; - zip_file_path: string; -}; - -export type WorksPublishReadResult = - | { status: 'ready'; filePath: string; publish: WorksPublishData } - | { status: 'missing'; filePath: string } - | { status: 'invalid'; filePath: string; error: string }; - -function readString(record: Record, key: string): string { - const value = record[key]; - return typeof value === 'string' ? value.trim() : ''; -} - -function readRequiredString(record: Record, key: string): string { - const value = readString(record, key); - if (!value) { - throw new Error(`Missing ${key} in ${WORKS_PUBLISH_FILE_NAME}`); - } - return value; -} - -function parsePublishRecord(value: unknown): WorksPublishData { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new Error(`${WORKS_PUBLISH_FILE_NAME} must contain a JSON object`); - } - - const record = value as Record; - const appId = readRequiredString(record, 'app_id'); - const zipFilePath = readRequiredString(record, 'zip_file_path'); - if (extname(zipFilePath).toLowerCase() !== '.zip') { - throw new Error('zip_file_path must point to a .zip file'); - } - - return { - app_id: appId, - title: readRequiredString(record, 'title'), - summary: readRequiredString(record, 'summary'), - category: readRequiredString(record, 'category'), - age_band: readRequiredString(record, 'age_band'), - difficulty: readRequiredString(record, 'difficulty'), - version_name: readRequiredString(record, 'version_name'), - change_log: readRequiredString(record, 'change_log'), - zip_file_path: zipFilePath, - }; -} - -export async function readWorksPublishFile(projectPath: string): Promise { - const filePath = join(projectPath, WORKS_PUBLISH_FILE_NAME); - - try { - await access(filePath, constants.F_OK); - } catch { - return { status: 'missing', filePath }; - } - - try { - const raw = await readFile(filePath, 'utf8'); - return { - status: 'ready', - filePath, - publish: parsePublishRecord(JSON.parse(raw) as unknown), - }; - } catch (error) { - return { - status: 'invalid', - filePath, - error: error instanceof Error ? error.message : String(error), - }; - } -} diff --git a/electron/opencode/works-square-deploy-check.ts b/electron/opencode/works-square-deploy-check.ts deleted file mode 100644 index 21691e8..0000000 --- a/electron/opencode/works-square-deploy-check.ts +++ /dev/null @@ -1,735 +0,0 @@ -import { createHash } from 'node:crypto'; -import { execFile } from 'node:child_process'; -import { access, readFile, stat } from 'node:fs/promises'; -import { createRequire } from 'node:module'; -import path from 'node:path'; -import { promisify } from 'node:util'; -import { parse as parseYaml } from 'yaml'; -import type { WorksPublishData } from './works-publish-file'; -import { - GAME_CANVAS_DYNAMIC_WORKS_DEPLOY_CHECK_ID, - CONDITIONAL_DYNAMIC_WORKS_DEPLOY_CHECK_IDS, - REQUIRED_DYNAMIC_WORKS_DEPLOY_CHECK_IDS, - WORKS_DEPLOY_CHECK_FILE_NAME, - WORKS_DEPLOY_CHECK_SCHEMA_VERSION, - WORKS_DEPLOY_REPORT_FILE_NAME, - type WorksCanvasEvidence, - type WorksCanvasMeasurement, - type WorksDeployCheckItem, - type WorksDeployCheckReport, - type WorksDeployCheckResult, - type WorksDeployCheckExecution, - type WorksDeployCheckStatus, -} from '../../shared/works-square-deploy-check'; - -const AdmZip = createRequire(import.meta.url)('adm-zip') as typeof import('adm-zip'); -const execFileAsync = promisify(execFile); - -const MAX_ZIP_BYTES = 50 * 1024 * 1024; -const MAX_UNCOMPRESSED_BYTES = 200 * 1024 * 1024; -const MAX_ZIP_ENTRIES = 2_000; -const MAX_SCANNED_TEXT_BYTES = 32 * 1024 * 1024; - -const FORBIDDEN_RUNTIME_ACTIONS = /\b(?:npm\s+(?:install|ci|build|run\s+build)|pnpm\s+(?:install|i|build|run\s+build)|yarn\s+(?:install|build|run\s+build)|bun\s+(?:install|build|run\s+build)|pip\s+install|uv\s+sync|flutter\s+build)\b/i; -const STORAGE_ACCESS = /\b(?:localStorage|sessionStorage|document\.cookie|indexedDB|navigator\.serviceWorker|serviceWorker\.register)\b/i; -const PRIVATE_KEY_CONTENT = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/i; -const HIGH_CONFIDENCE_SECRET = /\b(?:AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|sk-[A-Za-z0-9]{20,})\b/; -const ENVIRONMENT_CAPABILITY_UNAVAILABLE = /(?:docker\s+(?:is\s+)?not\s+installed|docker\s+unavailable|no\s+(?:browser\s+automation|playwright|puppeteer)\s+available|(?:playwright|puppeteer|browser\s+automation)\s+unavailable)/i; -const DYNAMIC_CHECK_IDS = new Set([ - ...REQUIRED_DYNAMIC_WORKS_DEPLOY_CHECK_IDS, - ...CONDITIONAL_DYNAMIC_WORKS_DEPLOY_CHECK_IDS, -]); - -const EXCLUDED_ENTRY = /^(?:\.git(?:\/|$)|node_modules(?:\/|$)|dist(?:\/|$)|build(?:\/|$)|\.next(?:\/|$)|\.nuxt(?:\/|$)|\.vite(?:\/|$)|\.dart_tool(?:\/|$)|\.pub-cache(?:\/|$)|android\/\.gradle(?:\/|$)|ios\/Pods(?:\/|$)|\.venv(?:\/|$)|venv(?:\/|$)|.*\/__pycache__(?:\/|$)|__pycache__(?:\/|$)|.*\.pyc$|.*\.DS_Store$)/i; -const SENSITIVE_ENTRY = /(?:^|\/)(?:\.env(?!\.example$)[^/]*|id_rsa(?:\.[^/]*)?|id_ed25519(?:\.[^/]*)?|credentials?\.(?:json|ya?ml)|service[-_]?account\.(?:json|ya?ml)|.*\.(?:pem|key|p12|pfx|jks))$/i; -const TEXT_ENTRY = /\.(?:c|cc|css|csv|html?|js|jsx|json|md|mjs|mts|py|scss|sh|sql|toml|ts|tsx|vue|yml|yaml|lock|conf|ini|txt|xml)$/i; - -type UnknownRecord = Record; -type ArchiveEntry = ReturnType[number]; -type ArchiveIndex = Map; -export type WorksDeployCheckMode = 'local' | 'cloud'; - -function asRecord(value: unknown): UnknownRecord | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null; - return value as UnknownRecord; -} - -function readString(record: UnknownRecord, key: string): string { - return typeof record[key] === 'string' ? record[key].trim() : ''; -} - -function readRequiredString(record: UnknownRecord, key: string): string { - const value = readString(record, key); - if (!value) throw new Error(`Missing ${key}`); - return value; -} - -function isStatus(value: unknown): value is WorksDeployCheckStatus { - return value === 'PASS' || value === 'SKIPPED' || value === 'BLOCKED'; -} - -function normalizeCheckStatus(id: string, status: WorksDeployCheckStatus, detail: string): WorksDeployCheckStatus { - if (status !== 'BLOCKED' || !DYNAMIC_CHECK_IDS.has(id)) return status; - return ENVIRONMENT_CAPABILITY_UNAVAILABLE.test(detail) ? 'SKIPPED' : status; -} - -function normalizeCanvasMeasurement(value: unknown): WorksCanvasMeasurement | null { - const record = asRecord(value); - const width = record?.width; - const height = record?.height; - if (typeof width !== 'number' || !Number.isFinite(width) || width <= 0) return null; - if (typeof height !== 'number' || !Number.isFinite(height) || height <= 0) return null; - return { width, height }; -} - -function normalizeCanvasEvidence(value: unknown): WorksCanvasEvidence | null { - const record = asRecord(value); - const logicalResolution = normalizeCanvasMeasurement(record?.logical_resolution); - const desktopViewport = normalizeCanvasMeasurement(record?.desktop_viewport); - const mobileViewport = normalizeCanvasMeasurement(record?.mobile_viewport); - const desktopCanvas = normalizeCanvasMeasurement(record?.desktop_canvas); - const mobileCanvas = normalizeCanvasMeasurement(record?.mobile_canvas); - const expectedMaxCanvas = asRecord(record?.expected_max_canvas); - const expectedDesktop = normalizeCanvasMeasurement(expectedMaxCanvas?.desktop); - const expectedMobile = normalizeCanvasMeasurement(expectedMaxCanvas?.mobile); - if (!logicalResolution || !desktopViewport || !mobileViewport || !desktopCanvas || !mobileCanvas) return null; - if (!expectedDesktop || !expectedMobile || typeof record?.resize_verified !== 'boolean') return null; - return { - logical_resolution: logicalResolution, - desktop_viewport: desktopViewport, - mobile_viewport: mobileViewport, - desktop_canvas: desktopCanvas, - mobile_canvas: mobileCanvas, - expected_max_canvas: { - desktop: expectedDesktop, - mobile: expectedMobile, - }, - resize_verified: record.resize_verified, - }; -} - -function normalizeReport(value: unknown): WorksDeployCheckReport { - const record = asRecord(value); - if (!record) throw new Error(`${WORKS_DEPLOY_CHECK_FILE_NAME} must contain a JSON object`); - if (record.schema_version !== WORKS_DEPLOY_CHECK_SCHEMA_VERSION) { - throw new Error(`${WORKS_DEPLOY_CHECK_FILE_NAME} has unsupported schema_version`); - } - - const status = record.status; - if (!isStatus(status)) throw new Error(`${WORKS_DEPLOY_CHECK_FILE_NAME} has invalid status`); - const checkedAt = readRequiredString(record, 'checked_at'); - const zipFilePath = readRequiredString(record, 'zip_file_path'); - const zipSha256 = readRequiredString(record, 'zip_sha256').toLowerCase(); - if (!/^[a-f0-9]{64}$/.test(zipSha256)) throw new Error('zip_sha256 must be a SHA-256 hex string'); - - const checksRecord = asRecord(record.checks); - if (!checksRecord) throw new Error(`${WORKS_DEPLOY_CHECK_FILE_NAME} is missing checks`); - const checks: Record = {}; - for (const [id, rawCheck] of Object.entries(checksRecord)) { - const check = asRecord(rawCheck); - if (!check || !isStatus(check.status) || !readString(check, 'detail')) { - throw new Error(`${WORKS_DEPLOY_CHECK_FILE_NAME} has invalid check: ${id}`); - } - const execution = check.execution; - if (execution !== undefined && execution !== 'executed' && execution !== 'unavailable') { - throw new Error(`${WORKS_DEPLOY_CHECK_FILE_NAME} has invalid execution for check: ${id}`); - } - const canvasEvidence = id === GAME_CANVAS_DYNAMIC_WORKS_DEPLOY_CHECK_ID && check.canvas_evidence !== undefined - ? normalizeCanvasEvidence(check.canvas_evidence) - : undefined; - if (id === GAME_CANVAS_DYNAMIC_WORKS_DEPLOY_CHECK_ID && check.canvas_evidence !== undefined && !canvasEvidence) { - throw new Error(`${WORKS_DEPLOY_CHECK_FILE_NAME} has invalid canvas_evidence`); - } - const detail = readString(check, 'detail'); - checks[id] = { - status: normalizeCheckStatus(id, check.status, detail), - detail, - ...(readString(check, 'command') ? { command: readString(check, 'command') } : {}), - ...(readString(check, 'checked_at') ? { checked_at: readString(check, 'checked_at') } : {}), - ...(execution ? { execution: execution as WorksDeployCheckExecution } : {}), - ...(canvasEvidence ? { canvas_evidence: canvasEvidence } : {}), - }; - } - - for (const id of REQUIRED_DYNAMIC_WORKS_DEPLOY_CHECK_IDS) { - if (!checks[id]) throw new Error(`${WORKS_DEPLOY_CHECK_FILE_NAME} is missing check: ${id}`); - if (!checks[id]?.command) throw new Error(`${WORKS_DEPLOY_CHECK_FILE_NAME} is missing command for check: ${id}`); - } - for (const id of CONDITIONAL_DYNAMIC_WORKS_DEPLOY_CHECK_IDS) { - if (checks[id] && !checks[id]?.command) throw new Error(`${WORKS_DEPLOY_CHECK_FILE_NAME} is missing command for check: ${id}`); - } - - const normalizedStatus = Object.values(checks).some((check) => check.status === 'BLOCKED') - ? 'BLOCKED' - : Object.values(checks).some((check) => check.status === 'SKIPPED') - ? 'SKIPPED' - : status; - - return { - schema_version: WORKS_DEPLOY_CHECK_SCHEMA_VERSION, - status: normalizedStatus, - checked_at: checkedAt, - zip_file_path: zipFilePath, - zip_sha256: zipSha256, - checks, - }; -} - -function normalizeArchivePath(value: string): string | null { - const replaced = value.replaceAll('\\', '/'); - const withoutTrailingSlash = replaced.endsWith('/') ? replaced.slice(0, -1) : replaced; - if (!withoutTrailingSlash || withoutTrailingSlash.startsWith('/') || /^[A-Za-z]:\//.test(withoutTrailingSlash)) return null; - const parts = withoutTrailingSlash.split('/'); - if (parts.some((part) => !part || part === '.' || part === '..' || part.includes('\0'))) return null; - return parts.join('/'); -} - -function isSymlink(entry: ArchiveEntry): boolean { - const unixMode = (entry.attr >>> 16) & 0xffff; - return (unixMode & 0xf000) === 0xa000; -} - -function isTextEntry(name: string): boolean { - return TEXT_ENTRY.test(name) || name.endsWith('Dockerfile') || name.endsWith('nginx.conf'); -} - -function safePath(value: string): string | null { - const normalized = value.replaceAll('\\', '/').trim(); - if (!normalized || normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized)) return null; - const parts = normalized.split('/'); - if (parts.some((part) => !part || part === '..')) return null; - return parts.filter((part) => part !== '.').join('/'); -} - -function joinArchivePath(directory: string, file: string): string | null { - const safeDirectory = safePath(directory) ?? ''; - const safeFile = safePath(file); - if (safeFile === null) return null; - return [safeDirectory, safeFile].filter(Boolean).join('/'); -} - -function getBuildSpec(service: UnknownRecord): { context: string; dockerfile: string } | null { - const build = service.build; - if (typeof build === 'string') return { context: build, dockerfile: 'Dockerfile' }; - const buildRecord = asRecord(build); - if (!buildRecord) return null; - return { - context: readString(buildRecord, 'context') || '.', - dockerfile: readString(buildRecord, 'dockerfile') || 'Dockerfile', - }; -} - -function valuesAsList(value: unknown): unknown[] { - return Array.isArray(value) ? value : value === undefined ? [] : [value]; -} - -function stringifyCommand(value: unknown): string { - if (typeof value === 'string') return value; - if (Array.isArray(value)) return value.filter((item): item is string => typeof item === 'string').join(' '); - return ''; -} - -function isWindowsAbsolute(value: string): boolean { - return /^[A-Za-z]:[\\/]/.test(value); -} - -function resolveZipPath(projectPath: string, value: string): string { - return path.isAbsolute(value) || isWindowsAbsolute(value) ? value : path.resolve(projectPath, value); -} - -function samePath(left: string, right: string): boolean { - return path.normalize(left).toLowerCase() === path.normalize(right).toLowerCase(); -} - -function extractPort(value: unknown): { hostIp: string; hostPort: string; containerPort: string } | null { - if (typeof value === 'object' && value !== null && !Array.isArray(value)) { - const record = value as UnknownRecord; - return { - hostIp: readString(record, 'host_ip'), - hostPort: record.published === undefined ? '' : String(record.published), - containerPort: record.target === undefined ? '' : String(record.target), - }; - } - if (typeof value !== 'string') return null; - const parts = value.trim().split(':'); - if (parts.length === 3) return { hostIp: parts[0] ?? '', hostPort: parts[1] ?? '', containerPort: parts[2] ?? '' }; - if (parts.length === 2) return { hostIp: '', hostPort: parts[0] ?? '', containerPort: parts[1] ?? '' }; - return { hostIp: '', hostPort: '', containerPort: parts[0] ?? '' }; -} - -function volumeSource(value: unknown): { source: string; type: string } | null { - if (typeof value === 'string') { - const parts = value.split(':'); - if (parts.length < 2) return { source: '', type: 'anonymous' }; - return { source: parts.slice(0, -1).join(':'), type: 'string' }; - } - const record = asRecord(value); - if (!record) return null; - return { source: readString(record, 'source'), type: readString(record, 'type') || 'volume' }; -} - -function hasSafeStorageAdapter(text: string): boolean { - if (/safeStorage|safe-storage|safe_storage/i.test(text)) return true; - return /try\s*\{[\s\S]{0,4000}(?:localStorage|sessionStorage|document\.cookie|indexedDB|serviceWorker)[\s\S]{0,4000}\}\s*catch\s*(?:\([^)]*\))?\s*\{/i.test(text); -} - -function hasUnsafeComposeEnvironment(value: unknown): boolean { - const text = JSON.stringify(value ?? ''); - for (const match of text.matchAll(/\$\{([^}]+)\}/g)) { - const expression = match[1]?.trim() ?? ''; - const defaultMatch = expression.match(/^[A-Za-z_][A-Za-z0-9_]*:-(.+)$/); - if (!defaultMatch || !defaultMatch[1]?.trim() || HIGH_CONFIDENCE_SECRET.test(defaultMatch[1])) return true; - } - return /\$(?!\{)[A-Za-z_][A-Za-z0-9_]*/.test(text); -} - -function hasSafeEnvFile(value: unknown, index: ArchiveIndex): boolean { - const files = valuesAsList(value); - return files.length > 0 && files.every((item) => { - if (typeof item !== 'string') return false; - const safeName = safePath(item); - return safeName !== null && Boolean(index.get(safeName)) && !SENSITIVE_ENTRY.test(safeName); - }); -} - -function sourceTextEntries(textEntries: Map): string[] { - return [...textEntries.entries()] - .filter(([name]) => !/\.(?:md|lock)$/i.test(name)) - .map(([, text]) => text); -} - -function isCanvasProject(publish: WorksPublishData, index: ArchiveIndex, textEntries: Map): boolean { - if (/(?:game|canvas|游戏|画布)/i.test(publish.category)) return true; - const names = [...index.keys()].filter((name) => /(?:game|canvas|phaser|webgl)[^/]*\.(?:css|html?|js|jsx|ts|tsx|vue)$/i.test(name)); - const sourceText = sourceTextEntries(textEntries).join('\n'); - return names.length > 0 || /): { pass: boolean; detail: string } { - const sourceText = sourceTextEntries(textEntries).join('\n'); - const hasRootSelector = /(?:^|[,{\s])(?:html|body|#(?:app|root|game-container))\b/i.test(sourceText); - const hasFullWidth = /\bwidth\s*:\s*100%/i.test(sourceText); - const hasFullHeight = /\bheight\s*:\s*100%/i.test(sourceText); - const hasResponsiveScale = /Phaser\.Scale\.(?:FIT|RESIZE)|(?:mode|scaleMode)\s*[:=]\s*(?:Phaser\.Scale\.)?(?:FIT|RESIZE)|ResizeObserver|(?:addEventListener|on)\s*\(\s*['"]resize|(?:window\.)?resize\s*\(/i.test(sourceText); - const hasFixedCanvas = /(?:canvas|#(?:game-container|app|root))[^{}]*\{[^}]*\b(?:width|height)\s*:\s*\d+(?:\.\d+)?px[^}]*\}/is.test(sourceText); - const missing: string[] = []; - if (!hasRootSelector || !hasFullWidth || !hasFullHeight) missing.push('根节点未声明 width/height: 100%'); - if (!hasResponsiveScale) missing.push('未发现 FIT/RESIZE 或 resize/ResizeObserver 自适应逻辑'); - if (hasFixedCanvas) missing.push('发现固定像素 canvas 或游戏容器尺寸'); - return missing.length === 0 - ? { pass: true, detail: '游戏/画布项目具备根节点全屏和响应式画布布局线索' } - : { pass: false, detail: `游戏/画布项目响应式布局不完整:${missing.join('、')}` }; -} - -function expectedCanvasSize(logical: WorksCanvasMeasurement, viewport: WorksCanvasMeasurement): WorksCanvasMeasurement { - const scale = Math.min(viewport.width / logical.width, viewport.height / logical.height); - return { - width: logical.width * scale, - height: logical.height * scale, - }; -} - -function measurementMatchesExpected(actual: WorksCanvasMeasurement, expected: WorksCanvasMeasurement): boolean { - return actual.width >= expected.width * 0.95 - && actual.height >= expected.height * 0.95 - && actual.width <= expected.width * 1.05 - && actual.height <= expected.height * 1.05; -} - -function validateCanvasEvidence(evidence: WorksCanvasEvidence): string | null { - if (!evidence.resize_verified) return '游戏/画布项目未提供 viewport/全屏变化后的重新布局证据'; - const expectedDesktop = expectedCanvasSize(evidence.logical_resolution, evidence.desktop_viewport); - const expectedMobile = expectedCanvasSize(evidence.logical_resolution, evidence.mobile_viewport); - if (!measurementMatchesExpected(evidence.expected_max_canvas.desktop, expectedDesktop)) return '桌面视口的最大等比尺寸记录不一致'; - if (!measurementMatchesExpected(evidence.expected_max_canvas.mobile, expectedMobile)) return '移动视口的最大等比尺寸记录不一致'; - if (!measurementMatchesExpected(evidence.desktop_canvas, expectedDesktop)) return '桌面主画布比最大等比尺寸小超过 5% 或溢出'; - if (!measurementMatchesExpected(evidence.mobile_canvas, expectedMobile)) return '移动主画布比最大等比尺寸小超过 5% 或溢出'; - return null; -} - -function checkCanvasReportEvidence(report: WorksDeployCheckReport, mode: WorksDeployCheckMode): WorksDeployCheckItem { - const check = report.checks[GAME_CANVAS_DYNAMIC_WORKS_DEPLOY_CHECK_ID]; - if (!check) return { status: 'BLOCKED', detail: '游戏/画布项目缺少 game_canvas_smoke 检查' }; - const unavailable = check.execution === 'unavailable' - && (check.status === 'BLOCKED' || check.status === 'SKIPPED'); - if (mode === 'cloud' && unavailable) { - return { status: 'PASS', detail: '本机 game_canvas_smoke 环境不可用,交由 Works Square 云端构建/运行链路继续验证' }; - } - if (check.status === 'SKIPPED') return { status: 'SKIPPED', detail: check.detail }; - if (check.status === 'BLOCKED') return { status: 'BLOCKED', detail: check.detail }; - if (!check.command) return { status: 'BLOCKED', detail: 'game_canvas_smoke 缺少实际执行命令' }; - if (!check.canvas_evidence) return { status: 'BLOCKED', detail: 'game_canvas_smoke 缺少桌面/移动视口和画布尺寸证据' }; - const evidenceError = validateCanvasEvidence(check.canvas_evidence); - return evidenceError - ? { status: 'BLOCKED', detail: evidenceError } - : { status: 'PASS', detail: 'game_canvas_smoke 包含桌面、移动、最大等比尺寸和 resize 复测证据' }; -} - -function checkCanvasHumanReportEvidence( - reportText: string, - report: WorksDeployCheckReport, - mode: WorksDeployCheckMode, -): WorksDeployCheckItem { - const canvasCheck = report.checks[GAME_CANVAS_DYNAMIC_WORKS_DEPLOY_CHECK_ID]; - if (mode === 'cloud' && canvasCheck?.execution === 'unavailable' - && (canvasCheck.status === 'BLOCKED' || canvasCheck.status === 'SKIPPED')) { - return { status: 'PASS', detail: '本机画布报告字段因浏览器环境不可用暂缺,云端构建链路继续承担运行验收' }; - } - const requiredFields: Array<{ label: string; pattern: RegExp }> = [ - { label: '逻辑分辨率', pattern: /逻辑分辨率|logical_resolution/i }, - { label: '桌面视口', pattern: /桌面(?:视口|viewport)|desktop_viewport/i }, - { label: '移动视口', pattern: /移动(?:视口|viewport)|mobile_viewport/i }, - { label: '主画布实际尺寸', pattern: /(?:主画布|canvas).*(?:实际尺寸|actual|desktop_canvas|mobile_canvas)/is }, - { label: '最大等比预期尺寸', pattern: /最大等比|expected_max_canvas/i }, - { label: 'resize 复测', pattern: /resize|全屏.*(?:变化|复测)|重新布局/i }, - ]; - const missing = requiredFields.filter(({ pattern }) => !pattern.test(reportText)).map(({ label }) => label); - return missing.length > 0 - ? { status: 'BLOCKED', detail: `部署报告缺少游戏/画布字段:${missing.join('、')}` } - : { status: 'PASS', detail: '部署报告包含游戏/画布逻辑分辨率、视口、画布尺寸、最大等比预期和 resize 证据' }; -} - -async function readArchiveData(archivePath: string, entry: ArchiveEntry): Promise { - const data = entry.getData(); - if (data.byteLength > 0 || entry.header.size === 0) return data; - try { - const command = process.platform === 'win32' - ? { file: 'tar', args: ['-xOf', archivePath, entry.entryName] } - : { file: 'unzip', args: ['-p', archivePath, entry.entryName] }; - const result = await execFileAsync(command.file, command.args, { maxBuffer: MAX_SCANNED_TEXT_BYTES }); - return Buffer.from(result.stdout); - } catch (error) { - throw new Error( - `Unable to read archive entry ${entry.entryName}: ${error instanceof Error ? error.message : String(error)}`, - { cause: error }, - ); - } -} - -async function readArchiveText(archivePath: string, entry: ArchiveEntry): Promise { - return (await readArchiveData(archivePath, entry)).toString('utf8'); -} - -function checkReportEvidence( - projectPath: string, - report: WorksDeployCheckReport, - publish: WorksPublishData, - zipSha256: string, - mode: WorksDeployCheckMode, -): WorksDeployCheckItem[] { - const checks: WorksDeployCheckItem[] = []; - const add = (status: WorksDeployCheckStatus, detail: string) => checks.push({ status, detail }); - const isEnvironmentUnavailable = (check: WorksDeployCheckItem): boolean => check.execution === 'unavailable' - || (check.status === 'SKIPPED' && ENVIRONMENT_CAPABILITY_UNAVAILABLE.test(check.detail)); - const isAllowedCloudUnavailable = (check: WorksDeployCheckItem): boolean => mode === 'cloud' - && isEnvironmentUnavailable(check) - && (check.status === 'BLOCKED' || check.status === 'SKIPPED'); - const reportStatus = aggregateVerificationStatus([ - { status: report.status, detail: '部署报告总状态' }, - ...Object.values(report.checks), - ]); - const reportChecksPass = Object.values(report.checks).every((check) => check.status === 'PASS'); - const reportChecksCloudReady = Object.values(report.checks).every((check) => check.status === 'PASS' || isAllowedCloudUnavailable(check)); - const reportReady = mode === 'cloud' - ? (report.status === 'PASS' && reportChecksPass - || (report.status === 'BLOCKED' || report.status === 'SKIPPED') && reportChecksCloudReady) - : reportStatus !== 'BLOCKED'; - add(reportReady ? (mode === 'cloud' ? 'PASS' : reportStatus) : 'BLOCKED', reportReady - ? (mode === 'cloud' && !reportChecksPass - ? '本地动态 smoke 未执行但均明确标记 unavailable,ZIP 可交由 Works Square 云端构建' - : reportStatus === 'SKIPPED' ? '部署报告包含因当前环境能力不足而未执行的检查' : '部署报告及其检查项标记为 PASS') - : '部署报告或其中一项检查标记为 BLOCKED'); - const reportZipPath = resolveZipPath(projectPath, report.zip_file_path); - const publishZipPath = resolveZipPath(projectPath, publish.zip_file_path); - add(samePath(reportZipPath, publishZipPath) ? 'PASS' : 'BLOCKED', samePath(reportZipPath, publishZipPath) ? '报告和 works-publish.json 指向同一个 zip' : '报告和 works-publish.json 指向不同 zip'); - add(report.zip_sha256 === zipSha256 ? 'PASS' : 'BLOCKED', report.zip_sha256 === zipSha256 ? '报告绑定了当前 zip' : '报告中的 zip_sha256 与当前 zip 不一致'); - for (const id of REQUIRED_DYNAMIC_WORKS_DEPLOY_CHECK_IDS) { - const check = report.checks[id]; - const status = check.status === 'PASS' || isAllowedCloudUnavailable(check) - ? 'PASS' - : check.status === 'SKIPPED' && mode !== 'cloud' - ? 'SKIPPED' - : 'BLOCKED'; - add(status, check.detail); - } - return checks; -} - -export async function readWorksDeployCheck( - projectPath: string, - publish: WorksPublishData | null, - options: { mode?: WorksDeployCheckMode } = {}, -): Promise { - const mode = options.mode ?? 'local'; - const filePath = path.join(projectPath, WORKS_DEPLOY_CHECK_FILE_NAME); - try { - await access(filePath); - } catch { - return { status: 'missing', filePath }; - } - - try { - const report = normalizeReport(JSON.parse(await readFile(filePath, 'utf8')) as unknown); - if (!publish) return { status: 'blocked', filePath, report, error: 'works-publish.json 缺失或无效' }; - const verification = await verifyWorksDeployPackage(projectPath, publish, report, { mode }); - return { - status: verification.status === 'PASS' - ? 'pass' - : verification.status === 'SKIPPED' - ? 'warning' - : 'blocked', - filePath, - report, - checks: report.checks, - static_checks: Object.fromEntries(verification.checks.map((check, index) => [`static_${index + 1}`, check])), - ...(verification.zipSha256 ? { zip_sha256: verification.zipSha256 } : {}), - ...(verification.status === 'BLOCKED' ? { error: verification.error } : {}), - }; - } catch (error) { - return { status: 'invalid', filePath, error: error instanceof Error ? error.message : String(error) }; - } -} - -type VerificationResult = { - status: WorksDeployCheckStatus; - checks: WorksDeployCheckItem[]; - zipSha256?: string; - error?: string; -}; - -export async function verifyWorksDeployPackage( - projectPath: string, - publish: WorksPublishData, - report: WorksDeployCheckReport, - options: { mode?: WorksDeployCheckMode } = {}, -): Promise { - const mode = options.mode ?? 'local'; - const checks: WorksDeployCheckItem[] = []; - const add = (status: WorksDeployCheckStatus, detail: string) => checks.push({ status, detail }); - const zipPath = resolveZipPath(projectPath, publish.zip_file_path); - - let archiveStat; - try { - archiveStat = await stat(zipPath); - if (!archiveStat.isFile()) throw new Error('zip_file_path must point to a file'); - } catch (error) { - add('BLOCKED', `找不到 zip 程序包:${error instanceof Error ? error.message : String(error)}`); - return { status: 'BLOCKED', checks, error: '找不到 zip 程序包' }; - } - - if (archiveStat.size > MAX_ZIP_BYTES) add('BLOCKED', `zip 大小超过 50 MB:${archiveStat.size} bytes`); - else add('PASS', `zip 大小符合限制:${archiveStat.size} bytes`); - - let deploymentReportText = ''; - try { - deploymentReportText = await readFile(path.join(projectPath, WORKS_DEPLOY_REPORT_FILE_NAME), 'utf8'); - add('PASS', '项目根目录包含部署报告'); - } catch { - add('BLOCKED', '项目根目录缺少部署报告'); - } - - const zipBytes = await readFile(zipPath); - const zipSha256 = createHash('sha256').update(zipBytes).digest('hex'); - checks.push(...checkReportEvidence(projectPath, report, publish, zipSha256, mode)); - - let archive: AdmZip; - try { - archive = new AdmZip(zipPath); - if (!archive.test()) throw new Error('zip integrity test failed'); - } catch (error) { - add('BLOCKED', `无法读取或解压 zip:${error instanceof Error ? error.message : String(error)}`); - return finalizeVerification(checks, zipSha256); - } - - const entries = archive.getEntries(); - const index: ArchiveIndex = new Map(); - let uncompressedBytes = 0; - let invalidPath = false; - let duplicatePath = false; - let unsafeEntry = false; - for (const entry of entries) { - const normalized = normalizeArchivePath(entry.entryName); - if (!normalized) { - invalidPath = true; - continue; - } - if (index.has(normalized)) duplicatePath = true; - index.set(normalized, entry); - uncompressedBytes += entry.header.size; - if (isSymlink(entry)) unsafeEntry = true; - if (EXCLUDED_ENTRY.test(normalized)) unsafeEntry = true; - if (SENSITIVE_ENTRY.test(normalized)) unsafeEntry = true; - } - if (entries.length > MAX_ZIP_ENTRIES) add('BLOCKED', `zip 文件数量超过 2000:${entries.length}`); - else add('PASS', `zip 文件数量符合限制:${entries.length}`); - if (uncompressedBytes > MAX_UNCOMPRESSED_BYTES) add('BLOCKED', `zip 解压后超过 200 MB:${uncompressedBytes} bytes`); - else add('PASS', `zip 解压后大小符合限制:${uncompressedBytes} bytes`); - add(invalidPath ? 'BLOCKED' : 'PASS', invalidPath ? 'zip 包含绝对路径、空路径或路径穿越' : 'zip 路径安全'); - add(duplicatePath ? 'BLOCKED' : 'PASS', duplicatePath ? 'zip 包含重复规范化路径' : 'zip 没有重复规范化路径'); - add(unsafeEntry ? 'BLOCKED' : 'PASS', unsafeEntry ? 'zip 包含符号链接、缓存目录或敏感文件' : 'zip 没有符号链接、缓存目录或敏感文件'); - - const manifestEntry = index.get('niancode.yml'); - const composeEntry = index.get('docker-compose.yml'); - add(manifestEntry && !manifestEntry.isDirectory ? 'PASS' : 'BLOCKED', manifestEntry && !manifestEntry.isDirectory ? 'zip 根目录包含 niancode.yml' : 'zip 根目录缺少 niancode.yml'); - add(composeEntry && !composeEntry.isDirectory ? 'PASS' : 'BLOCKED', composeEntry && !composeEntry.isDirectory ? 'zip 根目录包含 docker-compose.yml' : 'zip 根目录缺少 docker-compose.yml'); - - if (!manifestEntry || !composeEntry || manifestEntry.isDirectory || composeEntry.isDirectory) { - return finalizeVerification(checks, zipSha256); - } - - const manifestText = await readArchiveText(zipPath, manifestEntry); - const composeText = await readArchiveText(zipPath, composeEntry); - let parsedManifest: unknown; - let parsedCompose: unknown; - try { - parsedManifest = parseYaml(manifestText); - parsedCompose = parseYaml(composeText); - } catch (error) { - add('BLOCKED', `niancode.yml 或 docker-compose.yml 不是合法 YAML:${error instanceof Error ? error.message : String(error)}`); - return finalizeVerification(checks, zipSha256); - } - - const manifest = asRecord(parsedManifest); - const compose = asRecord(parsedCompose); - const manifestCompose = asRecord(manifest?.compose); - const runtimeOk = manifest?.runtime === 'compose'; - const fileOk = readString(manifestCompose ?? {}, 'file') === 'docker-compose.yml'; - const publicService = readString(manifestCompose ?? {}, 'public_service'); - const publicPort = manifestCompose?.public_port; - add(runtimeOk && fileOk && publicPort === 8080 ? 'PASS' : 'BLOCKED', runtimeOk && fileOk && publicPort === 8080 ? 'niancode.yml 使用 runtime: compose、根目录 docker-compose.yml 和数字端口 8080' : 'niancode.yml 不符合 Works Square Compose manifest 要求'); - - const services = asRecord(compose?.services); - const topLevelVolumes = asRecord(compose?.volumes); - add(compose?.version === undefined ? 'PASS' : 'BLOCKED', compose?.version === undefined ? 'docker-compose.yml 没有顶层 version' : 'docker-compose.yml 禁止使用顶层 version'); - add(services ? 'PASS' : 'BLOCKED', services ? 'Compose services 是对象' : 'Compose 缺少 services 对象'); - if (!services) return finalizeVerification(checks, zipSha256); - - const publicConfig = asRecord(services[publicService]); - add(publicConfig ? 'PASS' : 'BLOCKED', publicConfig ? `Compose 存在公开服务 ${publicService}` : `Compose 不存在公开服务 ${publicService}`); - if (!publicConfig) return finalizeVerification(checks, zipSha256); - add(getBuildSpec(publicConfig) ? 'PASS' : 'BLOCKED', getBuildSpec(publicConfig) ? '公开服务使用 build 构建自包含镜像' : '公开服务缺少 build,不能依赖运行时宿主机文件'); - - const buildContextPaths: Array<{ service: string; context: string; dockerfile: string }> = []; - let hasUnsafeComposeConfig = false; - for (const [serviceName, rawService] of Object.entries(services)) { - const service = asRecord(rawService); - if (!service) { - hasUnsafeComposeConfig = true; - continue; - } - if (service.privileged === true || service.network_mode === 'host' || service.pid === 'host' || service.ipc === 'host') hasUnsafeComposeConfig = true; - if (service.env_file !== undefined && !hasSafeEnvFile(service.env_file, index)) hasUnsafeComposeConfig = true; - if (service.ports !== undefined && !Array.isArray(service.ports)) hasUnsafeComposeConfig = true; - if (service.volumes !== undefined && !Array.isArray(service.volumes)) hasUnsafeComposeConfig = true; - if (service.expose !== undefined && !Array.isArray(service.expose)) hasUnsafeComposeConfig = true; - if (hasUnsafeComposeEnvironment(service.environment)) hasUnsafeComposeConfig = true; - const build = getBuildSpec(service); - if (build) buildContextPaths.push({ service: serviceName, context: build.context, dockerfile: build.dockerfile }); - if (FORBIDDEN_RUNTIME_ACTIONS.test(stringifyCommand(service.command)) || FORBIDDEN_RUNTIME_ACTIONS.test(stringifyCommand(service.entrypoint))) hasUnsafeComposeConfig = true; - for (const volume of valuesAsList(service.volumes)) { - const parsedVolume = volumeSource(volume); - if (!parsedVolume || parsedVolume.type === 'bind' || !parsedVolume.source || parsedVolume.source.startsWith('.') || parsedVolume.source.startsWith('/') || parsedVolume.source.startsWith('~') || isWindowsAbsolute(parsedVolume.source) || parsedVolume.source.includes('..') || !topLevelVolumes?.[parsedVolume.source]) { - hasUnsafeComposeConfig = true; - } - } - if (serviceName === publicService) { - const ports = valuesAsList(service.ports).map(extractPort); - const publicPortOk = ports.length > 0 && ports.every((port) => port?.hostIp === '127.0.0.1' && port.hostPort === '' && port.containerPort === '8080'); - add(publicPortOk ? 'PASS' : 'BLOCKED', publicPortOk ? '公开服务使用 127.0.0.1::8080 动态端口' : '公开服务没有使用 127.0.0.1::8080 动态端口'); - } else if (service.ports !== undefined) { - hasUnsafeComposeConfig = true; - } - } - add(hasUnsafeComposeConfig ? 'BLOCKED' : 'PASS', hasUnsafeComposeConfig ? 'Compose 含有 bind mount、危险宿主配置、未声明环境变量或运行时构建命令' : 'Compose 没有 bind mount、危险宿主配置或运行时构建命令'); - - let scannedTextBytes = 0; - let storageUnsafe = false; - let secretUnsafe = false; - const textEntries = new Map(); - for (const [name, entry] of index) { - if (entry.isDirectory || !isTextEntry(name)) continue; - if (scannedTextBytes >= MAX_SCANNED_TEXT_BYTES) break; - const data = await readArchiveData(zipPath, entry); - scannedTextBytes += data.byteLength; - const text = data.toString('utf8'); - textEntries.set(name, text); - if (PRIVATE_KEY_CONTENT.test(text) || HIGH_CONFIDENCE_SECRET.test(text)) secretUnsafe = true; - if (STORAGE_ACCESS.test(text) && !hasSafeStorageAdapter(text)) storageUnsafe = true; - } - add(storageUnsafe ? 'BLOCKED' : 'PASS', storageUnsafe ? '浏览器持久化 API 使用没有可识别的异常降级保护' : 'Web Storage、Cookie、IndexedDB 和 Service Worker 使用具备降级保护或未使用'); - add(secretUnsafe ? 'BLOCKED' : 'PASS', secretUnsafe ? '源码或配置包含私钥/高置信度凭据' : '没有发现私钥或高置信度凭据'); - - let contextUnsafe = false; - for (const build of buildContextPaths) { - const context = safePath(build.context); - const dockerfile = joinArchivePath(build.context, build.dockerfile); - if (context === null || dockerfile === null || !index.get(dockerfile) || index.get(dockerfile)?.isDirectory) { - contextUnsafe = true; - continue; - } - const prefix = context === '' ? '' : `${context}/`; - const contextNames = [...index.keys()].filter((name) => name === context || name.startsWith(prefix)); - const hasPackage = contextNames.includes(`${prefix}package.json`) || contextNames.includes('package.json') && context === ''; - const hasNodeLock = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'].some((lock) => contextNames.includes(`${prefix}${lock}`) || context === '' && contextNames.includes(lock)); - const hasPythonDeclaration = contextNames.some((name) => name === `${prefix}pyproject.toml` || name === `${prefix}requirements.txt` || context === '' && (name === 'pyproject.toml' || name === 'requirements.txt')); - const hasPythonLock = contextNames.some((name) => /(?:^|\/)(?:uv\.lock|poetry\.lock|pdm\.lock|requirements\.lock)$/.test(name)); - if (hasPackage && !hasNodeLock) contextUnsafe = true; - if (hasPythonDeclaration && !hasPythonLock) contextUnsafe = true; - const dockerfileText = index.get(dockerfile) ? await readArchiveText(zipPath, index.get(dockerfile) as ArchiveEntry) : ''; - const dockerStartText = (dockerfileText.match(/^\s*(?:CMD|ENTRYPOINT).*$/gim) ?? []).join('\n').replace(/[,[\]"'()]/g, ' '); - if (!/^\s*(?:CMD|ENTRYPOINT)\b/im.test(dockerfileText) || FORBIDDEN_RUNTIME_ACTIONS.test(dockerStartText)) contextUnsafe = true; - if (hasPackage && !/\b(?:npm|pnpm|yarn)\s+(?:ci|install|i)\b/i.test(dockerfileText)) contextUnsafe = true; - const packageEntryName = context === '' ? 'package.json' : `${prefix}package.json`; - const packageText = hasPackage && index.get(packageEntryName) - ? await readArchiveText(zipPath, index.get(packageEntryName) as ArchiveEntry) - : ''; - const viteConfigName = contextNames.find((name) => /(?:^|\/)vite\.config\.(?:js|ts|mjs|mts|cjs|cts)$/.test(name)); - const isViteProject = Boolean(viteConfigName || /\b(?:vite|@vitejs\/plugin-[A-Za-z0-9_-]+)\b/i.test(packageText)); - if (isViteProject) { - const viteConfig = viteConfigName && index.get(viteConfigName) ? await readArchiveText(zipPath, index.get(viteConfigName) as ArchiveEntry) : ''; - if (!hasPackage || !hasNodeLock || !viteConfigName || !/\bbase\s*:\s*["']\.\/["']/.test(viteConfig)) contextUnsafe = true; - if (!/\b(?:npm|pnpm|yarn)\s+(?:run\s+build|build)\b/i.test(dockerfileText)) contextUnsafe = true; - } - if (hasPackage && /(?:^|\/)index\.html$/.test(contextNames.join('\n'))) { - const htmlEntry = contextNames.find((name) => /(?:^|\/)index\.html$/.test(name)); - const html = htmlEntry && index.get(htmlEntry) ? await readArchiveText(zipPath, index.get(htmlEntry) as ArchiveEntry) : ''; - if (/(?:src|href)=["']\/assets\//i.test(html)) contextUnsafe = true; - } - let runtimeText = `${dockerfileText}\n${packageText}`; - for (const name of contextNames) { - const entry = index.get(name); - if (!entry || !/(?:nginx\.conf|server\.(?:js|ts)|main\.(?:py|js|ts))$/.test(name)) continue; - runtimeText += `\n${await readArchiveText(zipPath, entry)}`; - } - const mentions8080 = /8080/.test(runtimeText); - const listensOnAllInterfaces = /0\.0\.0\.0|listen\s+8080\b|--host\s+0\.0\.0\.0/i.test(runtimeText); - if (!mentions8080 || !listensOnAllInterfaces) contextUnsafe = true; - } - add(contextUnsafe ? 'BLOCKED' : 'PASS', contextUnsafe ? 'build context、Dockerfile、锁文件、Vite 相对资源、0.0.0.0:8080 监听或运行约束不完整' : 'build context、Dockerfile、锁文件、Vite 资源路径和 0.0.0.0:8080 运行约束通过'); - - const canvasProject = isCanvasProject(publish, index, textEntries); - if (canvasProject) { - const canvasLayout = inspectCanvasLayout(textEntries); - add(canvasLayout.pass ? 'PASS' : 'BLOCKED', canvasLayout.detail); - checks.push(checkCanvasReportEvidence(report, mode)); - checks.push(checkCanvasHumanReportEvidence(deploymentReportText, report, mode)); - } - - return finalizeVerification(checks, zipSha256); -} - -function finalizeVerification(checks: WorksDeployCheckItem[], zipSha256?: string): VerificationResult { - const failed = checks.filter((check) => check.status === 'BLOCKED'); - return { - status: aggregateVerificationStatus(checks), - checks, - ...(zipSha256 ? { zipSha256 } : {}), - ...(failed.length > 0 ? { error: failed.map((check) => check.detail).join(';') } : {}), - }; -} - -function aggregateVerificationStatus(checks: WorksDeployCheckItem[]): WorksDeployCheckStatus { - if (checks.some((check) => check.status === 'BLOCKED')) return 'BLOCKED'; - if (checks.some((check) => check.status === 'SKIPPED')) return 'SKIPPED'; - return 'PASS'; -} diff --git a/electron/services/project-progress-sync.ts b/electron/services/project-progress-sync.ts index b48b77c..44706a9 100644 --- a/electron/services/project-progress-sync.ts +++ b/electron/services/project-progress-sync.ts @@ -3,7 +3,7 @@ import { watch, type FSWatcher } from 'node:fs'; import { access, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import type { OpencodeProject, OpencodeProjectStore } from '../opencode/project-store'; -import { readWorksPublishFile } from '../opencode/works-publish-file'; +import { readWorksSubmissionBinding } from './works-submission-binding'; import { WORKS_SQUARE_CONFIG } from '../api/works-config'; import { proxyAwareFetch } from '../utils/proxy-fetch'; import { logger } from '../utils/logger'; @@ -15,13 +15,13 @@ import { getValidWorksSquareAccessToken, subscribeWorksSquareSession, } from './works-square-session'; +import { WORKS_SUBMISSION_BINDING_FILE_NAME } from '../../shared/works-submission-binding'; export const PROJECT_PROGRESS_DOCUMENT_NAMES = ['GDD.md', 'TASKS.md'] as const; export const PROJECT_PROGRESS_DEBOUNCE_MS = 1_000; const PROJECT_PROGRESS_ENDPOINT = '/api/project-progress'; const PROJECT_AGENT_PROMPT_ENDPOINT_PREFIX = '/api/projects'; const PROJECT_PROGRESS_MAX_CHARS = 20_000; -const WORKS_PUBLISH_FILE_NAME = 'works-publish.json'; type ProjectProgressDocumentName = typeof PROJECT_PROGRESS_DOCUMENT_NAMES[number]; type ProjectProgressType = 'server' | 'local'; @@ -89,7 +89,7 @@ function isSyncRelevantFile(filename: string | null): boolean { return PROJECT_PROGRESS_DOCUMENT_NAMES.includes(normalized as ProjectProgressDocumentName) || normalized === PRODUCT_OVERVIEW_FILENAME || normalized === LEGACY_PROMOTION_PLAN_FILENAME - || normalized === WORKS_PUBLISH_FILE_NAME; + || normalized === WORKS_SUBMISSION_BINDING_FILE_NAME; } export function mergeProjectProgressDocuments(gdd: string, tasks: string): string { @@ -139,9 +139,9 @@ async function readProductOverview(projectPath: string): Promise { async function resolveProjectProgressIdentity( project: OpencodeProject, ): Promise { - const publish = await readWorksPublishFile(project.path); - if (publish.status === 'ready' && publish.publish.app_id) { - return { projectType: 'server', projectKey: publish.publish.app_id }; + const binding = await readWorksSubmissionBinding(project.path); + if (binding?.status === 'submitted' && binding.app_id) { + return { projectType: 'server', projectKey: binding.app_id }; } return { projectType: 'local', projectKey: project.id }; } @@ -189,7 +189,7 @@ export function createProjectProgressSync( if (!normalized || normalized === PRODUCT_OVERVIEW_FILENAME || normalized === LEGACY_PROMOTION_PLAN_FILENAME) { state.pendingPromotion = true; } - if (normalized === WORKS_PUBLISH_FILE_NAME) { + if (normalized === WORKS_SUBMISSION_BINDING_FILE_NAME) { state.pendingProgress = true; state.pendingPromotion = true; state.lastSyncedFingerprint = null; diff --git a/electron/services/works-cloud-deployment.ts b/electron/services/works-cloud-deployment.ts deleted file mode 100644 index 2542b31..0000000 --- a/electron/services/works-cloud-deployment.ts +++ /dev/null @@ -1,481 +0,0 @@ -import { watch, type FSWatcher } from 'node:fs'; -import { readFile, writeFile } from 'node:fs/promises'; -import { basename, extname, join, resolve } from 'node:path'; -import type { OpencodeProject, OpencodeProjectStore } from '../opencode/project-store'; -import { readWorksDeployCheck } from '../opencode/works-square-deploy-check'; -import { readWorksPublishFile, type WorksPublishData } from '../opencode/works-publish-file'; -import { WORKS_SQUARE_CONFIG } from '../api/works-config'; -import { proxyAwareFetch } from '../utils/proxy-fetch'; -import { logger } from '../utils/logger'; -import { - WORKS_CLOUD_DEPLOYMENT_FILE_NAME, - WORKS_CLOUD_DEPLOYMENT_SCHEMA_VERSION, - type WorksCloudDeploymentRecord, - type WorksCloudDeploymentStatus, -} from '../../shared/works-cloud-deployment'; -import { getValidWorksSquareAccessToken, subscribeWorksSquareSession } from './works-square-session'; - -const DEFAULT_DEBOUNCE_MS = 700; - -type ProjectStore = Pick; -type ProjectWatcher = Pick; -type WatchDirectory = (projectPath: string, onChange: (filename: string | null) => void) => ProjectWatcher; - -export type WorksCloudDeploymentOptions = { - apiBaseUrl?: string; - debounceMs?: number; - fetchImpl?: (input: string | URL, init?: RequestInit) => Promise; - getAccessToken?: () => Promise; - watchDirectory?: WatchDirectory; -}; - -type DeploymentState = { - project: OpencodeProject; - watcher: ProjectWatcher | null; - timer: ReturnType | null; - running: boolean; - record: WorksCloudDeploymentRecord | null; -}; - -type DeploymentPatch = { - status: WorksCloudDeploymentStatus; - app_id?: string; - version_name?: string; - zip_sha256?: string; - version_id?: string; - review_status?: string; - error?: string | null; -}; - -type SubmittedDeploymentInput = { - appId: string; - versionId: string; - versionName: string; - reviewStatus: string; - zipSha256?: string; -}; - -function normalizeApiBaseUrl(value: string): string { - const baseUrl = value.trim().replace(/\/+$/, ''); - if (!/^https?:\/\//i.test(baseUrl)) { - throw new Error('Works Square API base URL must start with http:// or https://'); - } - return baseUrl; -} - -function defaultWatchDirectory(projectPath: string, onChange: (filename: string | null) => void): ProjectWatcher { - return watch(projectPath, { persistent: false }, (_event, filename) => { - onChange(typeof filename === 'string' ? filename : filename ? filename.toString() : null); - }); -} - -function normalizeFilename(filename: string | null): string | null { - return filename ? filename.replaceAll('\\', '/') : null; -} - -function isRelevantFile(filename: string | null): boolean { - const normalized = normalizeFilename(filename); - if (!normalized) return true; - return normalized === 'works-publish.json' - || normalized === 'works-deploy-check.json' - || normalized === '部署报告.md' - || extname(normalized).toLowerCase() === '.zip'; -} - -function resolveProjectFilePath(projectPath: string, value: string): string { - return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('/') ? value : resolve(projectPath, value); -} - -function readString(value: unknown): string | null { - return typeof value === 'string' && value.trim() ? value.trim() : null; -} - -async function readPayload(response: Response): Promise { - const text = await response.text(); - if (!text.trim()) return null; - try { - return JSON.parse(text) as unknown; - } catch { - return text; - } -} - -function responseMessage(payload: unknown, fallback: string): string { - if (payload && typeof payload === 'object' && !Array.isArray(payload)) { - const record = payload as Record; - for (const key of ['message', 'msg', 'detail', 'error']) { - const value = readString(record[key]); - if (value) return value; - } - } - return typeof payload === 'string' && payload.trim() ? payload.trim() : fallback; -} - -function isPendingStatus(status: WorksCloudDeploymentStatus): boolean { - return status === 'armed' - || status === 'waiting_for_package' - || status === 'waiting_for_login' - || status === 'uploading'; -} - -async function readDeploymentRecord(projectPath: string): Promise { - try { - const parsed = JSON.parse(await readFile(join(projectPath, WORKS_CLOUD_DEPLOYMENT_FILE_NAME), 'utf8')) as Partial; - if (parsed.schema_version !== WORKS_CLOUD_DEPLOYMENT_SCHEMA_VERSION - || typeof parsed.project_id !== 'string' - || typeof parsed.status !== 'string' - || typeof parsed.requested_at !== 'string' - || typeof parsed.updated_at !== 'string') { - return null; - } - return parsed as WorksCloudDeploymentRecord; - } catch { - return null; - } -} - -export function createWorksCloudDeployment( - projectStore: ProjectStore, - options: WorksCloudDeploymentOptions = {}, -) { - const apiBaseUrl = normalizeApiBaseUrl(options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl); - const debounceMs = Math.max(0, options.debounceMs ?? DEFAULT_DEBOUNCE_MS); - const fetchImpl = options.fetchImpl ?? proxyAwareFetch; - const getAccessToken = options.getAccessToken ?? getValidWorksSquareAccessToken; - const watchDirectory = options.watchDirectory ?? defaultWatchDirectory; - const states = new Map(); - let started = false; - let unsubscribeProjectStore: (() => void) | null = null; - let unsubscribeSession: (() => void) | null = null; - - function clearTimer(state: DeploymentState): void { - if (state.timer) { - clearTimeout(state.timer); - state.timer = null; - } - } - - function closeState(state: DeploymentState): void { - clearTimer(state); - state.watcher?.close(); - state.watcher = null; - } - - function disposeProject(projectId: string): void { - const state = states.get(projectId); - if (!state) return; - closeState(state); - states.delete(projectId); - } - - function attachWatcher(state: DeploymentState): void { - if (state.watcher) return; - try { - state.watcher = watchDirectory(state.project.path, (filename) => { - if (isRelevantFile(filename)) schedule(state, false); - }); - } catch (error) { - logger.warn(`[works-cloud-deployment] Failed to watch ${state.project.path}`, error); - } - } - - function schedule(state: DeploymentState, immediate: boolean): void { - if (!started || !isPendingStatus(state.record?.status ?? 'armed')) return; - clearTimer(state); - state.timer = setTimeout(() => { - state.timer = null; - void attempt(state); - }, immediate ? 0 : debounceMs); - } - - async function persist(state: DeploymentState, record: WorksCloudDeploymentRecord): Promise { - state.record = record; - await writeFile( - join(state.project.path, WORKS_CLOUD_DEPLOYMENT_FILE_NAME), - `${JSON.stringify(record, null, 2)}\n`, - 'utf8', - ); - return record; - } - - async function updateStatus(state: DeploymentState, patch: DeploymentPatch): Promise { - const now = new Date().toISOString(); - const previous = state.record; - const next: WorksCloudDeploymentRecord = { - schema_version: WORKS_CLOUD_DEPLOYMENT_SCHEMA_VERSION, - project_id: state.project.id, - status: patch.status, - requested_at: previous?.requested_at ?? now, - updated_at: now, - ...(previous?.app_id ? { app_id: previous.app_id } : {}), - ...(previous?.version_name ? { version_name: previous.version_name } : {}), - ...(previous?.zip_sha256 ? { zip_sha256: previous.zip_sha256 } : {}), - ...(previous?.version_id ? { version_id: previous.version_id } : {}), - ...(previous?.review_status ? { review_status: previous.review_status } : {}), - ...(previous?.error ? { error: previous.error } : {}), - ...(patch.app_id ? { app_id: patch.app_id } : {}), - ...(patch.version_name ? { version_name: patch.version_name } : {}), - ...(patch.zip_sha256 ? { zip_sha256: patch.zip_sha256 } : {}), - ...(patch.version_id ? { version_id: patch.version_id } : {}), - ...(patch.review_status ? { review_status: patch.review_status } : {}), - }; - if (patch.error === null) delete next.error; - if (patch.error) next.error = patch.error; - return await persist(state, next); - } - - function getOrCreateState(project: OpencodeProject, record: WorksCloudDeploymentRecord | null = null): DeploymentState { - const existing = states.get(project.id); - if (existing) { - existing.project = project; - if (record) existing.record = record; - attachWatcher(existing); - return existing; - } - const state: DeploymentState = { - project, - watcher: null, - timer: null, - running: false, - record, - }; - states.set(project.id, state); - attachWatcher(state); - return state; - } - - async function arm(projectId: string): Promise { - const project = (await projectStore.listProjects()).find((item) => item.id === projectId); - if (!project) throw new Error('Project not found'); - const state = getOrCreateState(project); - const now = new Date().toISOString(); - const record: WorksCloudDeploymentRecord = { - schema_version: WORKS_CLOUD_DEPLOYMENT_SCHEMA_VERSION, - project_id: project.id, - status: 'armed', - requested_at: now, - updated_at: now, - }; - await persist(state, record); - schedule(state, true); - return record; - } - - async function recordSubmitted( - projectId: string, - input: SubmittedDeploymentInput, - ): Promise { - const project = (await projectStore.listProjects()).find((item) => item.id === projectId); - if (!project) throw new Error('Project not found'); - - const state = getOrCreateState(project, await readDeploymentRecord(project.path)); - closeState(state); - const now = new Date().toISOString(); - return await persist(state, { - schema_version: WORKS_CLOUD_DEPLOYMENT_SCHEMA_VERSION, - project_id: project.id, - status: 'submitted', - requested_at: now, - updated_at: now, - app_id: input.appId, - version_id: input.versionId, - version_name: input.versionName, - review_status: input.reviewStatus, - ...(input.zipSha256 ? { zip_sha256: input.zipSha256 } : {}), - }); - } - - async function ensureRemoteProject(accessToken: string, publish: WorksPublishData): Promise { - const headers = { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }; - const response = await fetchImpl(`${apiBaseUrl}/api/projects`, { - method: 'POST', - headers, - body: JSON.stringify({ - app_id: publish.app_id, - title: publish.title, - summary: publish.summary, - cover_url: null, - category: publish.category, - age_band: publish.age_band.trim() || null, - difficulty: publish.difficulty, - }), - }); - if (response.ok) return; - const payload = await readPayload(response); - if (response.status !== 409) { - throw new Error(`Works Square project create failed (${response.status}): ${responseMessage(payload, 'unknown error')}`); - } - - const ownership = await fetchImpl(`${apiBaseUrl}/api/projects/mine/${encodeURIComponent(publish.app_id)}/status`, { - method: 'GET', - headers: { Authorization: `Bearer ${accessToken}` }, - }); - if (!ownership.ok) { - const ownershipPayload = await readPayload(ownership); - throw new Error(`Works Square project ownership check failed (${ownership.status}): ${responseMessage(ownershipPayload, 'unknown error')}`); - } - } - - async function uploadArchive( - accessToken: string, - projectPath: string, - publish: WorksPublishData, - ): Promise<{ versionId: string; reviewStatus: string }> { - const zipPath = resolveProjectFilePath(projectPath, publish.zip_file_path); - const archive = await readFile(zipPath); - const form = new FormData(); - form.set('version_name', publish.version_name || 'v1.0.0'); - form.set('change_log', publish.change_log || '提交部署程序包'); - form.set('archive', new Blob([new Uint8Array(archive)], { type: 'application/zip' }), basename(zipPath)); - const response = await fetchImpl(`${apiBaseUrl}/api/projects/${encodeURIComponent(publish.app_id)}/versions/upload`, { - method: 'POST', - headers: { Authorization: `Bearer ${accessToken}` }, - body: form, - }); - const payload = await readPayload(response); - if (!response.ok) { - throw new Error(`Works Square cloud upload failed (${response.status}): ${responseMessage(payload, 'unknown error')}`); - } - const record = payload && typeof payload === 'object' && !Array.isArray(payload) - ? payload as Record - : {}; - const versionId = readString(record.version_id); - if (!versionId) throw new Error('Works Square cloud upload returned no version_id'); - return { - versionId, - reviewStatus: readString(record.review_status) ?? 'building', - }; - } - - async function attempt(state: DeploymentState): Promise { - if (!started || state.running || !isPendingStatus(state.record?.status ?? 'armed')) return; - state.running = true; - try { - const publish = await readWorksPublishFile(state.project.path); - if (publish.status !== 'ready') { - await updateStatus(state, { status: 'waiting_for_package', error: null }); - return; - } - - const deploymentCheck = await readWorksDeployCheck(state.project.path, publish.publish, { mode: 'cloud' }); - if (deploymentCheck.status === 'missing') { - await updateStatus(state, { status: 'waiting_for_package', app_id: publish.publish.app_id, version_name: publish.publish.version_name, error: null }); - return; - } - if (deploymentCheck.status !== 'pass' || !deploymentCheck.zip_sha256) { - await updateStatus(state, { - status: 'failed', - app_id: publish.publish.app_id, - version_name: publish.publish.version_name, - error: deploymentCheck.error || 'ZIP 静态安全检查未通过,未提交云端', - }); - closeState(state); - return; - } - - const accessToken = await getAccessToken(); - if (!accessToken) { - await updateStatus(state, { - status: 'waiting_for_login', - app_id: publish.publish.app_id, - version_name: publish.publish.version_name, - zip_sha256: deploymentCheck.zip_sha256, - error: '请先登录 Works Square,云端部署会在登录态恢复后继续', - }); - return; - } - - await updateStatus(state, { - status: 'uploading', - app_id: publish.publish.app_id, - version_name: publish.publish.version_name, - zip_sha256: deploymentCheck.zip_sha256, - error: null, - }); - await ensureRemoteProject(accessToken, publish.publish); - const uploaded = await uploadArchive(accessToken, state.project.path, publish.publish); - await updateStatus(state, { - status: 'submitted', - app_id: publish.publish.app_id, - version_name: publish.publish.version_name, - zip_sha256: deploymentCheck.zip_sha256, - version_id: uploaded.versionId, - review_status: uploaded.reviewStatus, - error: null, - }); - closeState(state); - logger.info(`[works-cloud-deployment] Submitted ${state.project.id} as ${uploaded.versionId}`); - } catch (error) { - await updateStatus(state, { - status: 'failed', - error: error instanceof Error ? error.message : String(error), - }); - closeState(state); - logger.warn(`[works-cloud-deployment] Failed for ${state.project.id}`, error); - } finally { - state.running = false; - } - } - - async function get(projectId: string): Promise { - const state = states.get(projectId); - if (state?.record) return state.record; - const project = (await projectStore.listProjects()).find((item) => item.id === projectId); - return project ? await readDeploymentRecord(project.path) : null; - } - - async function start(): Promise { - if (started) return; - started = true; - unsubscribeProjectStore = projectStore.subscribe((change) => { - if (change.type === 'remove') { - disposeProject(change.projectId); - return; - } - const state = states.get(change.project.id); - if (state) { - state.project = change.project; - attachWatcher(state); - } - }); - unsubscribeSession = subscribeWorksSquareSession((session) => { - if (!session) return; - for (const state of states.values()) { - if (state.record?.status === 'waiting_for_login') schedule(state, true); - } - }); - - for (const project of await projectStore.listProjects()) { - const record = await readDeploymentRecord(project.path); - if (!record || record.project_id !== project.id || !isPendingStatus(record.status)) continue; - const state = getOrCreateState(project, record); - if (record.status === 'uploading') { - await updateStatus(state, { status: 'armed', error: '应用重启后恢复云端部署任务' }); - } - schedule(state, true); - } - } - - function stop(): void { - if (!started) return; - started = false; - unsubscribeProjectStore?.(); - unsubscribeSession?.(); - unsubscribeProjectStore = null; - unsubscribeSession = null; - for (const state of states.values()) closeState(state); - states.clear(); - } - - return { - arm, - recordSubmitted, - get, - start, - stop, - getWatchedProjectIds: (): string[] => [...states.keys()], - }; -} diff --git a/electron/services/works-submission-binding.ts b/electron/services/works-submission-binding.ts new file mode 100644 index 0000000..8738601 --- /dev/null +++ b/electron/services/works-submission-binding.ts @@ -0,0 +1,179 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { OpencodeProjectStore } from '../opencode/project-store'; +import { logger } from '../utils/logger'; +import { + WORKS_SUBMISSION_BINDING_FILE_NAME, + WORKS_SUBMISSION_BINDING_SCHEMA_VERSION, + type WorksSubmissionBindingRecord, +} from '../../shared/works-submission-binding'; + +const LEGACY_PENDING_STATUSES = new Set([ + 'armed', + 'waiting_for_package', + 'waiting_for_login', + 'uploading', + 'failed', +]); +const LEGACY_RETIRED_MESSAGE = '旧版自动部署任务已停用,请在项目配置中点击“提交审核”重新提交。'; + +type ProjectStore = Pick; + +type SubmittedBindingInput = { + appId: string; + versionId: string; + versionName: string; + reviewStatus: string; + zipSha256?: string; +}; + +type LegacyBindingRecord = { + schema_version: 1; + project_id: string; + status: string; + requested_at: string; + updated_at: string; + app_id?: string; + version_name?: string; + zip_sha256?: string; + version_id?: string; + review_status?: string; +}; + +function readString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function parseCurrentRecord(value: unknown): WorksSubmissionBindingRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record; + if ( + record.schema_version !== WORKS_SUBMISSION_BINDING_SCHEMA_VERSION + || (record.status !== 'submitted' && record.status !== 'legacy_retired') + ) return null; + const projectId = readString(record.project_id); + const requestedAt = readString(record.requested_at); + const updatedAt = readString(record.updated_at); + if (!projectId || !requestedAt || !updatedAt) return null; + return record as WorksSubmissionBindingRecord; +} + +function parseLegacyRecord(value: unknown): LegacyBindingRecord | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record; + const projectId = readString(record.project_id); + const status = readString(record.status); + const requestedAt = readString(record.requested_at); + const updatedAt = readString(record.updated_at); + if (record.schema_version !== 1 || !projectId || !status || !requestedAt || !updatedAt) { + return null; + } + return record as LegacyBindingRecord; +} + +function migrateLegacyRecord(record: LegacyBindingRecord): WorksSubmissionBindingRecord { + const now = new Date().toISOString(); + if ( + record.status === 'submitted' + && readString(record.app_id) + && readString(record.version_id) + && readString(record.version_name) + ) { + return { + schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION, + project_id: record.project_id, + status: 'submitted', + requested_at: record.requested_at, + updated_at: now, + app_id: record.app_id, + version_id: record.version_id, + version_name: record.version_name, + ...(readString(record.review_status) ? { review_status: record.review_status } : {}), + ...(readString(record.zip_sha256) ? { zip_sha256: record.zip_sha256 } : {}), + }; + } + + return { + schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION, + project_id: record.project_id, + status: 'legacy_retired', + requested_at: record.requested_at, + updated_at: now, + error_code: 'LEGACY_AUTO_DEPLOY_RETIRED', + message: LEGACY_PENDING_STATUSES.has(record.status) + ? LEGACY_RETIRED_MESSAGE + : '旧版部署记录已停用,请使用项目配置中的“提交审核”。', + }; +} + +async function persistRecord( + projectPath: string, + record: WorksSubmissionBindingRecord, +): Promise { + await writeFile( + join(projectPath, WORKS_SUBMISSION_BINDING_FILE_NAME), + `${JSON.stringify(record, null, 2)}\n`, + 'utf8', + ); + return record; +} + +export async function readWorksSubmissionBinding( + projectPath: string, +): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(await readFile( + join(projectPath, WORKS_SUBMISSION_BINDING_FILE_NAME), + 'utf8', + )) as unknown; + } catch { + return null; + } + + const current = parseCurrentRecord(parsed); + if (current) return current; + const legacy = parseLegacyRecord(parsed); + if (!legacy) return null; + return persistRecord(projectPath, migrateLegacyRecord(legacy)); +} + +export function createWorksSubmissionBindingStore(projectStore: ProjectStore) { + async function recordSubmitted( + projectId: string, + input: SubmittedBindingInput, + ): Promise { + const project = (await projectStore.listProjects()).find((item) => item.id === projectId); + if (!project) throw new Error('Project not found'); + const now = new Date().toISOString(); + return persistRecord(project.path, { + schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION, + project_id: project.id, + status: 'submitted', + requested_at: now, + updated_at: now, + app_id: input.appId, + version_id: input.versionId, + version_name: input.versionName, + review_status: input.reviewStatus, + ...(input.zipSha256 ? { zip_sha256: input.zipSha256 } : {}), + }); + } + + async function get(projectId: string): Promise { + const project = (await projectStore.listProjects()).find((item) => item.id === projectId); + return project ? readWorksSubmissionBinding(project.path) : null; + } + + async function start(): Promise { + for (const project of await projectStore.listProjects()) { + try { + await readWorksSubmissionBinding(project.path); + } catch (error) { + logger.warn(`[works-submission-binding] Failed to migrate ${project.id}`, error); + } + } + } + + return { recordSubmitted, get, start }; +} diff --git a/shared/works-cloud-deployment.ts b/shared/works-cloud-deployment.ts deleted file mode 100644 index 8487739..0000000 --- a/shared/works-cloud-deployment.ts +++ /dev/null @@ -1,24 +0,0 @@ -export const WORKS_CLOUD_DEPLOYMENT_FILE_NAME = 'works-cloud-deploy.json'; -export const WORKS_CLOUD_DEPLOYMENT_SCHEMA_VERSION = 1 as const; - -export type WorksCloudDeploymentStatus = - | 'armed' - | 'waiting_for_package' - | 'waiting_for_login' - | 'uploading' - | 'submitted' - | 'failed'; - -export type WorksCloudDeploymentRecord = { - schema_version: typeof WORKS_CLOUD_DEPLOYMENT_SCHEMA_VERSION; - project_id: string; - status: WorksCloudDeploymentStatus; - requested_at: string; - updated_at: string; - app_id?: string; - version_name?: string; - zip_sha256?: string; - version_id?: string; - review_status?: string; - error?: string; -}; diff --git a/shared/works-square-deploy-check.ts b/shared/works-square-deploy-check.ts deleted file mode 100644 index 96c8047..0000000 --- a/shared/works-square-deploy-check.ts +++ /dev/null @@ -1,64 +0,0 @@ -export const WORKS_DEPLOY_CHECK_FILE_NAME = 'works-deploy-check.json'; -export const WORKS_DEPLOY_REPORT_FILE_NAME = '部署报告.md'; -export const WORKS_DEPLOY_CHECK_SCHEMA_VERSION = 1 as const; - -export const REQUIRED_DYNAMIC_WORKS_DEPLOY_CHECK_IDS = [ - 'compose_config', - 'compose_build', - 'compose_up', - 'container_port', - 'http_smoke', - 'fresh_directory_smoke', - 'sandbox_browser_smoke', -] as const; -export const GAME_CANVAS_DYNAMIC_WORKS_DEPLOY_CHECK_ID = 'game_canvas_smoke' as const; -export const CONDITIONAL_DYNAMIC_WORKS_DEPLOY_CHECK_IDS = [GAME_CANVAS_DYNAMIC_WORKS_DEPLOY_CHECK_ID] as const; - -export type WorksCanvasMeasurement = { - width: number; - height: number; -}; - -export type WorksCanvasEvidence = { - logical_resolution: WorksCanvasMeasurement; - desktop_viewport: WorksCanvasMeasurement; - mobile_viewport: WorksCanvasMeasurement; - desktop_canvas: WorksCanvasMeasurement; - mobile_canvas: WorksCanvasMeasurement; - expected_max_canvas: { - desktop: WorksCanvasMeasurement; - mobile: WorksCanvasMeasurement; - }; - resize_verified: boolean; -}; - -export type WorksDeployCheckStatus = 'PASS' | 'SKIPPED' | 'BLOCKED'; -export type WorksDeployCheckExecution = 'executed' | 'unavailable'; - -export type WorksDeployCheckItem = { - status: WorksDeployCheckStatus; - detail: string; - command?: string; - checked_at?: string; - execution?: WorksDeployCheckExecution; - canvas_evidence?: WorksCanvasEvidence; -}; - -export type WorksDeployCheckReport = { - schema_version: typeof WORKS_DEPLOY_CHECK_SCHEMA_VERSION; - status: WorksDeployCheckStatus; - checked_at: string; - zip_file_path: string; - zip_sha256: string; - checks: Record; -}; - -export type WorksDeployCheckResult = { - status: 'pass' | 'warning' | 'blocked' | 'missing' | 'invalid'; - filePath: string; - report?: WorksDeployCheckReport; - checks?: Record; - static_checks?: Record; - error?: string; - zip_sha256?: string; -}; diff --git a/shared/works-submission-binding.ts b/shared/works-submission-binding.ts new file mode 100644 index 0000000..2f90003 --- /dev/null +++ b/shared/works-submission-binding.ts @@ -0,0 +1,19 @@ +export const WORKS_SUBMISSION_BINDING_FILE_NAME = 'works-cloud-deploy.json'; +export const WORKS_SUBMISSION_BINDING_SCHEMA_VERSION = 2 as const; + +export type WorksSubmissionBindingStatus = 'submitted' | 'legacy_retired'; + +export type WorksSubmissionBindingRecord = { + schema_version: typeof WORKS_SUBMISSION_BINDING_SCHEMA_VERSION; + project_id: string; + status: WorksSubmissionBindingStatus; + requested_at: string; + updated_at: string; + app_id?: string; + version_name?: string; + zip_sha256?: string; + version_id?: string; + review_status?: string; + error_code?: 'LEGACY_AUTO_DEPLOY_RETIRED'; + message?: string; +}; diff --git a/src/lib/skill-display.ts b/src/lib/skill-display.ts index 8f358ff..a6a6c5e 100644 --- a/src/lib/skill-display.ts +++ b/src/lib/skill-display.ts @@ -28,10 +28,6 @@ const SKILL_DISPLAY_BY_ID: Record = { name: '宣传与作品展示', description: '编写真实易懂的项目介绍、演讲稿、宣传页和发布故事。', }, - 'deploy-publish-check': { - name: '部署发布检查', - description: '检查项目构建、打包、部署、回滚和作品提交准备。', - }, 'nianxxgame-skill': { name: '网页游戏开发', description: '完成游戏策划、开发、试玩、体验打磨和发布验证。', diff --git a/src/lib/works-deploy-check.ts b/src/lib/works-deploy-check.ts deleted file mode 100644 index 4a27f37..0000000 --- a/src/lib/works-deploy-check.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { hostApiFetch } from '@/lib/host-api'; -import type { WorksDeployCheckResult } from '../../shared/works-square-deploy-check'; - -export type { WorksDeployCheckItem, WorksDeployCheckReport, WorksDeployCheckResult } from '../../shared/works-square-deploy-check'; - -export async function fetchWorksDeployCheck(projectId: string): Promise { - return await hostApiFetch( - `/api/opencode/projects/${encodeURIComponent(projectId)}/works-deploy-check`, - ); -} diff --git a/src/lib/works-publish-file.ts b/src/lib/works-publish-file.ts deleted file mode 100644 index 155e86b..0000000 --- a/src/lib/works-publish-file.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { hostApiFetch } from '@/lib/host-api'; - -export type WorksPublishData = { - app_id: string; - title: string; - summary: string; - category: string; - age_band: string; - difficulty: string; - version_name: string; - change_log: string; - zip_file_path: string; -}; - -export type WorksPublishReadResult = - | { status: 'ready'; filePath: string; publish: WorksPublishData } - | { status: 'missing'; filePath: string } - | { status: 'invalid'; filePath: string; error: string }; - -export async function fetchWorksPublishFile(projectId: string): Promise { - return await hostApiFetch( - `/api/opencode/projects/${encodeURIComponent(projectId)}/works-publish`, - ); -} diff --git a/src/lib/works-publish-helpers.ts b/src/lib/works-publish-helpers.ts deleted file mode 100644 index 0285256..0000000 --- a/src/lib/works-publish-helpers.ts +++ /dev/null @@ -1,76 +0,0 @@ -type PublishSourceDeliverable = { - stageId: string; - content: string; -}; - -export function getWorksPublishDefaultSummary( - projectName: string, - deliverables: PublishSourceDeliverable[], -): string { - const preferredDeliverable = deliverables.find((deliverable) => deliverable.stageId === 'marketing') - ?? deliverables.find((deliverable) => deliverable.stageId === 'pm') - ?? deliverables[0]; - const sourceText = preferredDeliverable?.content - .replace(/<[^>]*>/g, ' ') - .replace(/[#*_`>|[\]()-]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - if (sourceText) { - return sourceText.slice(0, 180); - } - return `${projectName} 的 Makelore 课程作品。`; -} - -export function getDeployPackageZipPath( - deliverables: PublishSourceDeliverable[], - stageId = 'deploy', -): string | null { - const deployDeliverables = deliverables.filter((deliverable) => deliverable.stageId === stageId); - for (let index = deployDeliverables.length - 1; index >= 0; index -= 1) { - const match = deployDeliverables[index].content.match(/(?:[A-Za-z]:[\\/][^\s`'",。;;<>]+|\/[^\s`'",。;;<>]+)\.zip\b/i); - if (match?.[0]) return match[0]; - } - return null; -} - -export function buildDeployProductBuildPrompt(input: { - projectName: string; - projectPath: string; - zipPath: string | null; - agentName?: string; -}): string { - const agentName = input.agentName?.trim() || '部署工程师'; - return [ - `请作为${agentName},直接开始构建当前项目的可发布产品文件。`, - `项目名称:${input.projectName}`, - `项目根目录:${input.projectPath}`, - '处理过程交互规则:', - '1. 先自己检查项目类型、启动方式和已有文件,能根据项目事实判断的就直接做。', - '2. 只有遇到会改变产品公开信息、需要用户选择、或需要权限确认的动作时,才在聊天页提问。', - '3. 需要运行命令或修改文件时,按工具权限流程处理;每完成一个关键步骤,用 1-2 句大白话同步进度。', - '4. 不要让用户填写作品 ID、标题、简介、分类、年龄段、难度、版本名、更新说明或 zip 路径,这些都由你根据项目内容写入文件。', - '构建要求:', - '1. 确认或创建 `Dockerfile`。', - '2. 确认或创建 `docker-compose.yml`。公开服务进程必须监听容器内 `0.0.0.0:8080`,端口映射使用 `127.0.0.1::8080` 动态宿主机端口。', - '3. 确认或创建 `niancode.yml`,使用 `runtime: compose`、`compose.file: docker-compose.yml`、`compose.public_service` 和 `compose.public_port: 8080`。', - '4. 把当前项目打成 `.zip` 包。zip 根目录必须直接包含 `niancode.yml` 和 `docker-compose.yml`,不要再套一层项目目录。', - '5. 打包时排除 `.git/`、`node_modules/`、`.venv/`、`venv/`、`.next/cache/`、`.pytest_cache/`、`.env`、证书、私钥和本地缓存。', - '6. 先执行完整发布验收:结构化解析 `niancode.yml` 和 `docker-compose.yml`,检查 build context、Dockerfile、端口、容器内 `0.0.0.0:8080` 监听、volume、运行命令、锁文件、Vite 相对资源、沙箱存储、密钥、环境变量、ZIP 路径/大小/数量和缓存排除项。只允许安全默认值 `${NAME:-safe-default}`;真实 `.env*` 只能排除,`.env.example` 只能放无敏感示例。', - '7. 如果本机有 Docker 和浏览器,执行完整 smoke:`docker compose config`、`docker compose build --pull=false`、`docker compose up -d`、容器状态、`docker port 8080/tcp`、HTTP smoke、Vite 入口 HTML 资源路径、日志和清理;然后在全新空目录解压 ZIP 重复整套 smoke。如果本机没有 Docker 或浏览器,不要假装 PASS,也不要停在“等用户去服务器启动”:把对应动态检查写成 `status: BLOCKED`、`execution: unavailable`,在 detail/部署报告中说明“本机环境不可用,交由 Works Square 云端构建验证”,继续生成 ZIP、works-publish.json 和交接报告。', - '8. 在不带 `allow-same-origin` 的 sandbox iframe 中做浏览器 smoke;任何白屏、未捕获 `SecurityError` 或启动失败都必须判定为 BLOCKED。若项目是游戏或画布项目,额外覆盖桌面视口、移动视口和一次 resize/全屏变化,确认根节点占满 iframe、主画布按逻辑分辨率保持最大等比尺寸;任一维度比预期小超过 5% 就是 BLOCKED。若浏览器不可用,同样用 `execution: unavailable` 记录事实,不要伪造画布尺寸证据。', - '9. 创建或更新项目根目录 `works-publish.json`,让 Main 的云端部署协调器读取它。', - ' 必填字段:app_id、title、summary、category、age_band、difficulty、version_name、change_log、zip_file_path。', - ' zip_file_path 必须指向已经生成的 `.zip` 包。', - '10. 在项目根目录创建 `works-deploy-check.json`。它必须是 UTF-8 JSON,schema_version 为 1,包含 status、checked_at、zip_file_path、zip_sha256,以及 compose_config、compose_build、compose_up、container_port、http_smoke、fresh_directory_smoke、sandbox_browser_smoke 这些 checks;每项都要有 PASS、SKIPPED、BLOCKED 之一以及 detail、command,动态检查还要标记 `execution: executed` 或 `execution: unavailable`。实际成功才写 PASS;Docker 或浏览器自动化不可用时写 SKIPPED 或 BLOCKED + execution: unavailable;命令执行失败或项目有问题写 BLOCKED。游戏或画布项目还必须增加 `game_canvas_smoke` 和尺寸证据。总状态优先级为 BLOCKED > SKIPPED > PASS;报告路径不一致或哈希不一致仍然 BLOCKED,云端只允许把明确标记 unavailable 的本地动态检查交给 Works Square 复核;哈希不一致仍然 BLOCKED。', - '11. 输出 `部署报告.md`,逐项列出 ZIP 路径、大小、解压大小、文件数、根目录前 40 项、公开服务/8080、容器内 `0.0.0.0:8080`、每个 build context/Dockerfile、无 bind mount、禁止项、Compose build/up、容器状态、docker port、HTTP 状态码、Vite base、入口 HTML、Web Storage/Cookie/IndexedDB/Service Worker、sandbox smoke;游戏或画布项目再列出逻辑分辨率、桌面/移动视口、主画布实际尺寸、最大等比预期尺寸和 resize 复测;最后写 PASS、SKIPPED 或 BLOCKED。', - '交接规则:', - '1. 当前任务如果由“部署与上传”页面的“构建产品”触发,Main 已经登记一次云端部署意图;你只需要完成文件、ZIP 和报告,Main 会在交接文件就绪且当前登录态有效时自动把 ZIP 提交到 Works Square 的远端构建接口。', - '2. 不要让用户去服务器手动启动 Docker,也不要询问 SSH 主机、用户名、端口或目标路径;本机 Docker/浏览器不可用不是让用户接管部署的理由。', - '3. 不能把“已生成文件”“已提交远端”“远端构建完成”“已公开”混为一谈。只有看到 `works-cloud-deploy.json` 的 `status: submitted` 和真实 `version_id` 才能说已提交远端;queued/building/reviewing 仍然不是已公开。', - '4. 如果当前会话没有云端部署意图文件,完成交接文件后说明需要从“部署与上传”页面点击“构建产品”来授权一次云端提交;不要自行调用未知服务器或暴露令牌。', - '5. 如果当前会话不能读取登录态或访问令牌,不要卡住构建;保留 `works-publish.json`、`works-deploy-check.json` 和 zip 包,让 Main 在登录态恢复后自动继续,并在报告里写明等待登录。', - input.zipPath - ? `已检测到旧部署报告里的 zip 包路径:${input.zipPath},请核对后决定是否复用或重新生成。` - : '如果还没有 zip 包,请先生成;完成静态安全检查和真实可执行的动态 smoke 后,把最终路径写入 works-publish.json。若动态环境不可用,必须明确标记 SKIPPED 或 BLOCKED + execution: unavailable 并让 Main 的云端复核接管,不得伪造 PASS;仅环境原因的 SKIPPED 必须提示未验证风险。', - ].join('\n'); -} diff --git a/src/lib/works-square.ts b/src/lib/works-square.ts index 54314c5..3b1d935 100644 --- a/src/lib/works-square.ts +++ b/src/lib/works-square.ts @@ -1,5 +1,4 @@ import { hostApiFetch } from '@/lib/host-api'; -import type { WorksCloudDeploymentRecord } from '../../shared/works-cloud-deployment'; export type ProjectPublic = { app_id: string; @@ -12,6 +11,8 @@ export type ProjectPublic = { status?: string | null; updated_at?: string | null; playable?: boolean; + play_url?: string | null; + /** @deprecated Use play_url. Kept for one client compatibility release. */ runtime_url?: string | null; creator_name?: string | null; buddy_name?: string | null; @@ -109,15 +110,6 @@ export type WorksProjectStatusDetail = { versions: WorksProjectVersion[]; }; -export type WorksProjectUploadInput = { - accessToken: string; - appId: string; - projectId: string; - versionName: string; - changeLog: string; - zipFilePath: string; -}; - export type WorksStaticPackageSummary = { archiveName: string; sha256: string; @@ -293,7 +285,7 @@ export function toPlazaCard(project: ProjectPublic): PlazaCard { difficulty: project.difficulty ?? null, updatedAt: project.updated_at ?? null, playable: Boolean(project.playable), - runtimeUrl: project.runtime_url ?? null, + runtimeUrl: project.play_url ?? project.runtime_url ?? null, }; } @@ -414,25 +406,6 @@ export async function fetchCurrentWorksProjectStatus( return assertSuccess(response, 'status', 'Failed to load Works Square project status'); } -export async function uploadWorksProjectZip( - input: WorksProjectUploadInput, -): Promise { - const response = await hostApiFetch>( - `/api/works/projects/${encodeURIComponent(input.appId)}/versions/upload`, - { - method: 'POST', - body: JSON.stringify({ - accessToken: input.accessToken, - projectId: input.projectId, - versionName: input.versionName, - changeLog: input.changeLog, - zipFilePath: input.zipFilePath, - }), - }, - ); - return assertSuccess(response, 'upload', 'Failed to upload Works Square project zip'); -} - export async function publishWorksProjectSource( input: WorksProjectSourcePublishInput, ): Promise { @@ -460,28 +433,6 @@ export async function publishWorksProjectSource( return { package: response.package, upload: response.upload }; } -export async function armWorksCloudDeployment(projectId: string): Promise { - const response = await hostApiFetch>( - `/api/opencode/projects/${encodeURIComponent(projectId)}/works-cloud-deploy`, - { method: 'POST', body: JSON.stringify({}) }, - ); - return assertSuccess(response, 'deployment', 'Failed to arm cloud deployment'); -} - -export async function fetchWorksCloudDeployment( - projectId: string, -): Promise { - const response = await hostApiFetch<{ - success: boolean; - deployment?: WorksCloudDeploymentRecord | null; - error?: string; - }>(`/api/opencode/projects/${encodeURIComponent(projectId)}/works-cloud-deploy`); - if (!response.success) { - throw new WorksSquareApiError(response.error || 'Failed to read cloud deployment status'); - } - return response.deployment ?? null; -} - export async function fetchWorksProjectVersions( accessToken: string, appId: string, diff --git a/src/pages/Makelore/index.tsx b/src/pages/Makelore/index.tsx index 72bd166..99ca08e 100644 --- a/src/pages/Makelore/index.tsx +++ b/src/pages/Makelore/index.tsx @@ -277,17 +277,6 @@ const courseSkills: Skill[] = [ appliesTo: ['市场运营'], statusLabel: '平台内置 · 只读', }, - { - id: 'deploy-publish-check', - name: '部署发布检查', - description: '检查项目构建、打包、部署、回滚和作品提交准备。', - prompt: '帮助发布部署执行完整的 Works Square Compose/ZIP/Docker/容器内 0.0.0.0:8080/HTTP/sandbox smoke 检查,使用安全环境变量默认值;游戏或画布项目额外覆盖桌面、移动和 resize 画布尺寸,写入 works-publish.json、works-deploy-check.json 和部署报告;真实执行失败或静态安全问题必须 BLOCKED,不得云端提交;本机 Docker/浏览器不可用时写 SKIPPED 或 BLOCKED + execution: unavailable,不伪造 PASS,由 Main 云端部署协调器提交远端构建,不要要求用户 SSH 到服务器。', - inputs: ['发布目标', 'Docker 构建命令', 'Demo 或素材', '隐私与安全情况', '登录态或访问令牌'], - outputs: ['部署报告', '发布步骤', '回滚步骤', '作品广场提交清单', 'works-publish.json', 'works-deploy-check.json', 'zip 包路径', '作品广场上传接口结果'], - guardrails: ['目标和权限不明确不发布', '检查密钥、私有路径、真实 .env*、未提供默认值的环境变量、bind mount、缓存和不安全命令', '上传包必须包含 niancode.yml、Dockerfile 和 docker-compose.yml', '游戏或画布项目只有真实浏览器成功时才提供 game_canvas_smoke 尺寸证据', '缺少 works-publish.json、works-deploy-check.json 或 zip_sha256 不匹配时阻止上传', 'Docker/浏览器环境不可用时输出 SKIPPED 或 BLOCKED + execution: unavailable,不伪造 PASS;有云端意图时交给 Main 远端复核'], - appliesTo: ['发布部署'], - statusLabel: '平台内置 · 只读', - }, { id: 'nianxxgame-skill', name: '网页游戏开发', diff --git a/tests/unit/device-preview-routes.test.ts b/tests/unit/device-preview-routes.test.ts index a975bc2..00a7783 100644 --- a/tests/unit/device-preview-routes.test.ts +++ b/tests/unit/device-preview-routes.test.ts @@ -52,14 +52,14 @@ function createContext(deployment: Record | null, activeProject path: 'D:/projects/planet-game', })), }, - worksCloudDeployment: { + worksSubmissionBinding: { get: vi.fn(async () => deployment), }, } as never; } const submittedDeployment = { - schema_version: 1, + schema_version: 2, project_id: 'project-1', status: 'submitted', requested_at: '2026-08-03T01:00:00.000Z', @@ -78,7 +78,8 @@ function matchingRemotePayload(overrides: { project: { app_id: 'planet-game', playable: true, - runtime_url: '/apps/planet-game/', + play_url: '/apps/planet-game/', + runtime_url: '/apps/legacy-planet-game/', version_name: 'v0.7.0', ...overrides.project, }, @@ -144,7 +145,39 @@ describe('device preview Host API route', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('returns only a trusted absolute HTTPS runtime URL for the submitted version', async () => { + it('explains that a legacy automatic deployment must be resubmitted', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + + await handleDevicePreviewRoutes( + createRequest(), + response.res, + new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'), + createContext({ + schema_version: 2, + project_id: 'project-1', + status: 'legacy_retired', + requested_at: '2026-08-01T00:00:00.000Z', + updated_at: '2026-08-10T00:00:00.000Z', + error_code: 'LEGACY_AUTO_DEPLOY_RETIRED', + message: '旧版自动部署任务已停用,请在项目配置中点击“提交审核”重新提交。', + }), + ); + + expect(response.json()).toEqual({ + success: true, + preview: { + state: 'unavailable', + projectId: 'project-1', + updatedAt: '2026-08-10T00:00:00.000Z', + message: '旧版自动部署任务已停用,请在项目配置中点击“提交审核”重新提交。', + }, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('prefers the trusted static play URL over the legacy runtime alias', async () => { const fetchMock = vi.fn().mockResolvedValueOnce(new Response( JSON.stringify(matchingRemotePayload()), { status: 200 }, @@ -185,11 +218,35 @@ describe('device preview Host API route', () => { expect(JSON.stringify(response.json())).not.toContain('managed-secret-token'); }); + it('keeps the legacy runtime alias as a one-release fallback', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response( + JSON.stringify(matchingRemotePayload({ + project: { play_url: null, runtime_url: '/apps/runtime-fallback/' }, + })), + { status: 200 }, + ))); + const response = createResponse(); + + await handleDevicePreviewRoutes( + createRequest(), + response.res, + new URL('http://127.0.0.1/api/opencode/projects/project-1/device-preview'), + createContext(submittedDeployment), + ); + + expect(response.json()).toMatchObject({ + preview: { + state: 'ready', + launchUrl: 'https://square.nianxx.cn/apps/runtime-fallback/', + }, + }); + }); + it('creates an exact short-lived owner preview for a pending-review release', async () => { const fetchMock = vi.fn() .mockResolvedValueOnce(new Response( JSON.stringify(matchingRemotePayload({ - project: { playable: false, runtime_url: null }, + project: { playable: false, play_url: null, runtime_url: null }, latestVersion: { review_status: 'pending_review' }, })), { status: 200 }, @@ -237,7 +294,7 @@ describe('device preview Host API route', () => { vi.stubGlobal('fetch', vi.fn() .mockResolvedValueOnce(new Response( JSON.stringify(matchingRemotePayload({ - project: { playable: false, runtime_url: null }, + project: { playable: false, play_url: null, runtime_url: null }, latestVersion: { review_status: 'pending_review' }, })), { status: 200 }, @@ -262,9 +319,9 @@ describe('device preview Host API route', () => { it.each([ ['an untrusted origin', 'https://evil.example/steal'], ['an overlong URL', `https://square.nianxx.cn/apps/${'x'.repeat(1_100)}`], - ])('refuses a runtime URL from %s', async (_case, runtimeUrl) => { + ])('refuses a play URL from %s', async (_case, playUrl) => { vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response( - JSON.stringify(matchingRemotePayload({ project: { runtime_url: runtimeUrl } })), + JSON.stringify(matchingRemotePayload({ project: { play_url: playUrl } })), { status: 200 }, ))); const response = createResponse(); @@ -445,7 +502,7 @@ describe('device preview Host API route', () => { const previewUrl = 'https://square.nianxx.cn/apps/planet-game/preview'; vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response( JSON.stringify(matchingRemotePayload({ - project: { runtime_url: undefined, preview_url: previewUrl }, + project: { play_url: undefined, runtime_url: undefined, preview_url: previewUrl }, })), { status: 200 }, ))); diff --git a/tests/unit/opencode-manager.test.ts b/tests/unit/opencode-manager.test.ts index ddda87e..28d06ce 100644 --- a/tests/unit/opencode-manager.test.ts +++ b/tests/unit/opencode-manager.test.ts @@ -1063,9 +1063,12 @@ describe('OpencodeManager', () => { const userDataDir = mkdtempSync(join(tmpdir(), 'niancode-opencode-manager-')); const bundledCourseSkillsDir = mkdtempSync(join(tmpdir(), 'niancode-course-skills-source-')); const retiredSkillDir = join(userDataDir, 'opencode', 'niancode-config', 'skills', 'student-growth-logger'); + const retiredDeploySkillDir = join(userDataDir, 'opencode', 'niancode-config', 'skills', 'deploy-publish-check'); try { mkdirSync(retiredSkillDir, { recursive: true }); + mkdirSync(retiredDeploySkillDir, { recursive: true }); writeFileSync(join(retiredSkillDir, 'SKILL.md'), '---\nname: student-growth-logger\n---\n'); + writeFileSync(join(retiredDeploySkillDir, 'SKILL.md'), '---\nname: deploy-publish-check\n---\n'); const { children, spawn } = createSpawnHarness(); const manager = new OpencodeManager({ @@ -1085,6 +1088,7 @@ describe('OpencodeManager', () => { await startPromise; expect(existsSync(retiredSkillDir)).toBe(false); + expect(existsSync(retiredDeploySkillDir)).toBe(false); } finally { rmSync(userDataDir, { recursive: true, force: true }); rmSync(bundledCourseSkillsDir, { recursive: true, force: true }); diff --git a/tests/unit/opencode-routes.test.ts b/tests/unit/opencode-routes.test.ts index 9a3d3cc..5849ad0 100644 --- a/tests/unit/opencode-routes.test.ts +++ b/tests/unit/opencode-routes.test.ts @@ -953,217 +953,19 @@ describe('opencode host api routes', () => { } }); - it('reports a missing root works-publish.json for a known project', async () => { - const projectPath = await mkdtemp(join(tmpdir(), 'niancode-works-publish-missing-')); - try { - const project = { - id: 'prj_publish_missing', - path: projectPath, - name: 'missing-publish', - createdAt: '2026-07-06T09:00:00.000Z', - updatedAt: '2026-07-06T09:00:00.000Z', - lastOpenedAt: '2026-07-06T09:00:00.000Z', - }; - const response = createResponse(); - + it.each(['works-publish', 'works-deploy-check', 'works-cloud-deploy'])( + 'does not expose the retired %s coordination route', + async (route) => { const handled = await handleOpencodeRoutes( createRequest('GET'), - response.res, - new URL(`http://127.0.0.1/api/opencode/projects/${project.id}/works-publish`), - { - opencodeProjectStore: { - listProjects: vi.fn(async () => [project]), - }, - } as never, + createResponse().res, + new URL(`http://127.0.0.1/api/opencode/projects/project-1/${route}`), + {} as never, ); - expect(handled).toBe(true); - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ - status: 'missing', - filePath: join(projectPath, 'works-publish.json'), - }); - } finally { - await rm(projectPath, { recursive: true, force: true }); - } - }); - - it('reports invalid root works-publish.json content', async () => { - const projectPath = await mkdtemp(join(tmpdir(), 'niancode-works-publish-invalid-')); - try { - await writeFile(join(projectPath, 'works-publish.json'), '{"title":"No app id"}\n', 'utf8'); - const project = { - id: 'prj_publish_invalid', - path: projectPath, - name: 'invalid-publish', - createdAt: '2026-07-06T09:00:00.000Z', - updatedAt: '2026-07-06T09:00:00.000Z', - lastOpenedAt: '2026-07-06T09:00:00.000Z', - }; - const response = createResponse(); - - const handled = await handleOpencodeRoutes( - createRequest('GET'), - response.res, - new URL(`http://127.0.0.1/api/opencode/projects/${project.id}/works-publish`), - { - opencodeProjectStore: { - listProjects: vi.fn(async () => [project]), - }, - } as never, - ); - - expect(handled).toBe(true); - expect(response.statusCode).toBe(200); - expect(response.json()).toMatchObject({ - status: 'invalid', - filePath: join(projectPath, 'works-publish.json'), - }); - expect(String((response.json() as { error?: string }).error)).toContain('Missing app_id'); - } finally { - await rm(projectPath, { recursive: true, force: true }); - } - }); - - it('reads and normalizes root works-publish.json for a known project', async () => { - const projectPath = await mkdtemp(join(tmpdir(), 'niancode-works-publish-ready-')); - try { - await writeFile( - join(projectPath, 'works-publish.json'), - `${JSON.stringify({ - app_id: 'course-app', - title: 'Course App', - summary: 'A short summary', - category: 'web', - age_band: '8-12', - difficulty: 'beginner', - version_name: 'v1.0.0', - change_log: 'First upload', - zip_file_path: 'D:/repo/course-app/dist/course-app.zip', - }, null, 2)}\n`, - 'utf8', - ); - const project = { - id: 'prj_publish_ready', - path: projectPath, - name: 'ready-publish', - createdAt: '2026-07-06T09:00:00.000Z', - updatedAt: '2026-07-06T09:00:00.000Z', - lastOpenedAt: '2026-07-06T09:00:00.000Z', - }; - const response = createResponse(); - - const handled = await handleOpencodeRoutes( - createRequest('GET'), - response.res, - new URL(`http://127.0.0.1/api/opencode/projects/${project.id}/works-publish`), - { - opencodeProjectStore: { - listProjects: vi.fn(async () => [project]), - }, - } as never, - ); - - expect(handled).toBe(true); - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ - status: 'ready', - filePath: join(projectPath, 'works-publish.json'), - publish: { - app_id: 'course-app', - title: 'Course App', - summary: 'A short summary', - category: 'web', - age_band: '8-12', - difficulty: 'beginner', - version_name: 'v1.0.0', - change_log: 'First upload', - zip_file_path: 'D:/repo/course-app/dist/course-app.zip', - }, - }); - } finally { - await rm(projectPath, { recursive: true, force: true }); - } - }); - - it('reports a missing machine deployment check for a known project', async () => { - const projectPath = await mkdtemp(join(tmpdir(), 'niancode-deploy-check-missing-')); - try { - const project = { - id: 'prj_deploy_check_missing', - path: projectPath, - name: 'missing-deploy-check', - createdAt: '2026-07-12T09:00:00.000Z', - updatedAt: '2026-07-12T09:00:00.000Z', - lastOpenedAt: '2026-07-12T09:00:00.000Z', - }; - const response = createResponse(); - - const handled = await handleOpencodeRoutes( - createRequest('GET'), - response.res, - new URL(`http://127.0.0.1/api/opencode/projects/${project.id}/works-deploy-check`), - { opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never, - ); - - expect(handled).toBe(true); - expect(response.statusCode).toBe(200); - expect(response.json()).toEqual({ - status: 'missing', - filePath: join(projectPath, 'works-deploy-check.json'), - }); - } finally { - await rm(projectPath, { recursive: true, force: true }); - } - }); - - it('arms and reads the Main-owned cloud deployment coordinator', async () => { - const project = { - id: 'prj_cloud_deploy', - path: 'D:/repo/cloud-deploy', - name: 'cloud deploy', - createdAt: '2026-07-12T09:00:00.000Z', - updatedAt: '2026-07-12T09:00:00.000Z', - lastOpenedAt: '2026-07-12T09:00:00.000Z', - }; - const deployment = { - schema_version: 1, - project_id: project.id, - status: 'armed' as const, - requested_at: '2026-07-12T09:00:00.000Z', - updated_at: '2026-07-12T09:00:00.000Z', - }; - const coordinator = { - arm: vi.fn(async () => deployment), - get: vi.fn(async () => deployment), - }; - const context = { - opencodeProjectStore: { listProjects: vi.fn(async () => [project]) }, - worksCloudDeployment: coordinator, - } as never; - - const postResponse = createResponse(); - expect(await handleOpencodeRoutes( - createRequest('POST'), - postResponse.res, - new URL(`http://127.0.0.1/api/opencode/projects/${project.id}/works-cloud-deploy`), - context, - )).toBe(true); - expect(postResponse.statusCode).toBe(202); - expect(postResponse.json()).toEqual({ success: true, deployment }); - expect(coordinator.arm).toHaveBeenCalledWith(project.id); - - const getResponse = createResponse(); - expect(await handleOpencodeRoutes( - createRequest('GET'), - getResponse.res, - new URL(`http://127.0.0.1/api/opencode/projects/${project.id}/works-cloud-deploy`), - context, - )).toBe(true); - expect(getResponse.statusCode).toBe(200); - expect(getResponse.json()).toEqual({ success: true, deployment }); - expect(coordinator.get).toHaveBeenCalledWith(project.id); - }); + expect(handled).toBe(false); + }, + ); it('lists sessions through the active project directory', async () => { const response = createResponse(); diff --git a/tests/unit/project-progress-sync.test.ts b/tests/unit/project-progress-sync.test.ts index b412e3f..f04b07e 100644 --- a/tests/unit/project-progress-sync.test.ts +++ b/tests/unit/project-progress-sync.test.ts @@ -40,17 +40,17 @@ async function writeLegacyPromotionPlan(projectPath: string, content: string): P await writeFile(join(projectPath, 'PROMOTION_PLAN.md'), content, 'utf8'); } -function createServerPublishFile(appId: string): string { +function createServerBindingFile(projectId: string, appId: string): string { return `${JSON.stringify({ + schema_version: 2, + project_id: projectId, + status: 'submitted', + requested_at: '2026-08-10T00:00:00.000Z', + updated_at: '2026-08-10T00:00:00.000Z', app_id: appId, - title: '测试游戏', - summary: '合规同步测试', - category: 'game', - age_band: '10-16', - difficulty: 'beginner', + version_id: 'version-1', version_name: 'v0.1.0', - change_log: 'test', - zip_file_path: 'dist/test.zip', + review_status: 'building', })}\n`; } @@ -95,11 +95,14 @@ describe('project progress compliance synchronization', () => { const localPath = await createProjectDirectory('local'); await writeGameDocuments(serverPath, '# server gdd', '# server tasks'); await writeGameDocuments(localPath, '# local gdd', '# local tasks'); - await writeFile(join(serverPath, 'works-publish.json'), createServerPublishFile('server-game'), 'utf8'); - const projectStore = createProjectStore(createMemoryProjectStorage()); const serverProject = await projectStore.rememberProject(serverPath); const localProject = await projectStore.rememberProject(localPath); + await writeFile( + join(serverPath, 'works-cloud-deploy.json'), + createServerBindingFile(serverProject.id, 'server-game'), + 'utf8', + ); const harness = createWatcherHarness(); const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); const sync = createProjectProgressSync(projectStore, { @@ -129,7 +132,7 @@ describe('project progress compliance synchronization', () => { project_key: localProject.id, }); - const serverChanged = harness.callbacks.get(serverPath); + const serverChanged = harness.callbacks.get(serverProject.path); expect(serverChanged).toBeDefined(); serverChanged?.('GDD.md'); await vi.advanceTimersByTimeAsync(25); @@ -149,10 +152,13 @@ describe('project progress compliance synchronization', () => { const productOverview = '# 产品运营介绍\n\n一句话价值:让玩家在三分钟内完成一次太空清洁任务。'; await writeGameDocuments(projectPath, '# gdd', '# tasks'); await writeProductOverview(projectPath, productOverview); - await writeFile(join(projectPath, 'works-publish.json'), createServerPublishFile('promotion-game'), 'utf8'); - const projectStore = createProjectStore(createMemoryProjectStorage()); const project = await projectStore.rememberProject(projectPath); + await writeFile( + join(projectPath, 'works-cloud-deploy.json'), + createServerBindingFile(project.id, 'promotion-game'), + 'utf8', + ); const harness = createWatcherHarness(); const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); const sync = createProjectProgressSync(projectStore, { @@ -180,10 +186,13 @@ describe('project progress compliance synchronization', () => { await writeGameDocuments(projectPath, '# gdd', '# tasks'); await writeProductOverview(projectPath, productOverview); await writeLegacyPromotionPlan(projectPath, '# 旧运营宣传计划\n\n不应覆盖新文档'); - await writeFile(join(projectPath, 'works-publish.json'), createServerPublishFile('overview-preferred-game'), 'utf8'); - const projectStore = createProjectStore(createMemoryProjectStorage()); const project = await projectStore.rememberProject(projectPath); + await writeFile( + join(projectPath, 'works-cloud-deploy.json'), + createServerBindingFile(project.id, 'overview-preferred-game'), + 'utf8', + ); const harness = createWatcherHarness(); const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); const sync = createProjectProgressSync(projectStore, { @@ -206,10 +215,13 @@ describe('project progress compliance synchronization', () => { const legacyPromotionPlan = '# 旧运营宣传计划\n\n卖点:用简单操作理解环保行动。'; await writeGameDocuments(projectPath, '# gdd', '# tasks'); await writeLegacyPromotionPlan(projectPath, legacyPromotionPlan); - await writeFile(join(projectPath, 'works-publish.json'), createServerPublishFile('legacy-promotion-game'), 'utf8'); - const projectStore = createProjectStore(createMemoryProjectStorage()); const project = await projectStore.rememberProject(projectPath); + await writeFile( + join(projectPath, 'works-cloud-deploy.json'), + createServerBindingFile(project.id, 'legacy-promotion-game'), + 'utf8', + ); const harness = createWatcherHarness(); const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); const sync = createProjectProgressSync(projectStore, { @@ -250,8 +262,12 @@ describe('project progress compliance synchronization', () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/agent/prompt'))).toBe(false); - await writeFile(join(projectPath, 'works-publish.json'), createServerPublishFile('bound-promotion-game'), 'utf8'); - harness.callbacks.get(projectPath)?.('works-publish.json'); + await writeFile( + join(projectPath, 'works-cloud-deploy.json'), + createServerBindingFile(project.id, 'bound-promotion-game'), + 'utf8', + ); + harness.callbacks.get(project.path)?.('works-cloud-deploy.json'); await sync.flushProject(project.id); expect(fetchMock).toHaveBeenCalledTimes(3); @@ -268,10 +284,13 @@ describe('project progress compliance synchronization', () => { const secondOverview = '# 更新后的产品运营介绍\n\n新增真实试玩证据。'; await writeGameDocuments(projectPath, '# gdd', '# tasks'); await writeProductOverview(projectPath, firstOverview); - await writeFile(join(projectPath, 'works-publish.json'), createServerPublishFile('retry-promotion-game'), 'utf8'); - const projectStore = createProjectStore(createMemoryProjectStorage()); const project = await projectStore.rememberProject(projectPath); + await writeFile( + join(projectPath, 'works-cloud-deploy.json'), + createServerBindingFile(project.id, 'retry-promotion-game'), + 'utf8', + ); const harness = createWatcherHarness(); const fetchMock = vi.fn() .mockResolvedValueOnce(new Response('{}', { status: 200 })) @@ -396,13 +415,13 @@ describe('project progress compliance synchronization', () => { synchronizers.push(sync); await sync.start(); - expect(harness.closeMocks.get(projectPath)).toBeDefined(); + expect(harness.closeMocks.get(project.path)).toBeDefined(); await projectStore.removeProject(project.id); expect(sync.getWatchedProjectIds()).toEqual([]); - expect(harness.closeMocks.get(projectPath)).toHaveBeenCalledTimes(1); + expect(harness.closeMocks.get(project.path)).toHaveBeenCalledTimes(1); sync.stop(); - expect(harness.closeMocks.get(projectPath)).toHaveBeenCalledTimes(1); + expect(harness.closeMocks.get(project.path)).toHaveBeenCalledTimes(1); }); it('does not expose document content in error logs or request URLs', async () => { diff --git a/tests/unit/works-cloud-deployment.test.ts b/tests/unit/works-cloud-deployment.test.ts deleted file mode 100644 index fbc063b..0000000 --- a/tests/unit/works-cloud-deployment.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { createHash } from 'node:crypto'; -import { execFile } from 'node:child_process'; -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { promisify } from 'node:util'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createWorksCloudDeployment } from '@electron/services/works-cloud-deployment'; -import type { OpencodeProject } from '@electron/opencode/project-store'; -import { WORKS_CLOUD_DEPLOYMENT_FILE_NAME } from '../../shared/works-cloud-deployment'; -import { REQUIRED_DYNAMIC_WORKS_DEPLOY_CHECK_IDS } from '../../shared/works-square-deploy-check'; - -const tempDirectories: string[] = []; -const execFileAsync = promisify(execFile); - -const project: OpencodeProject = { - id: 'prj_cloud_deployment_test', - path: '', - name: 'cloud deployment test', - createdAt: '2026-07-12T09:00:00.000Z', - updatedAt: '2026-07-12T09:00:00.000Z', - lastOpenedAt: '2026-07-12T09:00:00.000Z', -}; - -function createStore(projectRecord: OpencodeProject) { - return { - listProjects: vi.fn(async () => [projectRecord]), - subscribe: vi.fn(() => () => undefined), - }; -} - -async function createReadyProject(): Promise<{ projectPath: string; zipPath: string }> { - const projectPath = await mkdtemp(join(tmpdir(), 'niancode-cloud-deployment-')); - tempDirectories.push(projectPath); - const sourcePath = join(projectPath, 'package-source'); - const zipPath = join(projectPath, 'cloud-upload.zip'); - await mkdir(sourcePath, { recursive: true }); - const files: Record = { - 'niancode.yml': [ - 'runtime: compose', - 'compose:', - ' file: docker-compose.yml', - ' public_service: frontend', - ' public_port: 8080', - ].join('\n'), - 'docker-compose.yml': [ - 'services:', - ' frontend:', - ' build:', - ' context: .', - ' dockerfile: Dockerfile', - ' ports:', - ' - "127.0.0.1::8080"', - ].join('\n'), - Dockerfile: [ - 'FROM node:20-alpine AS build', - 'WORKDIR /app', - 'COPY package.json package-lock.json ./', - 'RUN npm ci', - 'COPY . .', - 'RUN npm run build', - 'FROM nginx:1.27-alpine', - 'COPY nginx.conf /etc/nginx/conf.d/default.conf', - 'COPY --from=build /app/dist /usr/share/nginx/html', - 'EXPOSE 8080', - 'CMD ["nginx", "-g", "daemon off;"]', - ].join('\n'), - 'package.json': JSON.stringify({ scripts: { build: 'vite build' }, devDependencies: { vite: '^7.0.0' } }), - 'package-lock.json': '{}', - 'vite.config.js': 'export default { base: "./" };', - 'index.html': '', - 'nginx.conf': 'server { listen 0.0.0.0:8080; }', - 'src/main.ts': 'console.log("ready");', - }; - for (const [name, content] of Object.entries(files)) { - const filePath = join(sourcePath, name); - await mkdir(dirname(filePath), { recursive: true }); - await writeFile(filePath, content, 'utf8'); - } - const zipCommand = process.platform === 'win32' - ? { file: 'tar', args: ['-a', '-cf', zipPath, ...Object.keys(files)] } - : { file: 'zip', args: ['-q', '-0', '-r', zipPath, '.'] }; - await execFileAsync(zipCommand.file, zipCommand.args, { cwd: sourcePath }); - const zipSha256 = createHash('sha256').update(await readFile(zipPath)).digest('hex'); - const publish = { - app_id: 'cloud-deployment-test', - title: '云端部署测试', - summary: '用于测试 Main 自动提交。', - category: 'web', - age_band: '10-12', - difficulty: 'beginner', - version_name: 'v1.0.0', - change_log: '自动提交测试', - zip_file_path: zipPath, - }; - await writeFile(join(projectPath, 'works-publish.json'), `${JSON.stringify(publish, null, 2)}\n`, 'utf8'); - await writeFile(join(projectPath, '部署报告.md'), '# PASS\n本机动态检查已执行。\n', 'utf8'); - const checks = Object.fromEntries(REQUIRED_DYNAMIC_WORKS_DEPLOY_CHECK_IDS.map((id) => [id, { - status: 'PASS', - detail: `${id} 通过`, - command: `check ${id}`, - execution: 'executed', - }])); - await writeFile(join(projectPath, 'works-deploy-check.json'), `${JSON.stringify({ - schema_version: 1, - status: 'PASS', - checked_at: '2026-07-12T00:00:00.000Z', - zip_file_path: zipPath, - zip_sha256: zipSha256, - checks, - }, null, 2)}\n`, 'utf8'); - return { projectPath, zipPath }; -} - -async function waitForStatus(projectPath: string, expected: string): Promise> { - let lastRecord: Record | undefined; - for (let attempt = 0; attempt < 100; attempt += 1) { - try { - const record = JSON.parse(await readFile(join(projectPath, WORKS_CLOUD_DEPLOYMENT_FILE_NAME), 'utf8')) as Record; - lastRecord = record; - if (record.status === expected) return record; - if (record.status === 'failed') { - throw new Error(`Deployment failed while waiting for ${expected}: ${String(record.error)}`); - } - } catch { - // The coordinator may not have written the first status yet. - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error(`Timed out waiting for ${expected}; last record: ${JSON.stringify(lastRecord)}`); -} - -afterEach(async () => { - await Promise.all(tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); -}); - -describe('Main-owned Works Square cloud deployment', () => { - it('records an already submitted one-click release without starting another upload', async () => { - const projectPath = await mkdtemp(join(tmpdir(), 'niancode-cloud-record-')); - tempDirectories.push(projectPath); - const projectRecord = { ...project, path: projectPath }; - const fetchMock = vi.fn(); - const coordinator = createWorksCloudDeployment(createStore(projectRecord), { - apiBaseUrl: 'https://square.test', - fetchImpl: fetchMock, - watchDirectory: () => ({ close: vi.fn() }), - }); - - const record = await coordinator.recordSubmitted(projectRecord.id, { - appId: 'makelore-project', - versionId: 'version-remote-7', - versionName: 'v2.0.0', - reviewStatus: 'building', - zipSha256: 'a'.repeat(64), - }); - - expect(record).toMatchObject({ - project_id: projectRecord.id, - status: 'submitted', - app_id: 'makelore-project', - version_id: 'version-remote-7', - version_name: 'v2.0.0', - review_status: 'building', - zip_sha256: 'a'.repeat(64), - }); - await expect(coordinator.get(projectRecord.id)).resolves.toEqual(record); - expect(JSON.parse(await readFile( - join(projectPath, WORKS_CLOUD_DEPLOYMENT_FILE_NAME), - 'utf8', - ))).toEqual(record); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('keeps an armed task waiting for the package instead of asking for server SSH', async () => { - const projectPath = await mkdtemp(join(tmpdir(), 'niancode-cloud-waiting-')); - tempDirectories.push(projectPath); - const projectRecord = { ...project, path: projectPath }; - const coordinator = createWorksCloudDeployment(createStore(projectRecord), { - apiBaseUrl: 'https://square.test', - debounceMs: 0, - getAccessToken: async () => 'access-token', - watchDirectory: () => ({ close: vi.fn() }), - }); - await coordinator.start(); - await coordinator.arm(projectRecord.id); - - const record = await waitForStatus(projectPath, 'waiting_for_package'); - expect(record.error).toBeUndefined(); - coordinator.stop(); - }); - - it('submits a ready ZIP from Main without exposing the access token to the Agent', async () => { - const { projectPath, zipPath } = await createReadyProject(); - const projectRecord = { ...project, path: projectPath }; - const fetchMock = vi.fn() - .mockResolvedValueOnce(new Response(null, { status: 201 })) - .mockResolvedValueOnce(new Response(JSON.stringify({ version_id: 'version-remote-1', review_status: 'building' }), { status: 201 })); - const coordinator = createWorksCloudDeployment(createStore(projectRecord), { - apiBaseUrl: 'https://square.test', - debounceMs: 0, - fetchImpl: fetchMock, - getAccessToken: async () => 'access-token', - watchDirectory: () => ({ close: vi.fn() }), - }); - await coordinator.start(); - await coordinator.arm(projectRecord.id); - - const record = await waitForStatus(projectPath, 'submitted'); - expect(record).toMatchObject({ - project_id: projectRecord.id, - app_id: 'cloud-deployment-test', - version_id: 'version-remote-1', - review_status: 'building', - }); - expect(record.zip_sha256).toMatch(/^[a-f0-9]{64}$/); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchMock.mock.calls[0]?.[0]).toBe('https://square.test/api/projects'); - expect(fetchMock.mock.calls[1]?.[0]).toBe('https://square.test/api/projects/cloud-deployment-test/versions/upload'); - expect((fetchMock.mock.calls[1]?.[1] as RequestInit).headers).toEqual({ Authorization: 'Bearer access-token' }); - const form = (fetchMock.mock.calls[1]?.[1] as RequestInit).body as FormData; - expect(form.get('version_name')).toBe('v1.0.0'); - expect(form.get('change_log')).toBe('自动提交测试'); - expect((form.get('archive') as File).name).toBe('cloud-upload.zip'); - expect(zipPath).toContain('cloud-upload.zip'); - coordinator.stop(); - }); -}); diff --git a/tests/unit/works-publish-helpers.test.ts b/tests/unit/works-publish-helpers.test.ts deleted file mode 100644 index 7e6ff4a..0000000 --- a/tests/unit/works-publish-helpers.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { buildDeployProductBuildPrompt } from '@/lib/works-publish-helpers'; - -describe('deployment product build prompt', () => { - it('requires three-state machine evidence without blocking on missing local tools', () => { - const prompt = buildDeployProductBuildPrompt({ - projectName: '测试作品', - projectPath: '/tmp/test-project', - zipPath: null, - }); - - expect(prompt).toContain('works-deploy-check.json'); - expect(prompt).toContain('compose_config'); - expect(prompt).toContain('fresh_directory_smoke'); - expect(prompt).toContain('sandbox_browser_smoke'); - expect(prompt).toContain('execution: unavailable'); - expect(prompt).toContain('Main 会在交接文件就绪'); - expect(prompt).toContain('不要让用户去服务器手动启动 Docker'); - expect(prompt).toContain('PASS、SKIPPED、BLOCKED'); - expect(prompt).toContain('Docker 或浏览器自动化不可用时写 SKIPPED'); - expect(prompt).toContain('zip_sha256'); - expect(prompt).toContain('哈希不一致仍然 BLOCKED'); - }); -}); diff --git a/tests/unit/works-routes.test.ts b/tests/unit/works-routes.test.ts index 5dc6cc8..de5232a 100644 --- a/tests/unit/works-routes.test.ts +++ b/tests/unit/works-routes.test.ts @@ -7,18 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { handleWorksRoutes } from '@electron/api/routes/works'; import { createProjectConfig } from '../../shared/project-config'; -const readWorksPublishFileMock = vi.hoisted(() => vi.fn()); -const readWorksDeployCheckMock = vi.hoisted(() => vi.fn()); const getValidWorksSquareAccessTokenMock = vi.hoisted(() => vi.fn()); -vi.mock('@electron/opencode/works-publish-file', () => ({ - readWorksPublishFile: (...args: unknown[]) => readWorksPublishFileMock(...args), -})); - -vi.mock('@electron/opencode/works-square-deploy-check', () => ({ - readWorksDeployCheck: (...args: unknown[]) => readWorksDeployCheckMock(...args), -})); - vi.mock('@electron/services/works-square-session', () => ({ getValidWorksSquareAccessToken: (...args: unknown[]) => getValidWorksSquareAccessTokenMock(...args), })); @@ -87,8 +77,6 @@ describe('works square host api routes', () => { beforeEach(() => { vi.restoreAllMocks(); - readWorksPublishFileMock.mockReset(); - readWorksDeployCheckMock.mockReset(); getValidWorksSquareAccessTokenMock.mockReset(); getValidWorksSquareAccessTokenMock.mockResolvedValue('main-owned-access-token'); }); @@ -708,6 +696,7 @@ describe('works square host api routes', () => { status: 'draft', updated_at: '2026-06-20T22:55:37.790408+08:00', playable: false, + play_url: '/apps/space-cleaner/', runtime_url: null, owner_email: 'private@example.com', }, @@ -754,6 +743,7 @@ describe('works square host api routes', () => { status: 'draft', updated_at: '2026-06-20T22:55:37.790408+08:00', playable: false, + play_url: '/apps/space-cleaner/', runtime_url: null, }, latest_version: { @@ -785,6 +775,23 @@ describe('works square host api routes', () => { ); }); + it('does not expose the retired direct ZIP upload route', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + + const handled = await handleWorksRoutes( + createRequest('POST', {}), + response.res, + new URL('http://127.0.0.1/api/works/projects/space-cleaner/versions/upload'), + {} as never, + ); + + expect(handled).toBe(true); + expect(response.statusCode).toBe(404); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('loads current project status with the Main-owned session when no renderer token is sent', async () => { const status = { project: { @@ -904,126 +911,6 @@ describe('works square host api routes', () => { expect(JSON.stringify(response.json())).not.toContain('token=secret'); }); - it.each(['pass', 'warning'] as const)('uploads a zip version when the deployment check is %s', async (deployStatus) => { - tempDir = await mkdtemp(join(tmpdir(), 'niancode-works-upload-')); - const zipPath = join(tempDir, 'project.zip'); - await writeFile(zipPath, Buffer.from('zip bytes')); - const fetchMock = vi.fn().mockResolvedValueOnce( - new Response(JSON.stringify({ - version_id: 'b2f6f1dd-cf4b-4f03-a4b8-a71e2c8dd5f1', - review_status: 'building', - }), { status: 201 }), - ); - vi.stubGlobal('fetch', fetchMock); - const response = createResponse(); - const project = { id: 'project-1', path: tempDir, name: 'space-cleaner' }; - readWorksPublishFileMock.mockResolvedValue({ - status: 'ready', - filePath: join(tempDir, 'works-publish.json'), - publish: { - app_id: 'space-cleaner', - title: '太空清洁队', - summary: '收集漂浮垃圾的小游戏。', - category: 'game', - age_band: '8-12', - difficulty: 'beginner', - version_name: 'v1.0.0', - change_log: 'First submitted version', - zip_file_path: zipPath, - }, - }); - readWorksDeployCheckMock.mockResolvedValue({ - status: deployStatus, - filePath: join(tempDir, 'works-deploy-check.json'), - checks: {}, - }); - - const handled = await handleWorksRoutes( - createRequest('POST', { - accessToken: 'access-token', - projectId: project.id, - versionName: 'v1.0.0', - changeLog: 'First submitted version', - zipFilePath: zipPath, - }), - response.res, - new URL('http://127.0.0.1/api/works/projects/space-cleaner/versions/upload'), - { opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never, - ); - - expect(handled).toBe(true); - expect(response.statusCode).toBe(201); - expect(response.json()).toEqual({ - success: true, - upload: { - version_id: 'b2f6f1dd-cf4b-4f03-a4b8-a71e2c8dd5f1', - review_status: 'building', - }, - }); - expect(fetchMock).toHaveBeenCalledOnce(); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toBe('https://square.nianxx.cn/api/projects/space-cleaner/versions/upload'); - expect(init.method).toBe('POST'); - expect(init.headers).toEqual({ Authorization: 'Bearer access-token' }); - expect(init.body).toBeInstanceOf(FormData); - - const form = init.body as FormData; - expect(form.get('version_name')).toBe('v1.0.0'); - expect(form.get('change_log')).toBe('First submitted version'); - const archive = form.get('archive'); - expect(archive).toBeInstanceOf(File); - expect((archive as File).name).toBe('project.zip'); - expect((archive as File).type).toBe('application/zip'); - }); - - it('blocks upload when the deployment check is not PASS', async () => { - tempDir = await mkdtemp(join(tmpdir(), 'niancode-works-upload-blocked-')); - const zipPath = join(tempDir, 'project.zip'); - await writeFile(zipPath, Buffer.from('zip bytes')); - const project = { id: 'project-blocked', path: tempDir, name: 'blocked' }; - readWorksPublishFileMock.mockResolvedValue({ - status: 'ready', - filePath: join(tempDir, 'works-publish.json'), - publish: { - app_id: 'blocked-app', - title: '阻断测试', - summary: '阻断测试', - category: 'web', - age_band: '10-12', - difficulty: 'beginner', - version_name: 'v1.0.0', - change_log: '阻断测试', - zip_file_path: zipPath, - }, - }); - readWorksDeployCheckMock.mockResolvedValue({ - status: 'blocked', - filePath: join(tempDir, 'works-deploy-check.json'), - error: 'HTTP smoke 失败', - }); - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - const response = createResponse(); - - const handled = await handleWorksRoutes( - createRequest('POST', { - accessToken: 'access-token', - projectId: project.id, - versionName: 'v1.0.0', - changeLog: '阻断测试', - zipFilePath: zipPath, - }), - response.res, - new URL('http://127.0.0.1/api/works/projects/blocked-app/versions/upload'), - { opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never, - ); - - expect(handled).toBe(true); - expect(response.statusCode).toBe(400); - expect(response.json()).toEqual({ success: false, error: 'BLOCKED: HTTP smoke 失败' }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - it('packages and submits source with Main-owned credentials and automatic release metadata', async () => { tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-publish-')); await writePublishableProject(tempDir); @@ -1054,7 +941,7 @@ describe('works square host api routes', () => { new URL('http://127.0.0.1/api/works/projects/publish-source'), { opencodeProjectStore: { listProjects: vi.fn(async () => [project]) }, - worksCloudDeployment: { recordSubmitted }, + worksSubmissionBinding: { recordSubmitted }, } as never, ); @@ -1146,7 +1033,7 @@ describe('works square host api routes', () => { new URL('http://127.0.0.1/api/works/projects/publish-source'), { opencodeProjectStore: { listProjects: vi.fn(async () => [project]) }, - worksCloudDeployment: { + worksSubmissionBinding: { recordSubmitted: vi.fn(async () => { throw new Error('disk unavailable'); }), }, } as never, diff --git a/tests/unit/works-square-deploy-check.test.ts b/tests/unit/works-square-deploy-check.test.ts deleted file mode 100644 index bf3d0cd..0000000 --- a/tests/unit/works-square-deploy-check.test.ts +++ /dev/null @@ -1,461 +0,0 @@ -import { createHash } from 'node:crypto'; -import { execFile } from 'node:child_process'; -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { promisify } from 'node:util'; -import { dirname, join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { afterEach, describe, expect, it } from 'vitest'; -import { readWorksDeployCheck } from '@electron/opencode/works-square-deploy-check'; -import { - GAME_CANVAS_DYNAMIC_WORKS_DEPLOY_CHECK_ID, - REQUIRED_DYNAMIC_WORKS_DEPLOY_CHECK_IDS, -} from '../../shared/works-square-deploy-check'; - -const execFileAsync = promisify(execFile); - -const projects: string[] = []; - -function validCompose(volumes = ''): string { - return [ - 'services:', - ' frontend:', - ' build:', - ' context: .', - ' dockerfile: Dockerfile', - ' ports:', - ' - "127.0.0.1::8080"', - volumes ? ` volumes:\n${volumes}` : '', - ].filter(Boolean).join('\n'); -} - -function validFiles(compose = validCompose(), overrides: Record = {}): Record { - return { - 'niancode.yml': [ - 'runtime: compose', - 'compose:', - ' file: docker-compose.yml', - ' public_service: frontend', - ' public_port: 8080', - ].join('\n'), - 'docker-compose.yml': compose, - Dockerfile: [ - 'FROM node:20-alpine AS build', - 'WORKDIR /app', - 'COPY package.json package-lock.json ./', - 'RUN npm ci', - 'COPY . .', - 'RUN npm run build', - 'FROM nginx:1.27-alpine', - 'COPY nginx.conf /etc/nginx/conf.d/default.conf', - 'COPY --from=build /app/dist /usr/share/nginx/html', - 'EXPOSE 8080', - 'CMD ["nginx", "-g", "daemon off;"]', - ].join('\n'), - 'package.json': JSON.stringify({ scripts: { build: 'vite build' }, devDependencies: { vite: '^7.0.0' } }), - 'package-lock.json': '{}', - 'vite.config.js': 'export default { base: "./" };', - 'index.html': '', - 'nginx.conf': 'server { listen 0.0.0.0:8080; }', - 'src/main.ts': 'console.log("ready");', - ...overrides, - }; -} - -type ProjectOptions = { - category?: string; - files?: Record; - includeCanvasCheck?: boolean; - localSmokeUnavailable?: boolean; - reportChecks?: Record; - reportStatus?: 'PASS' | 'SKIPPED' | 'BLOCKED'; -}; - -async function createProject(compose = validCompose(), options: ProjectOptions = {}): Promise<{ projectPath: string; zipPath: string }> { - const projectPath = await mkdtemp(join(tmpdir(), 'niancode-deploy-check-')); - projects.push(projectPath); - const zipPath = join(projectPath, 'project-upload.zip'); - const sourcePath = join(projectPath, 'package-source'); - await mkdir(sourcePath, { recursive: true }); - for (const [name, content] of Object.entries(validFiles(compose, options.files))) { - const filePath = join(sourcePath, name); - await mkdir(dirname(filePath), { recursive: true }); - await writeFile(filePath, content, 'utf8'); - } - await execFileAsync('zip', ['-q', '-0', '-r', zipPath, '.'], { cwd: sourcePath }); - const zipSha256 = createHash('sha256').update(await readFile(zipPath)).digest('hex'); - const publish = { - app_id: 'deploy-check-fixture', - title: '部署检查测试包', - summary: '用于测试部署门禁。', - category: options.category ?? 'web', - age_band: '10-12', - difficulty: 'beginner', - version_name: 'v1.0.0', - change_log: '测试包', - zip_file_path: zipPath, - }; - await writeFile(join(projectPath, 'works-publish.json'), `${JSON.stringify(publish, null, 2)}\n`, 'utf8'); - await writeFile(join(projectPath, '部署报告.md'), options.localSmokeUnavailable - ? '# BLOCKED\n本机 Docker/浏览器不可用,交由 Works Square 云端构建验证。\n' - : options.includeCanvasCheck - ? '# PASS\n逻辑分辨率:800x450\n桌面视口:1280x720,主画布实际尺寸:1280x720,最大等比预期尺寸:1280x720\n移动视口:390x844,主画布实际尺寸:390x219.375,最大等比预期尺寸:390x219.375\nresize 复测:已重新布局\n' - : '# PASS\n', 'utf8'); - const checks: Record = Object.fromEntries(REQUIRED_DYNAMIC_WORKS_DEPLOY_CHECK_IDS.map((id) => [id, options.localSmokeUnavailable - ? { - status: 'BLOCKED', - detail: `${id}:本机 Docker/浏览器不可用,交由 Works Square 云端构建验证`, - command: `check ${id}`, - execution: 'unavailable', - } - : { - status: 'PASS', - detail: `${id} 通过`, - command: `check ${id}`, - execution: 'executed', - }])); - if (options.includeCanvasCheck) { - checks[GAME_CANVAS_DYNAMIC_WORKS_DEPLOY_CHECK_ID] = { - status: 'PASS', - detail: '桌面、移动和 resize 复测通过', - command: 'playwright canvas viewport smoke', - canvas_evidence: { - logical_resolution: { width: 800, height: 450 }, - desktop_viewport: { width: 1280, height: 720 }, - mobile_viewport: { width: 390, height: 844 }, - desktop_canvas: { width: 1280, height: 720 }, - mobile_canvas: { width: 390, height: 219.375 }, - expected_max_canvas: { - desktop: { width: 1280, height: 720 }, - mobile: { width: 390, height: 219.375 }, - }, - resize_verified: true, - }, - }; - } - Object.assign(checks, options.reportChecks); - await writeFile(join(projectPath, 'works-deploy-check.json'), `${JSON.stringify({ - schema_version: 1, - status: options.localSmokeUnavailable ? 'BLOCKED' : options.reportStatus ?? 'PASS', - checked_at: '2026-07-12T00:00:00.000Z', - zip_file_path: zipPath, - zip_sha256: zipSha256, - checks, - }, null, 2)}\n`, 'utf8'); - return { projectPath, zipPath }; -} - -function readProjectCheck(projectPath: string) { - return readWorksDeployCheck(projectPath, { - app_id: 'deploy-check-fixture', - title: '部署检查测试包', - summary: '用于测试部署门禁。', - category: 'web', - age_band: '10-12', - difficulty: 'beginner', - version_name: 'v1.0.0', - change_log: '测试包', - zip_file_path: join(projectPath, 'project-upload.zip'), - }); -} - -afterEach(async () => { - await Promise.all(projects.splice(0).map((projectPath) => rm(projectPath, { recursive: true, force: true }))); -}); - -describe('Works Square deployment package checks', () => { - it('accepts SKIPPED for unavailable Docker checks', async () => { - const { projectPath } = await createProject(validCompose(), { - reportStatus: 'SKIPPED', - reportChecks: { - compose_build: { - status: 'SKIPPED', - detail: 'Docker not installed on current environment', - command: 'docker compose build --pull=false', - }, - }, - }); - - const result = await readProjectCheck(projectPath); - - expect(result.report?.checks.compose_build?.status).toBe('SKIPPED'); - }); - - it('normalizes legacy environment-only BLOCKED checks to SKIPPED', async () => { - const { projectPath } = await createProject(validCompose(), { - reportStatus: 'BLOCKED', - reportChecks: { - sandbox_browser_smoke: { - status: 'BLOCKED', - detail: 'No browser automation available (Playwright/Puppeteer)', - command: 'playwright test sandbox-smoke.spec.ts', - }, - }, - }); - - const result = await readProjectCheck(projectPath); - - expect(result.report?.checks.sandbox_browser_smoke?.status).toBe('SKIPPED'); - }); - - it.each([ - ['not verified', 'docker compose build --pull=false'], - ['docker compose up exited 1', 'docker compose up -d'], - ])('does not normalize a non-environment failure: %s', async (detail, command) => { - const { projectPath } = await createProject(validCompose(), { - reportStatus: 'BLOCKED', - reportChecks: { - compose_up: { status: 'BLOCKED', detail, command }, - }, - }); - - const result = await readProjectCheck(projectPath); - - expect(result.report?.checks.compose_up?.status).toBe('BLOCKED'); - }); - - it('returns warning when only environment-dependent checks are skipped', async () => { - const { projectPath } = await createProject(validCompose(), { - reportStatus: 'SKIPPED', - reportChecks: { - compose_build: { - status: 'SKIPPED', - detail: 'Docker not installed on current environment', - command: 'docker compose build --pull=false', - }, - }, - }); - - expect((await readProjectCheck(projectPath)).status).toBe('warning'); - }); - - it('blocks a hash mismatch even when dynamic checks are skipped', async () => { - const { projectPath } = await createProject(validCompose(), { - reportStatus: 'SKIPPED', - reportChecks: { - compose_build: { - status: 'SKIPPED', - detail: 'Docker not installed on current environment', - command: 'docker compose build --pull=false', - }, - }, - }); - await writeFile(join(projectPath, 'project-upload.zip'), Buffer.from('changed zip contents')); - - const result = await readProjectCheck(projectPath); - - expect(result.status).toBe('blocked'); - expect(result.error).toContain('当前 zip'); - }); - - it('blocks an executed dynamic failure', async () => { - const { projectPath } = await createProject(validCompose(), { - reportStatus: 'BLOCKED', - reportChecks: { - compose_up: { - status: 'BLOCKED', - detail: 'docker compose up exited 1', - command: 'docker compose up -d', - }, - }, - }); - - expect((await readProjectCheck(projectPath)).status).toBe('blocked'); - }); - - it('passes a self-contained Compose/Vite package with a matching report', async () => { - const { projectPath } = await createProject(); - const result = await readWorksDeployCheck(projectPath, { - app_id: 'deploy-check-fixture', - title: '部署检查测试包', - summary: '用于测试部署门禁。', - category: 'web', - age_band: '10-12', - difficulty: 'beginner', - version_name: 'v1.0.0', - change_log: '测试包', - zip_file_path: join(projectPath, 'project-upload.zip'), - }); - - expect(result.status).toBe('pass'); - expect(result.report?.status).toBe('PASS'); - expect(Object.values(result.checks ?? {}).every((check) => check.status === 'PASS')).toBe(true); - }); - - it('blocks bind mounts even when the agent report says PASS', async () => { - const { projectPath } = await createProject('services:\n frontend:\n build: .\n ports:\n - "127.0.0.1::8080"\n volumes:\n - .:/app'); - const result = await readWorksDeployCheck(projectPath, { - app_id: 'deploy-check-fixture', - title: '部署检查测试包', - summary: '用于测试部署门禁。', - category: 'web', - age_band: '10-12', - difficulty: 'beginner', - version_name: 'v1.0.0', - change_log: '测试包', - zip_file_path: join(projectPath, 'project-upload.zip'), - }); - - expect(result.status).toBe('blocked'); - expect(result.error).toContain('bind mount'); - }); - - it('blocks a report that is no longer bound to the current ZIP', async () => { - const { projectPath } = await createProject(); - await writeFile(join(projectPath, 'project-upload.zip'), Buffer.from('changed zip contents')); - const result = await readWorksDeployCheck(projectPath, { - app_id: 'deploy-check-fixture', - title: '部署检查测试包', - summary: '用于测试部署门禁。', - category: 'web', - age_band: '10-12', - difficulty: 'beginner', - version_name: 'v1.0.0', - change_log: '测试包', - zip_file_path: join(projectPath, 'project-upload.zip'), - }); - - expect(result.status).toBe('blocked'); - expect(result.error).toContain('当前 zip'); - }); - - it('allows safe Compose defaults and requires an all-interface container listener', async () => { - const safeCompose = validCompose().replace( - ' ports:', - ' environment:\n API_URL: "${API_URL:-http://backend:8000}"\n ports:', - ); - const { projectPath } = await createProject(safeCompose); - const safeResult = await readWorksDeployCheck(projectPath, { - app_id: 'deploy-check-fixture', - title: '部署检查测试包', - summary: '用于测试部署门禁。', - category: 'web', - age_band: '10-12', - difficulty: 'beginner', - version_name: 'v1.0.0', - change_log: '测试包', - zip_file_path: join(projectPath, 'project-upload.zip'), - }); - expect(safeResult.status).toBe('pass'); - - const safeEnvFileCompose = validCompose().replace( - ' ports:', - ' env_file:\n - .env.example\n ports:', - ); - const { projectPath: safeEnvFileProjectPath } = await createProject(safeEnvFileCompose, { - files: { '.env.example': 'API_URL=http://backend:8000\n' }, - }); - const safeEnvFileResult = await readWorksDeployCheck(safeEnvFileProjectPath, { - app_id: 'deploy-check-fixture', - title: '部署检查测试包', - summary: '用于测试部署门禁。', - category: 'web', - age_band: '10-12', - difficulty: 'beginner', - version_name: 'v1.0.0', - change_log: '测试包', - zip_file_path: join(safeEnvFileProjectPath, 'project-upload.zip'), - }); - expect(safeEnvFileResult.status).toBe('pass'); - - const { projectPath: unresolvedEnvProjectPath } = await createProject( - validCompose().replace( - ' ports:', - ' environment:\n API_URL: "${API_URL}"\n ports:', - ), - ); - const unresolvedEnvResult = await readWorksDeployCheck(unresolvedEnvProjectPath, { - app_id: 'deploy-check-fixture', - title: '部署检查测试包', - summary: '用于测试部署门禁。', - category: 'web', - age_band: '10-12', - difficulty: 'beginner', - version_name: 'v1.0.0', - change_log: '测试包', - zip_file_path: join(unresolvedEnvProjectPath, 'project-upload.zip'), - }); - expect(unresolvedEnvResult.status).toBe('blocked'); - expect(unresolvedEnvResult.error).toContain('未声明环境变量'); - - const { projectPath: blockedProjectPath } = await createProject(validCompose(), { - files: { 'nginx.conf': 'server { listen 127.0.0.1:8080; }' }, - }); - const blockedResult = await readWorksDeployCheck(blockedProjectPath, { - app_id: 'deploy-check-fixture', - title: '部署检查测试包', - summary: '用于测试部署门禁。', - category: 'web', - age_band: '10-12', - difficulty: 'beginner', - version_name: 'v1.0.0', - change_log: '测试包', - zip_file_path: join(blockedProjectPath, 'project-upload.zip'), - }); - expect(blockedResult.status).toBe('blocked'); - expect(blockedResult.error).toContain('0.0.0.0:8080'); - }); - - it('requires responsive canvas evidence for game projects', async () => { - const files = { - 'style.css': 'html, body, #app, #game-container { width: 100%; height: 100%; margin: 0; } canvas { display: block; }', - 'src/game.ts': 'const config = { width: 800, height: 450, parent: "game-container", scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH } }; window.addEventListener("resize", () => config);', - }; - const { projectPath } = await createProject(validCompose(), { category: 'game', files, includeCanvasCheck: true }); - const result = await readWorksDeployCheck(projectPath, { - app_id: 'deploy-check-fixture', - title: '游戏部署检查测试包', - summary: '用于测试游戏部署门禁。', - category: 'game', - age_band: '10-12', - difficulty: 'beginner', - version_name: 'v1.0.0', - change_log: '测试包', - zip_file_path: join(projectPath, 'project-upload.zip'), - }); - expect(result.status).toBe('pass'); - expect(result.checks?.[GAME_CANVAS_DYNAMIC_WORKS_DEPLOY_CHECK_ID]?.status).toBe('PASS'); - }); - - it('blocks a game project when canvas smoke evidence is missing', async () => { - const files = { - 'style.css': 'html, body, #app, #game-container { width: 100%; height: 100%; margin: 0; } canvas { display: block; }', - 'src/game.ts': 'const config = { scale: { mode: Phaser.Scale.FIT } };', - }; - const { projectPath } = await createProject(validCompose(), { category: 'game', files }); - const result = await readWorksDeployCheck(projectPath, { - app_id: 'deploy-check-fixture', - title: '游戏部署检查测试包', - summary: '用于测试游戏部署门禁。', - category: 'game', - age_band: '10-12', - difficulty: 'beginner', - version_name: 'v1.0.0', - change_log: '测试包', - zip_file_path: join(projectPath, 'project-upload.zip'), - }); - expect(result.status).toBe('blocked'); - expect(result.error).toContain('game_canvas_smoke'); - }); - - it('keeps local mode strict but allows explicitly unavailable local smoke in cloud mode', async () => { - const { projectPath } = await createProject(validCompose(), { localSmokeUnavailable: true }); - const publish = { - app_id: 'deploy-check-fixture', - title: '部署检查测试包', - summary: '用于测试部署门禁。', - category: 'web', - age_band: '10-12', - difficulty: 'beginner', - version_name: 'v1.0.0', - change_log: '测试包', - zip_file_path: join(projectPath, 'project-upload.zip'), - }; - - const localResult = await readWorksDeployCheck(projectPath, publish); - expect(localResult.status).toBe('blocked'); - - const cloudResult = await readWorksDeployCheck(projectPath, publish, { mode: 'cloud' }); - expect(cloudResult.status).toBe('pass'); - expect(cloudResult.zip_sha256).toMatch(/^[a-f0-9]{64}$/); - }); -}); diff --git a/tests/unit/works-square.test.ts b/tests/unit/works-square.test.ts index 52999e9..b709776 100644 --- a/tests/unit/works-square.test.ts +++ b/tests/unit/works-square.test.ts @@ -12,7 +12,6 @@ import { fetchWorksProjects, publishWorksProjectSource, toPlazaCard, - uploadWorksProjectZip, WorksSquareApiError, type ProjectPublic, } from '@/lib/works-square'; @@ -313,43 +312,6 @@ describe('works square client', () => { expect(error.message).toBe('Conflict'); }); - it('uploads a project zip version with the current access token', async () => { - hostApiFetchMock.mockResolvedValueOnce({ - success: true, - upload: { - version_id: 'b2f6f1dd-cf4b-4f03-a4b8-a71e2c8dd5f1', - review_status: 'building', - }, - }); - - const result = await uploadWorksProjectZip({ - accessToken: 'access-token', - appId: 'space cleaner', - projectId: 'project-1', - versionName: 'v1.0.0', - changeLog: 'Initial upload', - zipFilePath: '/tmp/project.zip', - }); - - expect(result).toEqual({ - version_id: 'b2f6f1dd-cf4b-4f03-a4b8-a71e2c8dd5f1', - review_status: 'building', - }); - expect(hostApiFetchMock).toHaveBeenCalledWith( - '/api/works/projects/space%20cleaner/versions/upload', - { - method: 'POST', - body: JSON.stringify({ - accessToken: 'access-token', - projectId: 'project-1', - versionName: 'v1.0.0', - changeLog: 'Initial upload', - zipFilePath: '/tmp/project.zip', - }), - }, - ); - }); - it('asks Main to package and submit source without renderer-owned secrets or archive fields', async () => { const publishResult = { package: { @@ -483,7 +445,8 @@ describe('works square client', () => { difficulty: 'beginner', updated_at: '2026-06-20T22:55:37.790408+08:00', playable: true, - runtime_url: '/apps/space-cleaner/', + play_url: '/apps/space-cleaner/', + runtime_url: '/apps/legacy-space-cleaner/', }; expect(toPlazaCard(project)).toEqual({ @@ -499,4 +462,16 @@ describe('works square client', () => { runtimeUrl: '/apps/space-cleaner/', }); }); + + it('keeps runtime_url as a compatibility fallback for one release', () => { + const project: ProjectPublic = { + app_id: 'legacy-game', + title: 'Legacy Game', + summary: 'Compatibility fixture', + playable: true, + runtime_url: '/apps/legacy-game/', + }; + + expect(toPlazaCard(project).runtimeUrl).toBe('/apps/legacy-game/'); + }); }); diff --git a/tests/unit/works-submission-binding.test.ts b/tests/unit/works-submission-binding.test.ts new file mode 100644 index 0000000..1952af6 --- /dev/null +++ b/tests/unit/works-submission-binding.test.ts @@ -0,0 +1,130 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createWorksSubmissionBindingStore, + readWorksSubmissionBinding, +} from '@electron/services/works-submission-binding'; +import type { OpencodeProject } from '@electron/opencode/project-store'; +import { + WORKS_SUBMISSION_BINDING_FILE_NAME, + WORKS_SUBMISSION_BINDING_SCHEMA_VERSION, +} from '../../shared/works-submission-binding'; + +const temporaryDirectories: string[] = []; + +async function createProject(): Promise { + const projectPath = await mkdtemp(join(tmpdir(), 'makelore-submission-binding-')); + temporaryDirectories.push(projectPath); + return { + id: 'project-1', + path: projectPath, + name: 'submission binding test', + createdAt: '2026-08-10T00:00:00.000Z', + updatedAt: '2026-08-10T00:00:00.000Z', + lastOpenedAt: '2026-08-10T00:00:00.000Z', + }; +} + +function createStore(project: OpencodeProject) { + return { listProjects: vi.fn(async () => [project]) }; +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => ( + rm(directory, { recursive: true, force: true }) + ))); +}); + +describe('Works Square submission binding store', () => { + it('records the exact one-click submitted version without owning upload behavior', async () => { + const project = await createProject(); + const store = createWorksSubmissionBindingStore(createStore(project)); + + const record = await store.recordSubmitted(project.id, { + appId: 'planet-game', + versionId: 'version-7', + versionName: 'v0.7.0', + reviewStatus: 'building', + zipSha256: 'a'.repeat(64), + }); + + expect(record).toMatchObject({ + schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION, + project_id: project.id, + status: 'submitted', + app_id: 'planet-game', + version_id: 'version-7', + version_name: 'v0.7.0', + review_status: 'building', + zip_sha256: 'a'.repeat(64), + }); + await expect(store.get(project.id)).resolves.toEqual(record); + await expect(readWorksSubmissionBinding(project.path)).resolves.toEqual(record); + }); + + it.each(['armed', 'waiting_for_package', 'waiting_for_login', 'uploading', 'failed'])( + 'retires a legacy %s task with an understandable next step', + async (status) => { + const project = await createProject(); + await writeFile( + join(project.path, WORKS_SUBMISSION_BINDING_FILE_NAME), + `${JSON.stringify({ + schema_version: 1, + project_id: project.id, + status, + requested_at: '2026-08-01T00:00:00.000Z', + updated_at: '2026-08-01T00:05:00.000Z', + })}\n`, + 'utf8', + ); + const store = createWorksSubmissionBindingStore(createStore(project)); + + await store.start(); + + const record = await store.get(project.id); + expect(record).toMatchObject({ + schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION, + project_id: project.id, + status: 'legacy_retired', + error_code: 'LEGACY_AUTO_DEPLOY_RETIRED', + message: '旧版自动部署任务已停用,请在项目配置中点击“提交审核”重新提交。', + }); + expect(JSON.parse(await readFile( + join(project.path, WORKS_SUBMISSION_BINDING_FILE_NAME), + 'utf8', + ))).toEqual(record); + }, + ); + + it('preserves a submitted legacy version binding while upgrading its schema', async () => { + const project = await createProject(); + await writeFile( + join(project.path, WORKS_SUBMISSION_BINDING_FILE_NAME), + `${JSON.stringify({ + schema_version: 1, + project_id: project.id, + status: 'submitted', + requested_at: '2026-08-01T00:00:00.000Z', + updated_at: '2026-08-01T00:05:00.000Z', + app_id: 'planet-game', + version_id: 'version-6', + version_name: 'v0.6.0', + review_status: 'approved', + })}\n`, + 'utf8', + ); + + const record = await readWorksSubmissionBinding(project.path); + + expect(record).toMatchObject({ + schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION, + status: 'submitted', + app_id: 'planet-game', + version_id: 'version-6', + version_name: 'v0.6.0', + review_status: 'approved', + }); + }); +});