diff --git a/README.md b/README.md index 3ea2a7d..4c5b56f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ EasyAI 团队的 Codex 插件市场。目前包含: -- `easyai-product-intake`(界面名称:**EasyAI 提需求/报 Bug**):引导产品经理补齐需求或 Bug 信息,在明确确认后提交到飞书多维表格。 +- `easyai-product-intake`(界面名称:**EasyAI 提需求/报 Bug**):提取负责人要解决的问题与期望结果,在明确确认后提交到飞书多维表格。 仓库公开可读,产品经理无需 Gitea 账号即可查看或下载。提交飞书任务仍然必须使用本人已授权的飞书身份;公开仓库不会授予任何飞书数据访问权限。 @@ -84,10 +84,11 @@ lark-cli config init --new 插件会: 1. 判断是需求还是 Bug。 -2. 每轮最多追问 3 个会影响执行或验收的问题。 -3. 展示结构化预览。 +2. 只有产品行为存在实质歧义时才追问,每轮最多提出 1 个阻塞问题,并给出推荐选项与影响。 +3. 展示包含全部待提交字段的结构化预览。 4. 等待你明确回复“确认提交”。 5. 使用你的飞书身份创建记录,并返回记录链接和提交幂等键。 +6. 由当前自动化服务判断直接开始、继续澄清或转人工,并通过飞书卡片通知。 没有明确确认时,插件不会写入飞书。任务状态、执行阶段、代码分支、PR、部署和审计字段由中央流水线维护,产品经理无需编辑。 diff --git a/plugins/easyai-product-intake/.codex-plugin/plugin.json b/plugins/easyai-product-intake/.codex-plugin/plugin.json index 1a041fa..0deca4c 100644 --- a/plugins/easyai-product-intake/.codex-plugin/plugin.json +++ b/plugins/easyai-product-intake/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "easyai-product-intake", - "version": "0.1.0+codex.20260811043301", - "description": "\u5e2e\u52a9 EasyAI \u4ea7\u54c1\u7ecf\u7406\u4ee5\u7edf\u4e00\u683c\u5f0f\u63d0\u4ea4\u9700\u6c42\u548c Bug \u5230\u98de\u4e66\u591a\u7ef4\u8868\u683c\u3002", + "version": "0.1.0+codex.20260827040247", + "description": "\u63d0\u53d6 EasyAI \u8d1f\u8d23\u4eba\u7684\u539f\u59cb\u610f\u56fe\u4e0e\u671f\u671b\u7ed3\u679c\uff0c\u5b8c\u6574\u9884\u89c8\u5e76\u660e\u786e\u786e\u8ba4\u540e\u5b89\u5168\u63d0\u4ea4\u9700\u6c42\u6216 Bug \u5230\u98de\u4e66\u591a\u7ef4\u8868\u683c\u3002", "author": { "name": "EasyAI" }, @@ -11,7 +11,7 @@ "interface": { "displayName": "EasyAI \u63d0\u9700\u6c42/\u62a5 Bug", "shortDescription": "\u89c4\u8303\u6536\u96c6\u4ea7\u54c1\u9700\u6c42\u4e0e Bug \u5e76\u63d0\u4ea4\u5230\u98de\u4e66", - "longDescription": "\u5f15\u5bfc\u4ea7\u54c1\u8865\u9f50\u5fc5\u586b\u4fe1\u606f\uff0c\u9884\u89c8\u786e\u8ba4\u540e\u4ec5\u5199\u5165\u98de\u4e66\u591a\u7ef4\u8868\u683c\u7684\u4ea7\u54c1\u5b57\u6bb5\u3002", + "longDescription": "\u53ea\u6536\u96c6\u7c7b\u578b\u3001\u6807\u9898\u3001\u8981\u89e3\u51b3\u7684\u95ee\u9898\u4e0e\u671f\u671b\u7ed3\u679c\u7b49\u8d1f\u8d23\u4eba\u610f\u56fe\uff0c\u6280\u672f\u8303\u56f4\u4e0e\u9a8c\u6536\u8bbe\u8ba1\u7531\u540e\u7eed Codex \u8c03\u7814\u3002\u5b8c\u6574\u9884\u89c8\u5e76\u660e\u786e\u786e\u8ba4\u540e\u624d\u5199\u5165\u98de\u4e66\uff0c\u5e76\u9632\u6b62\u9644\u4ef6\u53d8\u66f4\u3001\u5e76\u53d1\u6216\u7ed3\u679c\u672a\u77e5\u5bfc\u81f4\u91cd\u590d\u63d0\u4ea4\u3002", "developerName": "EasyAI", "category": "Productivity", "capabilities": [ diff --git a/plugins/easyai-product-intake/skills/easyai-submit-task/SKILL.md b/plugins/easyai-product-intake/skills/easyai-submit-task/SKILL.md index ae62221..baf6ec3 100644 --- a/plugins/easyai-product-intake/skills/easyai-submit-task/SKILL.md +++ b/plugins/easyai-product-intake/skills/easyai-submit-task/SKILL.md @@ -1,39 +1,43 @@ --- name: easyai-submit-task -description: 将 EasyAI 产品需求或 Bug 规范登记到指定飞书多维表格。当用户说“提需求”“新建需求”“报 Bug”“反馈问题”“登记产品任务”或要把产品想法提交给 Codex 处理时使用。 +description: 将 EasyAI 产品需求或 Bug 规范登记到指定飞书多维表格。仅在用户明确要求“提需求”“新建需求”“报 Bug”“反馈问题”“登记/提交产品任务”时使用;单纯讨论、分析、润色产品想法或询问流程时不触发提交工作流。 --- # EasyAI 提需求/报 Bug ## 执行流程 -1. 读取 [intake-rules.md](references/intake-rules.md) 判断类型和必填信息。 -2. 检查用户已给出的信息。每轮最多追问 3 个对验收有实质影响的问题,不询问仓库、分支或技术方案。 +1. 读取 [intake-rules.md](references/intake-rules.md) 判断是否已有类型、标题、要解决的问题/目标和期望结果。 +2. 只在产品行为存在实质歧义、无法形成这四项最小意图时追问。每轮最多问一个最关键的阻塞问题,并给出 2~3 个产品选项、推荐方案和各自影响。范围、仓库、分支、框架、实现与测试方式由后续 Codex 调研,不向负责人追问。 3. 按 [base-schema.md](references/base-schema.md) 构造 JSON,通过 stdin 传给 `scripts/prepare-intake.mjs`。 -4. 展示脚本返回的中文预览。必须等待用户明确说“确认提交”或等价表达。 -5. 确认后,将同一 JSON 通过 stdin 传给 `scripts/submit-intake.mjs --confirm `。 -6. 返回提交回执、幂等键和处理状态。任务编号由中央调度器在下一次扫描时生成并通过飞书通知。 +4. 展示脚本返回的完整中文预览和提交幂等键。必须等待用户明确说“确认提交”或等价表达。 +5. 若用户在确认前修改任何字段,重新预览并使用新的 `confirmation_token`;旧确认令牌失效。 +6. 确认后运行 `scripts/submit-intake.mjs --confirm `;脚本读取已保存且绑定负责人身份的草稿,不接受模型在提交时悄悄改写内容。 +7. 返回提交回执、幂等键和处理状态。若返回 `unknown`,先按幂等键核对 Base;确认不存在后,只有在负责人再次明确同意时才加 `--retry-unknown` 重试。 ## 命令 -```bash -printf '%s' '' | node scripts/prepare-intake.mjs -printf '%s' '' | node scripts/submit-intake.mjs --confirm '' +从本 `SKILL.md` 所在目录解析脚本绝对路径,但保持进程工作目录为用户当前工作目录,以便附件相对路径正确解析。优先启动 Node 进程后通过执行工具的 stdin 通道传入原始 JSON;不要把用户文本插值进 shell 命令。 + +```text +node /scripts/prepare-intake.mjs +node /scripts/submit-intake.mjs --confirm ``` `submit-intake.mjs` 使用当前电脑已授权的飞书用户身份提交企业内受控表单,不要要求用户提供 token、Cookie 或密码。 ## 安全边界 -- 只能通过受控表单写入 [base-schema.md](references/base-schema.md) 列出的产品字段。 +- 产品字段只能通过受控表单写入。表单成功后,脚本只按返回的 `record_id` 补写附件内容已纳入的 `原始意图指纹`,不修改任何产品文本。 - 禁止写入任务编号、任务状态、执行阶段、状态版本、Thread、PR、部署、验收、锁、重试和审计字段。 - 不经用户确认不得运行提交脚本。 +- 范围和验收提示都是可选信息;不得因为缺少技术范围、仓库、风险分析或完整验收设计而阻止登记。 - 附件必须是当前工作目录内的相对路径;不得读取或上传其他文件。 -- 提交失败时保留原输入并报告结构化错误,不得改写产品意图后自动重试。 +- 提交失败时保留原输入并报告结构化错误,不得改写产品意图或盲目重试。附件内容变化会使旧确认令牌失效。 ## 返回规则 -- 信息不完整:只返回下一组最多 3 个问题。 -- 待确认:展示类型、标题、背景、验收标准、优先级和附件。 -- 提交成功:返回提交回执并明确说明“已登记,等待系统分配任务编号”。 +- 信息不完整:只返回一个最关键的产品问题,包含 2~3 个选项、推荐方案和影响。 +- 待确认:展示脚本 `preview` 返回的全部字段、提交幂等键,并提示“回复确认提交后才会写入飞书”。 +- 提交成功:返回提交回执并明确说明“已登记,等待系统分配任务编号”;提醒用户留意后续自动开始、澄清或转人工卡片。 - 飞书授权不足:原样返回缺失 scope 及授权提示,不改用 bot 身份。 diff --git a/plugins/easyai-product-intake/skills/easyai-submit-task/agents/openai.yaml b/plugins/easyai-product-intake/skills/easyai-submit-task/agents/openai.yaml index a412b54..790df07 100644 --- a/plugins/easyai-product-intake/skills/easyai-submit-task/agents/openai.yaml +++ b/plugins/easyai-product-intake/skills/easyai-submit-task/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "EasyAI 提需求/报 Bug" - short_description: "规范收集产品需求和 Bug 并安全提交到飞书多维表格" - default_prompt: "Use $easyai-submit-task to submit a product requirement or bug to the EasyAI Feishu Base." + short_description: "提取负责人原始意图,确认后安全提交需求或 Bug" + default_prompt: "使用 $easyai-submit-task 提取我的目标和期望结果,完整预览并在我确认后提交一条 EasyAI 产品需求或 Bug。" diff --git a/plugins/easyai-product-intake/skills/easyai-submit-task/references/base-schema.md b/plugins/easyai-product-intake/skills/easyai-submit-task/references/base-schema.md index fd92a35..0b66b20 100644 --- a/plugins/easyai-product-intake/skills/easyai-submit-task/references/base-schema.md +++ b/plugins/easyai-product-intake/skills/easyai-submit-task/references/base-schema.md @@ -1,28 +1,27 @@ -# 飞书 Base 字段契约 +# 飞书 Base V2 提单契约 -- Base Token: `Ed21b9VwNaZfiesxWgDc67wXnoc` -- 数据表: `tblelMMlOif1YHPD`(需求与缺陷) -- 受控表单: `产品需求与 Bug 提交` -- 表单提交范围: 仅企业内获得链接的用户 +目标 Base 与表单由组织配置固定;脚本每次提交前读取真实表单详情,校验字段类型、必填配置和选项,不在 Skill 文档中复制一套可漂移的字段定义。 -允许提交的字段: +允许提交的 JSON: -| JSON 属性 | Base 字段 | 值 | -|---|---|---| -| `type` | 类型 | `需求` / `Bug` | -| `title` | 标题 | 非空文本 | -| `background` | 需求背景与目标 | 非空文本 | -| `acceptance_criteria` | 验收标准 | 非空字符串数组 | -| `priority` | 优先级 | `P0` / `P1` / `P2` / `P3` | -| `environment` | 发生环境 | Bug 必填 | -| `reproduction_steps` | 复现步骤 | Bug 必填字符串数组 | -| `actual_result` | 实际结果 | Bug 必填 | -| `expected_result` | 期望结果 | Bug 必填 | -| `impact_scope` | 影响范围 | `前端` / `后端` / `全栈` / `部署` 数组,可选 | +| JSON 属性 | Base 字段 | 要求 | +| --- | --- | --- | +| `type` | 类型 | 必填;`需求` / `Bug` | +| `title` | 标题 | 必填非空文本 | +| `background` / `problem_or_goal` | 需求背景与目标 | 必填非空文本 | +| `expected_result` / `expected_outcome` | 期望结果 | 必填非空文本 | +| `acceptance_criteria` | 验收标准 | 可选字符串数组,仅作为验收提示 | +| `priority` | 优先级 | 可选;`P0` / `P1` / `P2` / `P3`,默认 `P2` | +| `environment` | 发生环境 | Bug 可选 | +| `reproduction_steps` | 复现步骤 | Bug 可选字符串数组 | +| `actual_result` | 实际结果 | Bug 可选 | +| `impact_scope` | 影响范围 | 可选提示数组;`前端` / `后端` / `全栈` / `部署` | +| `constraints` | 约束与非目标 | 可选字符串数组,以“约束:”前缀保存 | +| `non_goals` | 约束与非目标 | 可选字符串数组,以“非目标:”前缀保存 | | `desired_at` | 期望完成时间 | `YYYY-MM-DD HH:mm:ss`,可选 | | `references` | 参考资料 | 可选文本 | | `attachments` | 附件 | 当前目录内相对路径数组 | -脚本通过受控表单自动写入但不接受模型指定:`产品负责人`、`提交幂等键`、`提交来源`。 +脚本自动注入且不接受模型指定:`产品负责人`、`提交幂等键`、`提交来源`。附件路径、大小与 SHA256 一起进入确认摘要;表单成功后同一内容摘要会按返回的记录编号固化到 `原始意图指纹`。该补写失败时回执进入 `unknown`,下次先按幂等键找到已有记录并完成指纹固化,不会重复提交表单。 -任务编号由中央调度器生成;其他系统字段不在受控表单中,全部禁止写入。 +受飞书现有表单主字段约束,`任务编号(系统自动生成,请勿填写)` 仍可能作为可选问题存在。V2 插件不得展示或提交它,任务编号由中央调度器生成;任务状态、执行阶段、状态版本、Thread、PR、部署、验收、锁、重试和审计字段都不属于提单插件。 diff --git a/plugins/easyai-product-intake/skills/easyai-submit-task/references/intake-rules.md b/plugins/easyai-product-intake/skills/easyai-submit-task/references/intake-rules.md index 2ba90fa..a5af09d 100644 --- a/plugins/easyai-product-intake/skills/easyai-submit-task/references/intake-rules.md +++ b/plugins/easyai-product-intake/skills/easyai-submit-task/references/intake-rules.md @@ -1,23 +1,40 @@ # 需求与 Bug 收集规则 -## 共同必填 +## 最小登记契约 -- 类型、标题、背景与目标、可执行的验收标准、优先级。 -- 验收标准要描述可观察结果,避免“优化一下”“体验更好”等无法判定的表达。 +- 必填:类型、标题、背景与目标、期望结果。 +- 优先级默认 `P2`,只有负责人明确表达紧急程度时才调整。 +- 验收提示、范围提示、约束、非目标、期望时间、参考资料和附件都是可选信息。 +- 范围提示可以是 `前端`、`后端`、`全栈`、`部署`;只填 `部署` 也允许登记。`全栈` 不与 `前端` 或 `后端` 同时选择。 - 产品负责人由当前飞书授权身份自动填入,不询问 open_id。 -## Bug 额外必填 +## 优先级 -- 发生环境、复现步骤、实际结果、期望结果。 -- 有截图、视频或日志时作为附件;无法提供时明确记录“暂无证据”,不伪造证据。 +- `P0`:正在发生的严重故障、安全事件或数据损失;不等同于自动紧急发布,服务仍会要求授权人员确认原因。 +- `P1`:阻塞关键业务或近期交付,需优先处理。 +- `P2`:正常排期的需求或缺陷,默认选择。 +- `P3`:低影响改进、体验优化或技术债。 -## 追问优先级 +## Bug 可选补充 -1. 先问会影响是否可执行和可验收的信息。 -2. 再问用户范围、兼容性或时间限制。 -3. 不要让产品选择仓库、技术框架、实现类或数据库结构。 +- 发生环境、复现步骤、实际结果。 +- 有截图、视频或日志时作为附件;无法提供时不伪造证据。 +- 缺少这些信息仍可登记。后续 Codex 先在代码、测试和环境中定位;只有产品现象或期望行为无法确定时才询问负责人。 -## 首个真实 Canary 条件 +## 追问协议 + +1. 只问会改变产品行为、目标或期望结果且确实阻塞登记的信息。 +2. 每轮最多一个问题,提供 2~3 个选项、推荐项和选择影响。 +3. 不问仓库、前后端归属、技术框架、实现类、数据库结构或测试方式;这些由 Codex 调研。 + +## 服务分流提示 + +- 最小契约成立:服务登记后由 Codex 调研代码并形成派生验收计划。 +- 产品行为存在歧义:服务通过带问题版本、状态版本和意图版本校验的飞书卡片继续澄清。 +- 只有技术信息不确定:Codex 自行搜索代码、测试和环境,不询问负责人。 +- 涉及鉴权/权限、支付、数据库迁移、生产基础设施、密钥或破坏性变更:如实记录负责人目标,不替负责人缩小或批准范围;后续服务按风险策略处理。 + +## 首个真实 Canary 条件(仅当用户明确说是 Canary 时应用) - 范围清晰、一天内可完成、验收结果确定、可回滚。 - 不涉及支付、权限、数据迁移、数据库结构或破坏性操作。 diff --git a/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/intake-lib.mjs b/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/intake-lib.mjs index f15f9e1..ee8e4df 100644 --- a/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/intake-lib.mjs +++ b/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/intake-lib.mjs @@ -1,108 +1,203 @@ -import { createHash } from 'node:crypto'; -import { readFile } from 'node:fs/promises'; +import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; +import { mkdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; const allowedKeys = new Set([ - 'type', - 'title', - 'background', - 'acceptance_criteria', - 'priority', - 'environment', - 'reproduction_steps', - 'actual_result', - 'expected_result', - 'impact_scope', - 'desired_at', - 'references', - 'attachments', + "type", + "title", + "background", + "problem_or_goal", + "acceptance_criteria", + "priority", + "environment", + "reproduction_steps", + "actual_result", + "expected_result", + "expected_outcome", + "impact_scope", + "constraints", + "non_goals", + "desired_at", + "references", + "attachments", ]); export async function readStdin() { - let input = ''; - process.stdin.setEncoding('utf8'); + let input = ""; + process.stdin.setEncoding("utf8"); for await (const chunk of process.stdin) input += chunk; - if (!input.trim()) throw new Error('请通过 stdin 传入 JSON'); + if (!input.trim()) throw new Error("请通过 stdin 传入 JSON"); return JSON.parse(input); } +/** Normalize the public V2 draft contract without asking for technical design. */ export function normalize(input) { - if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('输入必须是 JSON 对象'); + if (!input || typeof input !== "object" || Array.isArray(input)) + throw new Error("输入必须是 JSON 对象"); const unknown = Object.keys(input).filter((key) => !allowedKeys.has(key)); - if (unknown.length) throw new Error(`存在禁止或未知字段: ${unknown.join(', ')}`); + if (unknown.length) + throw new Error(`存在禁止或未知字段: ${unknown.join(", ")}`); + + const background = input.problem_or_goal ?? input.background; + const expectedResult = input.expected_outcome ?? input.expected_result; const value = { - type: requiredEnum(input.type, '类型', ['需求', 'Bug']), - title: requiredText(input.title, '标题'), - background: requiredText(input.background, '背景与目标'), - acceptance_criteria: requiredList(input.acceptance_criteria, '验收标准'), - priority: requiredEnum(input.priority, '优先级', ['P0', 'P1', 'P2', 'P3']), - impact_scope: optionalEnumList(input.impact_scope, '影响范围', ['前端', '后端', '全栈', '部署']), - attachments: optionalList(input.attachments, '附件'), + type: requiredEnum(input.type, "类型", ["需求", "Bug"]), + title: requiredText(input.title, "标题"), + background: requiredText(background, "背景与目标"), + expected_result: requiredText(expectedResult, "期望结果"), + priority: + input.priority === undefined + ? "P2" + : requiredEnum(input.priority, "优先级", ["P0", "P1", "P2", "P3"]), + acceptance_criteria: optionalList(input.acceptance_criteria, "验收提示"), + impact_scope: optionalScopeList(input.impact_scope), + constraints: optionalList(input.constraints, "约束"), + non_goals: optionalList(input.non_goals, "非目标"), + attachments: attachmentList(input.attachments), }; + for (const [key, label] of [ - ['environment', '发生环境'], - ['actual_result', '实际结果'], - ['expected_result', '期望结果'], - ['desired_at', '期望完成时间'], - ['references', '参考资料'], + ["environment", "发生环境"], + ["actual_result", "实际结果"], + ["references", "参考资料"], ]) { if (input[key] !== undefined) value[key] = requiredText(input[key], label); } + if (input.desired_at !== undefined) + value.desired_at = normalizeDesiredAt(input.desired_at); if (input.reproduction_steps !== undefined) - value.reproduction_steps = requiredList(input.reproduction_steps, '复现步骤'); - if (value.type === 'Bug') { - for (const [key, label] of [ - ['environment', '发生环境'], - ['actual_result', '实际结果'], - ['expected_result', '期望结果'], - ]) - if (!value[key]) throw new Error(`Bug 缺少${label}`); - if (!value.reproduction_steps?.length) throw new Error('Bug 缺少复现步骤'); - } + value.reproduction_steps = optionalList(input.reproduction_steps, "复现步骤"); return value; } export function canonical(value) { - if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; - if (value && typeof value === 'object') + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + if (value && typeof value === "object") return `{${Object.keys(value) .sort() .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`) - .join(',')}}`; + .join(",")}}`; return JSON.stringify(value); } -export function tokens(value) { - const digest = createHash('sha256').update(canonical(value)).digest('hex'); - return { confirmation_token: digest, submission_key: `intake-${digest.slice(0, 24)}` }; +export function sha256(value) { + return createHash("sha256").update(value).digest("hex"); } -export function preview(value) { +export function createDraftIdentity(ownerOpenId) { + const draftId = randomUUID(); + const secret = randomBytes(32).toString("base64url"); return { - '类型': value.type, - '标题': value.title, - '背景与目标': value.background, - '验收标准': value.acceptance_criteria, - '优先级': value.priority, - ...(value.environment ? { '发生环境': value.environment } : {}), - ...(value.reproduction_steps ? { '复现步骤': value.reproduction_steps } : {}), - ...(value.actual_result ? { '实际结果': value.actual_result } : {}), - ...(value.expected_result ? { '期望结果': value.expected_result } : {}), - ...(value.impact_scope.length ? { '影响范围': value.impact_scope } : {}), - ...(value.attachments.length ? { '附件': value.attachments } : {}), + draft_id: draftId, + confirmation_token: `${draftId}.${secret}`, + confirmation_token_hash: sha256(secret), + submission_key: `intake-v2-${sha256(`${draftId}:${ownerOpenId}`).slice(0, 24)}`, }; } +export function parseConfirmationToken(token) { + if (typeof token !== "string") + throw new Error("确认令牌缺失或已过期,请重新预览并确认"); + const separator = token.indexOf("."); + const draftId = token.slice(0, separator); + const secret = token.slice(separator + 1); + if ( + separator < 1 || + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + draftId, + ) || + !secret + ) + throw new Error("确认令牌缺失或已过期,请重新预览并确认"); + return { draftId, secret }; +} + +export function verifySecret(secret, expectedHash) { + const actual = Buffer.from(sha256(secret), "hex"); + const expected = Buffer.from(String(expectedHash ?? ""), "hex"); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + +export function contentDigest(value, ownerOpenId, attachments) { + return sha256(canonical({ value, ownerOpenId, attachments })); +} + +export function preview(value, attachmentManifest = []) { + return { + 类型: value.type, + 标题: value.title, + 背景与目标: value.background, + 期望结果: value.expected_result, + 优先级: value.priority, + ...(value.acceptance_criteria.length + ? { 可选验收提示: value.acceptance_criteria } + : {}), + ...(value.environment ? { 发生环境: value.environment } : {}), + ...(value.reproduction_steps?.length + ? { 复现步骤: value.reproduction_steps } + : {}), + ...(value.actual_result ? { 实际结果: value.actual_result } : {}), + ...(value.impact_scope.length ? { 范围提示: value.impact_scope } : {}), + ...(value.constraints.length ? { 约束: value.constraints } : {}), + ...(value.non_goals.length ? { 非目标: value.non_goals } : {}), + ...(value.desired_at ? { 期望完成时间: value.desired_at } : {}), + ...(value.references ? { 参考资料: value.references } : {}), + ...(attachmentManifest.length + ? { + 附件: attachmentManifest.map(({ path, size, sha256: digest }) => ({ + path, + size, + sha256: digest, + })), + } + : {}), + }; +} + +export async function attachmentManifest(paths, cwd = process.cwd()) { + const root = await realpath(cwd); + const manifest = []; + for (const path of paths) { + const target = await realpath(resolve(root, path)); + const relativePath = relative(root, target); + if (relativePath.startsWith("..") || isAbsolute(relativePath)) + throw new Error(`附件超出当前目录: ${path}`); + const metadata = await stat(target); + if (!metadata.isFile()) throw new Error(`附件不是普通文件: ${path}`); + manifest.push({ + path, + size: metadata.size, + sha256: createHash("sha256").update(await readFile(target)).digest("hex"), + }); + } + return manifest; +} + +export async function assertSafeAttachmentPaths(paths, cwd = process.cwd()) { + await attachmentManifest(paths, cwd); +} + export async function readJson(path, fallback) { try { - return JSON.parse(await readFile(path, 'utf8')); + return JSON.parse(await readFile(path, "utf8")); } catch (error) { - if (error?.code === 'ENOENT') return fallback; + if (error?.code === "ENOENT") return fallback; throw error; } } +export async function writeJsonAtomic(path, value) { + const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`; + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { + mode: 0o600, + }); + await rename(temporary, path); +} + function requiredText(value, label) { - if (typeof value !== 'string' || !value.trim()) throw new Error(`${label}缺失`); + if (typeof value !== "string" || !value.trim()) + throw new Error(`${label}缺失`); return value.trim(); } function requiredList(value, label) { @@ -114,14 +209,50 @@ function optionalList(value, label) { ? [] : requiredList(value, label); } +function attachmentList(value) { + const list = optionalList(value, "附件"); + for (const path of list) + if (isAbsolute(path) || path.split(/[\\/]/).includes("..")) + throw new Error(`不安全的附件路径: ${path}`); + return list; +} function requiredEnum(value, label, options) { - if (!options.includes(value)) throw new Error(`${label}必须是: ${options.join(' / ')}`); + if (!options.includes(value)) + throw new Error(`${label}必须是: ${options.join(" / ")}`); return value; } function optionalEnumList(value, label, options) { - const list = value === undefined || (Array.isArray(value) && value.length === 0) - ? [] - : requiredList(value, label); + const list = optionalList(value, label); for (const item of list) requiredEnum(item, label, options); return [...new Set(list)]; } +function optionalScopeList(value) { + const list = optionalEnumList(value, "范围提示", [ + "前端", + "后端", + "全栈", + "部署", + ]); + if (list.includes("全栈") && (list.includes("前端") || list.includes("后端"))) + throw new Error("范围提示选择全栈后不能再同时选择前端或后端"); + return list; +} +function normalizeDesiredAt(value) { + const text = requiredText(value, "期望完成时间"); + const match = + /^(\d{4})-(\d{2})-(\d{2})(?: (\d{2}):(\d{2}):(\d{2}))?$/.exec(text); + if (!match) + throw new Error("期望完成时间必须是 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss"); + const [, year, month, day, hour = "00", minute = "00", second = "00"] = match; + const [y, m, d, h, min, s] = [year, month, day, hour, minute, second].map(Number); + const validDate = + m >= 1 && + m <= 12 && + d >= 1 && + d <= new Date(Date.UTC(y, m, 0)).getUTCDate() && + h <= 23 && + min <= 59 && + s <= 59; + if (!validDate) throw new Error("期望完成时间不是有效日期时间"); + return `${year}-${month}-${day} ${hour}:${minute}:${second}`; +} diff --git a/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/prepare-intake.mjs b/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/prepare-intake.mjs index 4f8f481..350470b 100644 --- a/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/prepare-intake.mjs +++ b/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/prepare-intake.mjs @@ -1,10 +1,114 @@ #!/usr/bin/env node -import { normalize, preview, readStdin, tokens } from './intake-lib.mjs'; +import { execFileSync } from "node:child_process"; +import { homedir } from "node:os"; +import { resolve } from "node:path"; +import { + attachmentManifest, + contentDigest, + createDraftIdentity, + normalize, + preview, + readStdin, + writeJsonAtomic, +} from "./intake-lib.mjs"; + +const intakeHome = resolve( + process.env.EASYAI_INTAKE_HOME ?? homedir(), + ".easyai-intake", +); try { const value = normalize(await readStdin()); - console.log(JSON.stringify({ ok: true, preview: preview(value), ...tokens(value) }, null, 2)); + const auth = lark(["auth", "status", "--json", "--verify"]); + const user = auth.identities?.user; + if (!user?.verified || !user.openId) + throw new Error("当前电脑未完成飞书用户授权"); + + const attachments = await attachmentManifest(value.attachments); + const identity = createDraftIdentity(user.openId); + const preparedAt = new Date().toISOString(); + const draft = { + schema_version: 2, + status_code: "prepared", + draft_id: identity.draft_id, + submission_key: identity.submission_key, + owner_open_id: user.openId, + confirmation_token_hash: identity.confirmation_token_hash, + content_digest: contentDigest(value, user.openId, attachments), + value, + attachments, + prepared_at: preparedAt, + }; + await writeJsonAtomic( + resolve(intakeHome, "drafts", `${identity.draft_id}.json`), + draft, + ); + + console.log( + JSON.stringify( + { + ok: true, + schema_version: 2, + status_code: "prepared", + draft_id: identity.draft_id, + preview: preview(value, attachments), + confirmation_token: identity.confirmation_token, + submission_key: identity.submission_key, + prepared_at: preparedAt, + }, + null, + 2, + ), + ); } catch (error) { - console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : '未知错误' })); + console.error( + JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : "未知错误", + }), + ); process.exitCode = 1; } + +function lark(args) { + try { + const output = execFileSync(process.env.EASYAI_LARK_CLI ?? "lark-cli", args, { + encoding: "utf8", + env: { + ...process.env, + LARKSUITE_CLI_NO_UPDATE_NOTIFIER: "1", + LARKSUITE_CLI_NO_SKILLS_NOTIFIER: "1", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + return unwrapLark(output); + } catch (error) { + for (const output of [error?.stdout, error?.stderr]) { + const message = larkErrorMessage(output); + if (message) throw new Error(message); + } + throw error; + } +} + +function unwrapLark(output) { + const envelope = JSON.parse(output); + if (envelope.ok === false) + throw new Error(larkErrorMessage(output) ?? "飞书 CLI 调用失败"); + return envelope.data ?? envelope; +} + +function larkErrorMessage(output) { + if (typeof output !== "string" || !output.trim()) return undefined; + try { + const envelope = JSON.parse(output); + if (envelope.ok !== false) return undefined; + return ( + [envelope.error?.message, envelope.error?.hint] + .filter(Boolean) + .join(": ") || undefined + ); + } catch { + return undefined; + } +} diff --git a/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/submit-intake.mjs b/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/submit-intake.mjs index 30ff2cf..9a1f57e 100644 --- a/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/submit-intake.mjs +++ b/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/submit-intake.mjs @@ -1,124 +1,452 @@ #!/usr/bin/env node -import { execFileSync } from 'node:child_process'; -import { mkdir, realpath, rename, writeFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { dirname, isAbsolute, resolve } from 'node:path'; -import { normalize, readJson, readStdin, tokens } from './intake-lib.mjs'; +import { execFileSync } from "node:child_process"; +import { mkdir, rmdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import { resolve } from "node:path"; +import { + attachmentManifest, + canonical, + contentDigest, + parseConfirmationToken, + readJson, + verifySecret, + writeJsonAtomic, +} from "./intake-lib.mjs"; -const baseToken = 'Ed21b9VwNaZfiesxWgDc67wXnoc'; -const formShareToken = 'shrcnT6yRL9olcpFkppFkUcKe5e'; -const formUrl = 'https://karni9c557h.feishu.cn/share/base/shrcnT6yRL9olcpFkppFkUcKe5e'; -const receiptPath = resolve(homedir(), '.easyai-intake', 'receipts.json'); +const baseToken = "Ed21b9VwNaZfiesxWgDc67wXnoc"; +const taskTableId = "tblelMMlOif1YHPD"; +const formShareToken = "shrcnT6yRL9olcpFkppFkUcKe5e"; +const formUrl = + "https://karni9c557h.feishu.cn/share/base/shrcnT6yRL9olcpFkppFkUcKe5e"; +const intakeHome = resolve( + process.env.EASYAI_INTAKE_HOME ?? homedir(), + ".easyai-intake", +); + +let lockPath; +let receiptPath; +let receipt; +let submissionStarted = false; + +class Completed extends Error { + constructor(output) { + super("completed"); + this.output = output; + } +} try { - const value = normalize(await readStdin()); - const identity = tokens(value); - const confirmation = argument('--confirm'); - const dryRun = process.argv.includes('--dry-run'); - if (!confirmation || confirmation !== identity.confirmation_token) - throw new Error('确认令牌缺失或已过期,请重新预览并确认'); + const confirmation = argument("--confirm"); + const { draftId, secret } = parseConfirmationToken(confirmation); + const dryRun = process.argv.includes("--dry-run"); + const retryUnknown = process.argv.includes("--retry-unknown"); + const draftPath = resolve(intakeHome, "drafts", `${draftId}.json`); + const draft = await readJson(draftPath); + if (!draft || draft.schema_version !== 2 || draft.status_code !== "prepared") + throw new Error("确认草稿不存在或已过期,请重新预览"); + if (!verifySecret(secret, draft.confirmation_token_hash)) + throw new Error("确认令牌缺失或已过期,请重新预览并确认"); - const receipts = await readJson(receiptPath, {}); - if (!dryRun && receipts[identity.submission_key]) { - const existing = receipts[identity.submission_key]; - if (existing.status_code === 'submitting') - throw new Error(`提交 ${identity.submission_key} 的结果待中央调度器核对,请勿重复提交`); - console.log(JSON.stringify({ ok: true, duplicate: true, ...existing }, null, 2)); - process.exit(0); + const auth = lark(["auth", "status", "--json", "--verify"]); + const user = auth.identities?.user; + if (!user?.verified || !user.openId) + throw new Error("当前电脑未完成飞书用户授权"); + if (user.openId !== draft.owner_open_id) + throw new Error("当前飞书身份与预览负责人不一致,请重新预览"); + + const attachments = await attachmentManifest(draft.value.attachments); + const currentDigest = contentDigest(draft.value, user.openId, attachments); + if (currentDigest !== draft.content_digest) + throw new Error("内容或附件已改变,旧确认令牌失效,请重新预览"); + + lockPath = resolve(intakeHome, "locks", draftId); + if (!dryRun) await acquireLock(lockPath); + receiptPath = resolve(intakeHome, "receipts", `${draft.submission_key}.json`); + receipt = await readJson(receiptPath, { + schema_version: 2, + status_code: "prepared", + draft_id: draftId, + submission_key: draft.submission_key, + owner_open_id: user.openId, + prepared_at: draft.prepared_at, + }); + + if (receipt.status_code === "submitted") { + throw new Completed({ ok: true, duplicate: true, ...receipt }); } - const auth = lark(['auth', 'status', '--json', '--verify']); - const user = auth.identities?.user; - if (!user?.verified || !user.openId) throw new Error('当前电脑未完成飞书用户授权'); - const form = lark(['base', '+form-detail', '--share-token', formShareToken, '--as', 'user']); - if (form.base_token !== baseToken) throw new Error('飞书表单指向了非预期 Base'); - const fields = new Map((form.questions ?? []).map((field) => [field.title, field])); - for (const name of ['类型', '标题', '需求背景与目标', '验收标准', '优先级', '产品负责人', '提交幂等键', '提交来源']) - if (!fields.has(name)) throw new Error(`受控表单缺少必需字段: ${name}`); + const existing = findExistingSubmission(draft.submission_key); + if (existing) { + finalizeOwnerIntent(draft, existing); + receipt = submittedReceipt(draft, existing); + if (!dryRun) await writeJsonAtomic(receiptPath, receipt); + throw new Completed({ ok: true, duplicate: true, ...receipt }); + } + if (["submitting", "unknown"].includes(receipt.status_code) && !retryUnknown) + throw new Error( + `提交 ${draft.submission_key} 的结果尚未确认;已查询 Base 未找到记录。请核对后使用 --retry-unknown 明确重试,不能盲目重提`, + ); + + const form = lark([ + "base", + "+form-detail", + "--share-token", + formShareToken, + "--as", + "user", + ]); + if (form.base_token !== baseToken) + throw new Error("飞书表单指向了非预期 Base"); + const fields = new Map( + (form.questions ?? []).map((field) => [field.title, field]), + ); + + const value = draft.value; const payload = { - '类型': value.type, - '标题': value.title, - '需求背景与目标': value.background, - '验收标准': value.acceptance_criteria.map((item, index) => `${index + 1}. ${item}`).join('\n'), - '优先级': value.priority, - '产品负责人': [{ id: user.openId }], - '提交幂等键': identity.submission_key, - '提交来源': 'Codex Skill', - ...(value.environment ? { '发生环境': value.environment } : {}), - ...(value.reproduction_steps ? { '复现步骤': value.reproduction_steps.map((item, index) => `${index + 1}. ${item}`).join('\n') } : {}), - ...(value.actual_result ? { '实际结果': value.actual_result } : {}), - ...(value.expected_result ? { '期望结果': value.expected_result } : {}), - ...(value.impact_scope.length ? { '影响范围': value.impact_scope } : {}), - ...(value.desired_at ? { '期望完成时间': value.desired_at } : {}), - ...(value.references ? { '参考资料': value.references } : {}), + 类型: value.type, + 标题: value.title, + 需求背景与目标: value.background, + 期望结果: value.expected_result, + 优先级: value.priority, + 产品负责人: [{ id: user.openId }], + 提交幂等键: draft.submission_key, + 提交来源: "Codex Skill", + ...(value.acceptance_criteria.length + ? { + 验收标准: value.acceptance_criteria + .map((item, index) => `${index + 1}. ${item}`) + .join("\n"), + } + : {}), + ...(value.environment ? { 发生环境: value.environment } : {}), + ...(value.reproduction_steps?.length + ? { + 复现步骤: value.reproduction_steps + .map((item, index) => `${index + 1}. ${item}`) + .join("\n"), + } + : {}), + ...(value.actual_result ? { 实际结果: value.actual_result } : {}), + ...(value.impact_scope.length ? { 影响范围: value.impact_scope } : {}), + ...(value.constraints.length || value.non_goals.length + ? { + 约束与非目标: [ + ...value.constraints.map((item) => `约束:${item}`), + ...value.non_goals.map((item) => `非目标:${item}`), + ].join("\n"), + } + : {}), + ...(value.desired_at ? { 期望完成时间: value.desired_at } : {}), + ...(value.references ? { 参考资料: value.references } : {}), }; - for (const attachment of value.attachments) await assertSafePath(attachment); + validateLiveForm(fields, payload, value); + const submission = { fields: payload, - ...(value.attachments.length ? { attachments: { '附件': value.attachments } } : {}), + ...(value.attachments.length + ? { attachments: { 附件: value.attachments } } + : {}), }; + if (!dryRun) { - receipts[identity.submission_key] = { + submissionStarted = true; + receipt = { + ...receipt, + status_code: "submitting", submission_url: formUrl, - submission_key: identity.submission_key, - status_code: 'submitting', - status: '正在提交;若进程中断,请等待中央调度器核对,勿重复提交', + content_digest: draft.content_digest, + submitting_at: new Date().toISOString(), + status: "正在提交;进程中断时先按幂等键核对 Base", }; - await writeReceipts(receipts); + await writeJsonAtomic(receiptPath, receipt); + } + + let submitted; + try { + submitted = lark([ + "base", + "+form-submit", + "--share-token", + formShareToken, + ...(value.attachments.length ? ["--base-token", baseToken] : []), + "--json", + JSON.stringify(submission), + "--as", + "user", + ...(dryRun ? ["--dry-run"] : []), + ]); + if (!dryRun) { + const created = + recordFromResult(submitted) ?? + findExistingSubmission(draft.submission_key); + if (!created) throw new Error("表单返回成功但无法按提交幂等键定位新记录"); + finalizeOwnerIntent(draft, created); + submitted = created; + } + } catch (error) { + if (!dryRun) { + receipt = { + ...receipt, + status_code: "unknown", + unknown_at: new Date().toISOString(), + status: "提交调用未获得确定结果;重试前必须按提交幂等键查询 Base", + last_error: error instanceof Error ? error.message : "未知错误", + }; + await writeJsonAtomic(receiptPath, receipt); + } + throw error; } - const submitted = lark([ - 'base', '+form-submit', '--share-token', formShareToken, - ...(value.attachments.length ? ['--base-token', baseToken] : []), - '--json', JSON.stringify(submission), '--as', 'user', - ...(dryRun ? ['--dry-run'] : []), - ]); if (dryRun) { - console.log(JSON.stringify({ ok: true, dry_run: true, submission_key: identity.submission_key, submitted }, null, 2)); - process.exit(0); + throw new Completed({ + ok: true, + dry_run: true, + draft_id: draftId, + submission_key: draft.submission_key, + submitted, + }); } - const receipt = { - submission_url: formUrl, - submission_key: identity.submission_key, - status_code: 'submitted', - status: '已登记,等待系统分配任务编号', - }; - receipts[identity.submission_key] = receipt; - await writeReceipts(receipts); - - console.log(JSON.stringify({ ok: true, duplicate: false, ...receipt }, null, 2)); + receipt = submittedReceipt(draft, submitted); + await writeJsonAtomic(receiptPath, receipt); + console.log( + JSON.stringify({ ok: true, duplicate: false, ...receipt }, null, 2), + ); } catch (error) { - console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : '未知错误' })); - process.exitCode = 1; + if (error instanceof Completed) { + console.log(JSON.stringify(error.output, null, 2)); + } else { + if ( + submissionStarted && + receiptPath && + receipt?.status_code === "submitting" + ) { + receipt = { + ...receipt, + status_code: "unknown", + unknown_at: new Date().toISOString(), + status: "提交进程中断;重试前必须按提交幂等键查询 Base", + last_error: error instanceof Error ? error.message : "未知错误", + }; + await writeJsonAtomic(receiptPath, receipt).catch(() => undefined); + } + console.error( + JSON.stringify({ + ok: false, + status_code: receipt?.status_code, + submission_key: receipt?.submission_key, + error: error instanceof Error ? error.message : "未知错误", + }), + ); + process.exitCode = 1; + } +} finally { + if (lockPath) await rmdir(lockPath).catch(() => undefined); +} + +function submittedReceipt(draft, result) { + const record = recordFromResult(result) ?? result; + return { + schema_version: 2, + submission_url: formUrl, + draft_id: draft.draft_id, + submission_key: draft.submission_key, + owner_open_id: draft.owner_open_id, + content_digest: draft.content_digest, + status_code: "submitted", + status: "已登记,等待系统分配任务编号", + submitted_at: new Date().toISOString(), + ...(record?.record_id ? { record_id: record.record_id } : {}), + }; +} + +function recordFromResult(result) { + return ( + result?.record ?? + result?.data?.[0] ?? + (result?.record_id ? result : undefined) + ); +} + +function finalizeOwnerIntent(draft, record) { + const recordId = record?.record_id; + if (!recordId) + throw new Error("已登记记录缺少 record_id,无法固化原始意图指纹"); + lark([ + "base", + "+record-upsert", + "--base-token", + baseToken, + "--table-id", + taskTableId, + "--record-id", + recordId, + "--json", + JSON.stringify({ 原始意图指纹: draft.content_digest }), + "--as", + "user", + ]); +} + +function findExistingSubmission(submissionKey) { + const result = lark([ + "base", + "+record-search", + "--base-token", + baseToken, + "--table-id", + taskTableId, + "--keyword", + submissionKey, + "--search-field", + "提交幂等键", + "--field-id", + "提交幂等键", + "--field-id", + "任务编号", + "--limit", + "2", + "--format", + "json", + "--as", + "user", + ]); + const records = Array.isArray(result?.data) ? result.data : []; + return records.find((record) => { + const fields = record.fields ?? record; + return scalarText(fields["提交幂等键"]) === submissionKey; + }); +} + +function validateLiveForm(fields, payload, value) { + const expected = { + 类型: "select", + 标题: "text", + 需求背景与目标: "text", + 期望结果: "text", + 优先级: "select", + 产品负责人: "user", + 提交幂等键: "text", + 提交来源: "select", + }; + for (const [name, type] of Object.entries(expected)) + assertField(fields, name, type); + for (const name of Object.keys(payload)) + if (!fields.has(name)) throw new Error(`受控表单缺少字段: ${name}`); + for (const field of fields.values()) + if ( + field.required && + field.title !== "任务编号(系统自动生成,请勿填写)" && + !hasPayloadValue(payload[field.title]) + ) + throw new Error(`受控表单仍要求非 V2 必填字段: ${field.title}`); + assertSelectOptions(fields, "类型", [value.type]); + assertSelectOptions(fields, "优先级", [value.priority]); + assertSelectOptions(fields, "提交来源", ["Codex Skill"]); + if (value.impact_scope.length) + assertSelectOptions(fields, "影响范围", value.impact_scope); + if (value.attachments.length) assertField(fields, "附件", "attachment"); +} + +function assertField(fields, name, type) { + const field = fields.get(name); + if (!field) throw new Error(`受控表单缺少必需字段: ${name}`); + if (field.type !== type) + throw new Error( + `受控表单字段 ${name} 类型错误: 期望 ${type},实际 ${field.type}`, + ); +} + +function hasPayloadValue(value) { + return ( + value !== undefined && + value !== null && + value !== "" && + (!Array.isArray(value) || value.length > 0) + ); +} + +function scalarText(value) { + if (typeof value === "string") return value; + if (Array.isArray(value)) + return value + .map((part) => (typeof part === "string" ? part : (part?.text ?? ""))) + .join(""); + return value?.text ?? ""; +} + +async function acquireLock(path) { + try { + await mkdir(resolve(path, ".."), { recursive: true, mode: 0o700 }); + await mkdir(path, { mode: 0o700 }); + } catch (error) { + if (error?.code === "EEXIST") + throw new Error("同一草稿正在提交,请等待当前提交完成"); + throw error; + } } function lark(args) { - const output = execFileSync('lark-cli', args, { - encoding: 'utf8', - env: { ...process.env, LARKSUITE_CLI_NO_UPDATE_NOTIFIER: '1', LARKSUITE_CLI_NO_SKILLS_NOTIFIER: '1' }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - const envelope = JSON.parse(output); - if (envelope.ok === false) throw new Error([envelope.error?.message, envelope.error?.hint].filter(Boolean).join(': ')); - return envelope.data ?? envelope; + try { + const output = execFileSync( + process.env.EASYAI_LARK_CLI ?? "lark-cli", + args, + { + encoding: "utf8", + env: { + ...process.env, + LARKSUITE_CLI_NO_UPDATE_NOTIFIER: "1", + LARKSUITE_CLI_NO_SKILLS_NOTIFIER: "1", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + return unwrapLark(output); + } catch (error) { + for (const output of [error?.stdout, error?.stderr]) { + const message = larkErrorMessage(output); + if (message) throw new Error(message); + } + throw error; + } } + function argument(name) { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] : undefined; } -async function assertSafePath(path) { - if (typeof path !== 'string' || !path || isAbsolute(path) || path.split('/').includes('..')) - throw new Error(`不安全的附件路径: ${path}`); - const root = await realpath(process.cwd()); - const target = await realpath(resolve(root, path)); - if (target !== root && !target.startsWith(`${root}/`)) throw new Error(`附件超出当前目录: ${path}`); + +function unwrapLark(output) { + const envelope = JSON.parse(output); + if (envelope.ok === false) + throw new Error(larkErrorMessage(output) ?? "飞书 CLI 调用失败"); + return envelope.data ?? envelope; } -async function writeReceipts(receipts) { - const directory = dirname(receiptPath); - const temporary = `${receiptPath}.${process.pid}.tmp`; - await mkdir(directory, { recursive: true, mode: 0o700 }); - await writeFile(temporary, `${JSON.stringify(receipts, null, 2)}\n`, { mode: 0o600 }); - await rename(temporary, receiptPath); + +function larkErrorMessage(output) { + if (typeof output !== "string" || !output.trim()) return undefined; + try { + const envelope = JSON.parse(output); + if (envelope.ok !== false) return undefined; + return ( + [envelope.error?.message, envelope.error?.hint] + .filter(Boolean) + .join(": ") || undefined + ); + } catch { + return undefined; + } } + +function assertSelectOptions(fields, name, values) { + const options = new Set( + (fields.get(name)?.options ?? []).map((option) => option.name), + ); + for (const value of values) + if (!options.has(value)) + throw new Error(`受控表单字段 ${name} 缺少选项: ${value}`); +} + +// Used by focused tests to prove that attachment metadata participates in a draft. +export const __test = { canonical }; diff --git a/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/test-intake.mjs b/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/test-intake.mjs new file mode 100644 index 0000000..672f3d6 --- /dev/null +++ b/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/test-intake.mjs @@ -0,0 +1,308 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + attachmentManifest, + contentDigest, + createDraftIdentity, + normalize, + parseConfirmationToken, + preview, + verifySecret, +} from "./intake-lib.mjs"; + +const scriptRoot = dirname(fileURLToPath(import.meta.url)); +const temporary = await mkdtemp(resolve(tmpdir(), "easyai-intake-v2-")); + +try { + const minimal = normalize({ + type: "需求", + title: "在设置页展示版本号", + problem_or_goal: "便于支持人员确认当前版本", + expected_outcome: "设置页可看到当前前端版本号", + }); + assert.equal(minimal.priority, "P2"); + assert.deepEqual(minimal.acceptance_criteria, []); + assert.deepEqual(minimal.impact_scope, []); + assert.deepEqual(preview(minimal), { + 类型: "需求", + 标题: "在设置页展示版本号", + 背景与目标: "便于支持人员确认当前版本", + 期望结果: "设置页可看到当前前端版本号", + 优先级: "P2", + }); + + const incompleteBug = normalize({ + type: "Bug", + title: "保存后页面未更新", + background: "负责人观察到保存结果没有出现在列表", + expected_result: "保存成功后列表显示新数据", + }); + assert.equal(incompleteBug.environment, undefined); + assert.deepEqual(incompleteBug.reproduction_steps, undefined); + + assert.throws( + () => normalize({ type: "需求", title: "x", background: "y" }), + /期望结果缺失/, + ); + assert.throws( + () => + normalize({ + ...minimalInput(), + impact_scope: ["全栈", "前端"], + }), + /不能再同时选择/, + ); + assert.doesNotThrow(() => + normalize({ ...minimalInput(), impact_scope: ["部署"] }), + ); + assert.throws( + () => normalize({ ...minimalInput(), desired_at: "2026-02-30" }), + /不是有效日期时间/, + ); + assert.throws( + () => normalize({ ...minimalInput(), attachments: ["../secret.txt"] }), + /不安全的附件路径/, + ); + assert.throws( + () => normalize({ ...minimalInput(), task_id: "EAI-9999" }), + /禁止或未知字段/, + ); + + const firstIdentity = createDraftIdentity("ou_owner"); + const secondIdentity = createDraftIdentity("ou_owner"); + assert.notEqual(firstIdentity.draft_id, secondIdentity.draft_id); + assert.notEqual(firstIdentity.submission_key, secondIdentity.submission_key); + const parsed = parseConfirmationToken(firstIdentity.confirmation_token); + assert.equal(parsed.draftId, firstIdentity.draft_id); + assert.equal( + verifySecret(parsed.secret, firstIdentity.confirmation_token_hash), + true, + ); + + await writeFile(resolve(temporary, "evidence.txt"), "first"); + const attachmentValue = normalize({ + ...minimalInput(), + attachments: ["evidence.txt"], + }); + const firstManifest = await attachmentManifest( + attachmentValue.attachments, + temporary, + ); + const firstDigest = contentDigest(attachmentValue, "ou_owner", firstManifest); + await writeFile(resolve(temporary, "evidence.txt"), "second"); + const secondManifest = await attachmentManifest( + attachmentValue.attachments, + temporary, + ); + assert.notEqual( + firstDigest, + contentDigest(attachmentValue, "ou_owner", secondManifest), + ); + + const fakeCli = resolve(temporary, "fake-lark-cli.mjs"); + await writeFile(fakeCli, fakeLarkCli(), { mode: 0o700 }); + await chmod(fakeCli, 0o700); + const intakeHome = resolve(temporary, "home"); + const env = { + ...process.env, + EASYAI_INTAKE_HOME: intakeHome, + EASYAI_LARK_CLI: fakeCli, + }; + + const prepared = runJson( + "prepare-intake.mjs", + [], + { ...minimalInput(), constraints: ["必须复用既有组件"] }, + env, + ); + assert.equal(prepared.status_code, "prepared"); + assert.match( + prepared.confirmation_token, + new RegExp(`^${prepared.draft_id}\\.`), + ); + const draft = JSON.parse( + await readFile( + resolve( + intakeHome, + ".easyai-intake", + "drafts", + `${prepared.draft_id}.json`, + ), + "utf8", + ), + ); + assert.equal(draft.owner_open_id, "ou_owner"); + assert.equal(draft.status_code, "prepared"); + + const dryRun = runJson( + "submit-intake.mjs", + ["--confirm", prepared.confirmation_token, "--dry-run"], + undefined, + env, + ); + assert.equal(dryRun.dry_run, true); + + const submitted = runJson( + "submit-intake.mjs", + ["--confirm", prepared.confirmation_token], + undefined, + env, + ); + assert.equal(submitted.status_code, "submitted"); + const duplicate = runJson( + "submit-intake.mjs", + ["--confirm", prepared.confirmation_token], + undefined, + env, + ); + assert.equal(duplicate.duplicate, true); + + const unknownDraft = runJson( + "prepare-intake.mjs", + [], + { ...minimalInput(), title: "未知结果恢复" }, + env, + ); + const failed = runJson( + "submit-intake.mjs", + ["--confirm", unknownDraft.confirmation_token], + undefined, + { ...env, EASYAI_FAKE_FAIL_FORM: "1" }, + 1, + ); + assert.equal(failed.status_code, "unknown"); + const blockedRetry = runJson( + "submit-intake.mjs", + ["--confirm", unknownDraft.confirmation_token], + undefined, + env, + 1, + ); + assert.match(blockedRetry.error, /--retry-unknown/); + const recovered = runJson( + "submit-intake.mjs", + ["--confirm", unknownDraft.confirmation_token, "--retry-unknown"], + undefined, + env, + ); + assert.equal(recovered.status_code, "submitted"); + + const lockedDraft = runJson( + "prepare-intake.mjs", + [], + { ...minimalInput(), title: "并发锁测试" }, + env, + ); + await mkdir( + resolve(intakeHome, ".easyai-intake", "locks", lockedDraft.draft_id), + { recursive: true }, + ); + const locked = runJson( + "submit-intake.mjs", + ["--confirm", lockedDraft.confirmation_token], + undefined, + env, + 1, + ); + assert.match(locked.error, /正在提交/); + + console.log("intake v2 tests passed"); +} finally { + await rm(temporary, { recursive: true, force: true }); +} + +function minimalInput() { + return { + type: "需求", + title: "在设置页展示版本号", + background: "便于支持人员确认当前版本", + expected_result: "设置页可看到当前前端版本号", + }; +} + +function runJson(script, args, input, env, expectedStatus = 0) { + const result = spawnSync( + process.execPath, + [resolve(scriptRoot, script), ...args], + { + cwd: temporary, + env, + encoding: "utf8", + input: input === undefined ? undefined : JSON.stringify(input), + }, + ); + assert.equal( + result.status, + expectedStatus, + `${script} status=${result.status}\nstdout=${result.stdout}\nstderr=${result.stderr}`, + ); + return JSON.parse( + (expectedStatus === 0 ? result.stdout : result.stderr).trim(), + ); +} + +function fakeLarkCli() { + return `#!/usr/bin/env node +const args = process.argv.slice(2); +const ok = (data) => console.log(JSON.stringify({ ok: true, data })); +if (args[0] === "auth") { + ok({ identities: { user: { verified: true, openId: "ou_owner" } } }); +} else if (args.includes("+form-detail")) { + const question = (title, type, required = false, options = []) => ({ + title, type, required, options: options.map((name) => ({ name })), + }); + ok({ + base_token: "Ed21b9VwNaZfiesxWgDc67wXnoc", + questions: [ + question("类型", "select", true, ["需求", "Bug"]), + question("标题", "text", true), + question("需求背景与目标", "text", true), + question("期望结果", "text", true), + question("优先级", "select", true, ["P0", "P1", "P2", "P3"]), + question("产品负责人", "user", true), + question("提交幂等键", "text", true), + question("提交来源", "select", true, ["Codex Skill"]), + question("验收标准", "text"), + question("发生环境", "text"), + question("复现步骤", "text"), + question("实际结果", "text"), + question("影响范围", "select", false, ["前端", "后端", "全栈", "部署"]), + question("约束与非目标", "text"), + question("期望完成时间", "datetime"), + question("参考资料", "text"), + question("附件", "attachment"), + ], + }); +} else if (args.includes("+record-search")) { + ok({ data: [], record_id_list: [] }); +} else if (args.includes("+form-submit")) { + if (process.env.EASYAI_FAKE_FAIL_FORM === "1") { + console.error(JSON.stringify({ ok: false, error: { message: "network interrupted" } })); + process.exit(1); + } + ok({ record: { record_id: "rec_test" } }); +} else if (args.includes("+record-upsert")) { + const payload = JSON.parse(args[args.indexOf("--json") + 1] ?? "{}"); + if (!/^[a-f0-9]{64}$/.test(payload["原始意图指纹"] ?? "")) { + console.error(JSON.stringify({ ok: false, error: { message: "missing owner intent fingerprint" } })); + process.exit(1); + } + ok({ record_id: "rec_test" }); +} else { + console.error(JSON.stringify({ ok: false, error: { message: "unexpected command: " + args.join(" ") } })); + process.exit(1); +} +`; +}