Files
easyai-codex-plugins/plugins/easyai-product-intake/skills/easyai-submit-task/scripts/intake-lib.mjs
T

259 lines
8.8 KiB
JavaScript

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",
"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");
for await (const chunk of process.stdin) input += chunk;
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 对象");
const unknown = Object.keys(input).filter((key) => !allowedKeys.has(key));
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(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", "实际结果"],
["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 = optionalList(input.reproduction_steps, "复现步骤");
return value;
}
export function canonical(value) {
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(",")}}`;
return JSON.stringify(value);
}
export function sha256(value) {
return createHash("sha256").update(value).digest("hex");
}
export function createDraftIdentity(ownerOpenId) {
const draftId = randomUUID();
const secret = randomBytes(32).toString("base64url");
return {
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"));
} catch (error) {
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}缺失`);
return value.trim();
}
function requiredList(value, label) {
if (!Array.isArray(value) || !value.length) throw new Error(`${label}缺失`);
return value.map((item) => requiredText(item, label));
}
function optionalList(value, label) {
return value === undefined || (Array.isArray(value) && value.length === 0)
? []
: 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(" / ")}`);
return value;
}
function optionalEnumList(value, label, options) {
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}`;
}