feat(插件): 发布意图提单 V2
This commit is contained in:
@@ -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、部署和审计字段由中央流水线维护,产品经理无需编辑。
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 <confirmation_token>`。
|
||||
6. 返回提交回执、幂等键和处理状态。任务编号由中央调度器在下一次扫描时生成并通过飞书通知。
|
||||
4. 展示脚本返回的完整中文预览和提交幂等键。必须等待用户明确说“确认提交”或等价表达。
|
||||
5. 若用户在确认前修改任何字段,重新预览并使用新的 `confirmation_token`;旧确认令牌失效。
|
||||
6. 确认后运行 `scripts/submit-intake.mjs --confirm <confirmation_token>`;脚本读取已保存且绑定负责人身份的草稿,不接受模型在提交时悄悄改写内容。
|
||||
7. 返回提交回执、幂等键和处理状态。若返回 `unknown`,先按幂等键核对 Base;确认不存在后,只有在负责人再次明确同意时才加 `--retry-unknown` 重试。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
printf '%s' '<json>' | node scripts/prepare-intake.mjs
|
||||
printf '%s' '<json>' | node scripts/submit-intake.mjs --confirm '<confirmation_token>'
|
||||
从本 `SKILL.md` 所在目录解析脚本绝对路径,但保持进程工作目录为用户当前工作目录,以便附件相对路径正确解析。优先启动 Node 进程后通过执行工具的 stdin 通道传入原始 JSON;不要把用户文本插值进 shell 命令。
|
||||
|
||||
```text
|
||||
node <skill-root>/scripts/prepare-intake.mjs
|
||||
node <skill-root>/scripts/submit-intake.mjs --confirm <confirmation_token>
|
||||
```
|
||||
|
||||
`submit-intake.mjs` 使用当前电脑已授权的飞书用户身份提交企业内受控表单,不要要求用户提供 token、Cookie 或密码。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 只能通过受控表单写入 [base-schema.md](references/base-schema.md) 列出的产品字段。
|
||||
- 产品字段只能通过受控表单写入。表单成功后,脚本只按返回的 `record_id` 补写附件内容已纳入的 `原始意图指纹`,不修改任何产品文本。
|
||||
- 禁止写入任务编号、任务状态、执行阶段、状态版本、Thread、PR、部署、验收、锁、重试和审计字段。
|
||||
- 不经用户确认不得运行提交脚本。
|
||||
- 范围和验收提示都是可选信息;不得因为缺少技术范围、仓库、风险分析或完整验收设计而阻止登记。
|
||||
- 附件必须是当前工作目录内的相对路径;不得读取或上传其他文件。
|
||||
- 提交失败时保留原输入并报告结构化错误,不得改写产品意图后自动重试。
|
||||
- 提交失败时保留原输入并报告结构化错误,不得改写产品意图或盲目重试。附件内容变化会使旧确认令牌失效。
|
||||
|
||||
## 返回规则
|
||||
|
||||
- 信息不完整:只返回下一组最多 3 个问题。
|
||||
- 待确认:展示类型、标题、背景、验收标准、优先级和附件。
|
||||
- 提交成功:返回提交回执并明确说明“已登记,等待系统分配任务编号”。
|
||||
- 信息不完整:只返回一个最关键的产品问题,包含 2~3 个选项、推荐方案和影响。
|
||||
- 待确认:展示脚本 `preview` 返回的全部字段、提交幂等键,并提示“回复确认提交后才会写入飞书”。
|
||||
- 提交成功:返回提交回执并明确说明“已登记,等待系统分配任务编号”;提醒用户留意后续自动开始、澄清或转人工卡片。
|
||||
- 飞书授权不足:原样返回缺失 scope 及授权提示,不改用 bot 身份。
|
||||
|
||||
@@ -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。"
|
||||
|
||||
@@ -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、部署、验收、锁、重试和审计字段都不属于提单插件。
|
||||
|
||||
+28
-11
@@ -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 时应用)
|
||||
|
||||
- 范围清晰、一天内可完成、验收结果确定、可回滚。
|
||||
- 不涉及支付、权限、数据迁移、数据库结构或破坏性操作。
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
+107
-3
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+420
-92
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user