From 7441ec41a980ef094b5c70e4ae1d75ec4b8f915a Mon Sep 17 00:00:00 2001 From: liguochuan <292761894@qq.com> Date: Wed, 26 Aug 2026 16:44:52 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20build:=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20Qoder=20=E6=8F=92=E4=BB=B6=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=EF=BC=88core=20=E5=85=A8=E9=87=8F=E7=94=9F?= =?UTF-8?q?=E6=88=90=20+=20zip=20=E6=89=93=E5=8C=85=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/build-qoder-plugin.js:从 @double-coding/flow2spec-core 公共 API 全量生成 Qoder 原生插件(18 skills + 10 rules + 3 hooks),零依赖 zlib 产出 flow2spec-.zip - package.json 挂载 build:qoder-plugin 命令 - .gitignore 忽略 output/qoder-plugin/ 构建产物 --- .gitignore | 1 + package.json | 1 + scripts/build-qoder-plugin.js | 343 ++++++++++++++++++++++++++++++++++ 3 files changed, 345 insertions(+) create mode 100644 scripts/build-qoder-plugin.js diff --git a/.gitignore b/.gitignore index 73f37d2..c5fc806 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ docs/*.json .Knowledge/update-check.json .playwright-cli/ output/playwright/ +output/qoder-plugin/ node_modules/ .astro/ .Knowledge/.last-sync.json diff --git a/package.json b/package.json index a24cd31..fd727d5 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test:core": "node scripts/test-core-api.js", "test:cli": "node cli.js --help && node cli.js doctor --json", "sync:agents": "node cli.js init cursor claude codex", + "build:qoder-plugin": "node scripts/build-qoder-plugin.js", "sync:package-readme": "node scripts/sync-package-readme.js", "pack:check": "npm run version:check && node scripts/sync-package-readme.js --check && npm pack --workspace @double-coding/flow2spec-core --dry-run && npm pack --workspace @double-coding/flow2spec --dry-run", "tag:version:cli": "node scripts/git-tag-version.js cli", diff --git a/scripts/build-qoder-plugin.js b/scripts/build-qoder-plugin.js new file mode 100644 index 0000000..5f268a4 --- /dev/null +++ b/scripts/build-qoder-plugin.js @@ -0,0 +1,343 @@ +#!/usr/bin/env node +"use strict"; +/** + * 从 @double-coding/flow2spec-core 生成 Qoder 原生插件(全量能力)。 + * Core 是唯一事实源:skills/rules 走 resources 公共 API(dsh host 适配 = + * 纯 rules/ skills/ 相对路径 + 技能名 kebab-case,恰好匹配 Qoder 插件布局), + * hooks 按 init 同款规则渲染 __FLOW2SPEC_*__ 占位符。 + * + * 用法:node scripts/build-qoder-plugin.js [--locale zh-CN|en-US] [--out ] + */ + +const fs = require("fs"); +const path = require("path"); +const zlib = require("zlib"); +const core = require("@double-coding/flow2spec-core"); + +const PLUGIN_NAME = "flow2spec"; +const HOST = "dsh"; + +function parseArgs(argv) { + const args = { locale: "zh-CN", out: path.join("output", "qoder-plugin") }; + for (let i = 2; i < argv.length; i += 1) { + if (argv[i] === "--locale" && argv[i + 1]) args.locale = argv[(i += 1)]; + else if (argv[i] === "--out" && argv[i + 1]) args.out = argv[(i += 1)]; + } + return args; +} + +function kebabSkillNames(content) { + // 与 core resources 的 dsh host 技能名规则保持一致(addRules -> add-rules) + return String(content).replace(/\bf2s-[a-zA-Z0-9-]*[A-Z][a-zA-Z0-9-]*\b/g, (name) => + name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(), + ); +} + +function ensureDir(dir) { + fs.mkdirSync(dir, { recursive: true }); +} + +function writeFile(file, content) { + ensureDir(path.dirname(file)); + fs.writeFileSync(file, content, "utf8"); +} + +function renderHook(raw, metadata) { + return String(raw) + .replace(/__FLOW2SPEC_PACKAGE_NAME__/g, metadata.name) + .replace(/__FLOW2SPEC_CORE_VERSION__/g, metadata.coreVersion) + .replace(/__FLOW2SPEC_TEMPLATE_VERSION__/g, metadata.templateVersion); +} + +// ---- 零依赖 ZIP 打包(deflate,zip 根目录即插件根,含 .qoder-plugin/plugin.json)---- + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c >>> 0; + } + return table; +})(); + +function crc32(buffer) { + let crc = 0xffffffff; + for (const byte of buffer) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8); + return (crc ^ 0xffffffff) >>> 0; +} + +function dosDateTime(date) { + const year = Math.max(date.getFullYear(), 1980); + return { + time: (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1), + date: ((year - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(), + }; +} + +function listFilesRecursive(root, prefix = "") { + const files = []; + for (const entry of fs.readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const relative = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) files.push(...listFilesRecursive(path.join(root, entry.name), relative)); + else files.push(relative); + } + return files; +} + +function zipDirectory(rootDir, zipPath) { + const { time, date } = dosDateTime(new Date()); + const chunks = []; + const centralEntries = []; + let offset = 0; + + for (const relative of listFilesRecursive(rootDir)) { + const data = fs.readFileSync(path.join(rootDir, ...relative.split("/"))); + const compressed = zlib.deflateRawSync(data, { level: 9 }); + const nameBuffer = Buffer.from(relative, "utf8"); + const checksum = crc32(data); + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); // version needed + local.writeUInt16LE(0x0800, 6); // UTF-8 filename flag + local.writeUInt16LE(8, 8); // deflate + local.writeUInt16LE(time, 10); + local.writeUInt16LE(date, 12); + local.writeUInt32LE(checksum, 14); + local.writeUInt32LE(compressed.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(nameBuffer.length, 26); + local.writeUInt16LE(0, 28); // extra length + chunks.push(local, nameBuffer, compressed); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); // version made by + central.writeUInt16LE(20, 6); // version needed + central.writeUInt16LE(0x0800, 8); + central.writeUInt16LE(8, 10); + central.writeUInt16LE(time, 12); + central.writeUInt16LE(date, 14); + central.writeUInt32LE(checksum, 16); + central.writeUInt32LE(compressed.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(nameBuffer.length, 28); + central.writeUInt32LE(offset, 42); + centralEntries.push(Buffer.concat([central, nameBuffer])); + + offset += local.length + nameBuffer.length + compressed.length; + } + + const centralDirectory = Buffer.concat(centralEntries); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(centralEntries.length, 8); + eocd.writeUInt16LE(centralEntries.length, 10); + eocd.writeUInt32LE(centralDirectory.length, 12); + eocd.writeUInt32LE(offset, 16); + + fs.writeFileSync(zipPath, Buffer.concat([...chunks, centralDirectory, eocd])); +} + +const HOOKS_JSON = { + hooks: { + SessionStart: [ + { + hooks: [ + { + type: "command", + command: 'node "${QODER_PLUGIN_ROOT}/hooks/f2s-config-session.js"', + timeout: 10, + statusMessage: "Flow2Spec 配置摘要注入", + }, + { + type: "command", + command: 'node "${QODER_PLUGIN_ROOT}/hooks/f2s-update-check.js"', + timeout: 15, + statusMessage: "Flow2Spec 知识库版本检查", + }, + ], + }, + ], + PreToolUse: [ + { + matcher: "Skill", + hooks: [ + { + type: "command", + command: 'node "${QODER_PLUGIN_ROOT}/hooks/f2s-config-inject.js"', + timeout: 10, + }, + ], + }, + ], + }, +}; + +const AVATAR_SVG = ` + + + + + + + + + + + + F2S + +`; + +function pluginManifest(versions) { + return { + name: PLUGIN_NAME, + displayName: "Flow2Spec", + version: versions.coreVersion, + description: + "Spec-driven dev workflow suite: requirement clarification, tech design, task planning, knowledge-base lifecycle (build/sync/feat/fix/merge), docs tooling, and safe git commits, backed by a routed project knowledge base (.Knowledge).", + descriptionZh: + "Spec-driven 研发工作流全量套件:需求澄清、技术方案、任务规划、知识库全生命周期(build/sync/feat/fix/merge 等)、文档工具与安全 Git 提交,基于可路由的项目知识库(.Knowledge)。", + author: { name: "double-coding-lab" }, + homepage: "https://github.com/double-coding-lab/Flow2Spec#readme", + repository: "https://github.com/double-coding-lab/Flow2Spec", + logo: "./assets/avatar.svg", + keywords: ["qoder-plugin", "skill", "workflow", "spec-driven", "knowledge-base"], + category: "developer-tools", + tags: ["skill", "rules", "hooks", "workflow"], + skills: "./skills/", + rules: "./rules/", + hooks: "./hooks/hooks.json", + }; +} + +function readmeContent({ locale, versions, skills, ruleIds, hookNames }) { + const skillRows = skills + .map((skill) => { + const description = skill.description.replace(/\|/g, "\\|"); + const brief = description.length > 90 ? `${description.slice(0, 90)}…` : description; + return `| \`${skill.name}\` | ${brief} |`; + }) + .join("\n"); + const ruleList = ruleIds.map((id) => `- \`${id}\``).join("\n"); + const hookList = hookNames.map((name) => `- \`${name}\``).join("\n"); + return `# Flow2Spec — Qoder 插件(全量) + +Spec-driven 研发工作流全量套件,基于可路由的项目知识库(\`.Knowledge/\`)。 +由 \`scripts/build-qoder-plugin.js\` 从 \`@double-coding/flow2spec-core\` v${versions.coreVersion}(Template v${versions.templateVersion},locale ${locale})自动生成,请勿手工编辑本目录。 + +## Skills(${skills.length} 个) + +| 技能 | 说明 | +| --- | --- | +${skillRows} + +## Rules(${ruleIds.length} 条) + +${ruleList} + +## Hooks + +${hookList} + +接线见 \`hooks/hooks.json\`:SessionStart 注入配置摘要与知识库版本检查,PreToolUse(matcher: Skill) 兜底注入配置提示;脚本经 \`\${QODER_PLUGIN_ROOT}\` 定位,占位符已按 Core 版本渲染。 + +## 使用前提(重要) + +1. **目标项目须先初始化 Flow2Spec**:项目根执行 \`npx @double-coding/flow2spec init\`,生成 \`.Knowledge/\`(知识路由)与 \`flow2spec.config.json\`(编排开关);未初始化时技能会提示先执行 init。 +2. **Hooks 依赖 Node.js**(>=16)在 PATH 中可用;hooks 仅做提醒/检测,不替代技能正文的 \`Read("flow2spec.config.json")\` 门禁。 + +## 来源与说明 + +- 唯一事实源:\`@double-coding/flow2spec-core\` 的 \`resources\` 公共 API(dsh host 适配:纯 \`rules/\` \`skills/\` 相对路径、技能名 kebab-case)。 +- 技能内部引用(如 \`f2s-kb-addRules\`)已统一为 kebab-case 名称。 +- \`.Knowledge/\` 知识模板属项目级资产,由 \`flow2spec init\` 写入,不随插件分发。 +- Logo(\`assets/avatar.svg\`)为本插件自制。 +- 构建同时产出同级 \`flow2spec-.zip\`(zip 根即插件根,可直接导入或上传市场)。 +`; +} + +function main() { + const args = parseArgs(process.argv); + const repoRoot = path.resolve(__dirname, ".."); + const pluginRoot = path.resolve(repoRoot, args.out, PLUGIN_NAME); + const versions = core.getVersions(); + const api = core.createFlow2Spec({ cwd: repoRoot }); + const corePackage = JSON.parse( + fs.readFileSync(path.join(core.resourcesRoot, "package.json"), "utf8"), + ); + + fs.rmSync(pluginRoot, { recursive: true, force: true }); + + // Skills(全量,dsh host 适配)+ 各技能引用的 rules(已适配路径) + const catalog = api.resources.skillCatalog({ host: HOST, locale: args.locale }); + const rules = new Map(); + for (const skill of catalog) { + const frontmatter = `---\nname: ${skill.name}\ndescription: ${JSON.stringify(skill.description)}\n---\n\n`; + writeFile(path.join(pluginRoot, "skills", skill.name, "SKILL.md"), frontmatter + skill.content); + for (const resource of skill.resources) { + if (!rules.has(resource.relativePath)) rules.set(resource.relativePath, resource.content); + } + } + + // 未被任何技能引用的独立 rules 也全量带上(仅做技能名 kebab-case 对齐) + for (const relative of api.resources.listRules(args.locale)) { + const target = path.posix.join("rules", relative); + if (!rules.has(target)) { + rules.set(target, kebabSkillNames(api.resources.read(path.posix.join("rules", relative), args.locale))); + } + } + for (const [relativePath, content] of rules) { + writeFile(path.join(pluginRoot, ...relativePath.split("/")), content); + } + + // Hooks:占位符按 init 同款规则渲染 + const hookMetadata = { + name: corePackage.name, + coreVersion: versions.coreVersion, + templateVersion: versions.templateVersion, + }; + const hookNames = api.resources.listHooks(args.locale); + for (const hookName of hookNames) { + const raw = api.resources.read(path.posix.join("hooks", hookName), args.locale); + writeFile(path.join(pluginRoot, "hooks", hookName), renderHook(raw, hookMetadata)); + } + writeFile(path.join(pluginRoot, "hooks", "hooks.json"), `${JSON.stringify(HOOKS_JSON, null, 2)}\n`); + + writeFile( + path.join(pluginRoot, ".qoder-plugin", "plugin.json"), + `${JSON.stringify(pluginManifest(versions), null, 2)}\n`, + ); + writeFile(path.join(pluginRoot, "assets", "avatar.svg"), AVATAR_SVG); + writeFile( + path.join(pluginRoot, "README.md"), + readmeContent({ + locale: args.locale, + versions, + skills: catalog, + ruleIds: Array.from(rules.keys()).map((p) => path.posix.basename(p, ".md")).sort(), + hookNames, + }), + ); + + console.log(`[build-qoder-plugin] plugin: ${pluginRoot}`); + console.log(`[build-qoder-plugin] skills: ${catalog.length}, rules: ${rules.size}, hooks: ${hookNames.length}`); + console.log(`[build-qoder-plugin] core v${versions.coreVersion}, template v${versions.templateVersion}, locale ${args.locale}`); + + // Zip 分发包:清理旧版本 zip 后重新生成 + const outDir = path.dirname(pluginRoot); + for (const entry of fs.readdirSync(outDir)) { + if (entry.startsWith(`${PLUGIN_NAME}-`) && entry.endsWith(".zip")) { + fs.rmSync(path.join(outDir, entry), { force: true }); + } + } + const zipPath = path.join(outDir, `${PLUGIN_NAME}-${versions.coreVersion}.zip`); + zipDirectory(pluginRoot, zipPath); + console.log(`[build-qoder-plugin] zip: ${zipPath}`); +} + +main();