UNPKG

@lark-project/cli

Version:

飞书项目插件开发工具

280 lines (254 loc) 10.1 kB
#!/usr/bin/env node /** * postinstall 钩子:把随 CLI 发布的 skills 注册到本地 AI 工具 * (Claude Code / Gemini CLI / Copilot CLI / Codex 等)。 * * 解析 `skills` 可执行文件的优先级(无网络最稳 → 最兜底): * 1. 本包的 optionalDependencies 里拉到的 node_modules/skills/bin * 2. 全局 PATH 上已有的 `skills` 命令 * 3. `npx --yes skills`(最后兜底;会走网络)——不显式传 --registry, * 由 npx 自己从用户的 npm 配置继承(可能来自 ~/.npmrc、env 等), * 尊重用户自己的 registry 设置 * * 注册流程:先 `add` 当前 skills 目录,成功后再 `remove` 已被合并下线的旧 * skill(见 LEGACY_SKILLS)——存量用户升级即自动迁移。**顺序固定 add→remove**: * add 失败时不动旧 skill,用户至少保留旧版可用,不会落得新旧都没有。 * * 跳过条件: * - LPM_SKIP_SKILLS=1 * - CI=true * - npm_config_ignore_scripts * - INIT_CWD 是包根(本仓库自己的 yarn install) * * 失败不阻断 CLI 安装,会打印手动命令给用户兜底。 */ 'use strict'; const { spawn, execSync } = require('child_process'); const path = require('path'); const fs = require('fs'); const TIMEOUT_MS = 30_000; /** * 旧版按 7 个独立 skill 注册的 meegle-plugin-*,现已合并为单个 `meegle-plugin`, * 升级时需把这些旧名 remove 掉。 * * ⚠️ 不含 `meegle-plugin-backend`:它后来作为现役 skill 重新独立发布(见 * `skills/meegle-plugin-backend/`)。若放进本列表,add→remove 序会把刚 `add --all` * 注册进来的 backend 又删掉,导致它永远装不上。现役 skill 一律不进 LEGACY_SKILLS。 */ const LEGACY_SKILLS = [ 'meegle-plugin-create', 'meegle-plugin-feature', 'meegle-plugin-polish', 'meegle-plugin-publish', 'meegle-plugin-workflow', 'meegle-plugin-shared', ]; function shouldSkip() { if (process.env.LPM_SKIP_SKILLS) return 'LPM_SKIP_SKILLS 已设置'; if (process.env.CI) return 'CI 环境'; if (process.env.npm_config_ignore_scripts === 'true') return '--ignore-scripts'; const initCwd = process.env.INIT_CWD || process.cwd(); const pkgRoot = path.resolve(__dirname, '..'); if (initCwd === pkgRoot) return 'dev install(CLI 自身 yarn install)'; return null; } /** Resolve `skills` binary from bundled optionalDependency (zero network). */ function resolveBundledSkills() { try { const pkgJsonPath = require.resolve('skills/package.json', { paths: [path.resolve(__dirname, '..')], }); const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')); const binEntry = pkg.bin; if (!binEntry) return null; const relBin = typeof binEntry === 'string' ? binEntry : binEntry.skills; if (!relBin) return null; const absBin = path.resolve(path.dirname(pkgJsonPath), relBin); if (fs.existsSync(absBin)) return absBin; return null; } catch { return null; } } /** Check whether a global `skills` is on PATH. */ function resolveGlobalSkills() { try { // `command -v skills` on POSIX; `where skills` on Windows const cmd = process.platform === 'win32' ? 'where skills' : 'command -v skills'; const out = execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] }) .toString() .trim() .split(/\r?\n/)[0]; return out || null; } catch { return null; } } /** * 决定用哪个 `skills` runner。返回 `{ cmd, prefix, label }`:实际调用是 * `cmd` + `prefix` + 子命令参数。bundled / global / npx 三级兜底。 */ function resolveRunner() { const bundled = resolveBundledSkills(); if (bundled) { return { cmd: process.execPath, prefix: [bundled], label: `bundled skills (${path.relative(process.cwd(), bundled)})`, }; } const globalBin = resolveGlobalSkills(); if (globalBin) { return { cmd: globalBin, prefix: [], label: `global skills (${globalBin})` }; } return { cmd: 'npx', prefix: ['--yes', 'skills'], label: 'npx (fallback, uses your npm config)' }; } /** * 跑一条 `skills` 子命令。返回 Promise<number|null>(exit code;null = 被信号 * 杀掉 / spawn 出错)。**永不 reject**——由调用方按 code 决定下一步。 * * @param {object} [opts] * @param {boolean} [opts.silent] 失败时不打印手动兜底提示。旧 skill 清理用—— * 单个 skill 不存在 / 删除失败属正常(新用户没装过旧版),不该吓到用户。 * @param {number} [opts.timeoutMs] 单条命令超时。 */ function runSkillsCommand(cmd, args, skillsDir, opts = {}) { const { silent = false, timeoutMs = TIMEOUT_MS } = opts; return new Promise(resolve => { let settled = false; const finish = code => { if (settled) return; settled = true; clearTimeout(timer); resolve(code); }; const child = spawn(cmd, args, { stdio: 'inherit', shell: process.platform === 'win32', detached: process.platform !== 'win32', }); const timer = setTimeout(() => { killTree(child); if (!silent) { try { printManualHint(skillsDir, `timeout after ${Math.round(timeoutMs / 1000)}s`); } catch { /* stdout/stderr closed (EPIPE) during npm install */ } } finish(null); }, timeoutMs); child.on('error', err => { if (!silent) { try { printManualHint(skillsDir, err.message); } catch { /* EPIPE */ } } finish(null); }); child.on('exit', code => { // code === null 时是被信号杀掉(超时 SIGTERM/SIGKILL)→ killTree 已处理 if (code !== 0 && code !== null && !silent) { try { printManualHint(skillsDir, `exit code ${code}`); } catch { /* EPIPE */ } } finish(code); }); }); } /** * add 成功后清理旧 skill。逐个 `skills remove <name> -g`:单个失败 / skill * 不存在都属正常(新用户没装过旧版),silent + 不阻断后续。MUST 在 add 成功 * 之后调用——add 失败时不动旧 skill。 */ async function removeLegacy(runner, skillsDir) { for (const name of LEGACY_SKILLS) { // -y 不可省:`skills remove` 默认交互式(弹 Yes/No 确认),postinstall 非交互 // 环境缺 -y 会卡在确认提示直到超时被杀、什么都没删。 await runSkillsCommand( runner.cmd, [...runner.prefix, 'remove', name, '-g', '-y'], skillsDir, { silent: true, timeoutMs: 10_000 }, ); } } async function main() { const skillsDir = path.resolve(__dirname, '..', 'skills'); const skipReason = shouldSkip(); if (skipReason) { console.log(`ℹ skip registering skills (${skipReason})`); // CI / LPM_SKIP_SKILLS / --ignore-scripts 等场景 postinstall 被跳过—— // 这群人永远不会触发自动迁移;如果他们装过本版本之前的旧 meegle-plugin-* // 7 个 skill,需要手动卸掉,否则旧 skill 与新版 meegle-plugin 会并存 + 路由抢戏。 printSkipHint(skillsDir); return; } if (!fs.existsSync(skillsDir)) { console.warn(`⚠ skills 目录不存在:${skillsDir}`); return; } // --all = --skill '*' --agent '*' -y,跳过 skills 工具的交互选择 // -g = 装到用户全局而非当前项目 const addArgs = ['add', skillsDir, '-g', '--all']; const runner = resolveRunner(); console.log(`→ registering via ${runner.label}...`); const addCode = await runSkillsCommand(runner.cmd, [...runner.prefix, ...addArgs], skillsDir); if (addCode !== 0) { // 失败提示已在 runSkillsCommand 内打印;不清理旧 skill(保留旧版可用) return; } console.log('✓ Meegle plugin skills registered'); console.log(' 请完全重启你的 AI 工具(Claude Code / Gemini / Copilot / Codex)以加载 skills'); // add 成功 → 清理已合并下线的旧 skill,完成存量用户迁移 await removeLegacy(runner, skillsDir); } /** SIGTERM the whole process group on POSIX to avoid orphaned npx children. */ function killTree(child) { if (!child || !child.pid) return; try { if (process.platform === 'win32') { child.kill(); } else { // detached:true 时 child.pid 是进程组 leader;kill(-pid) 杀整个组 process.kill(-child.pid, 'SIGTERM'); // 2s 后升级 SIGKILL 兜底 setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch { /* 已经退出 */ } }, 2000).unref(); } } catch { /* 已经退出 */ } } function printSkipHint(skillsDir) { console.warn(' postinstall 跳过 → 自动注册和旧 skill 清理都没跑。如需手动处理:'); console.warn(` 1. 注册当前版本: npx skills add ${skillsDir} -g --all`); console.warn(` 2. 卸载旧版 meegle-plugin-* (本版已合并为单一 meegle-plugin,旧 ${LEGACY_SKILLS.length} 个 skill 已下线):`); console.warn(` for s in ${LEGACY_SKILLS.join(' ')}; do npx skills remove $s -g -y; done`); console.warn(' (POSIX 兼容;不存在的会被 silently skip)'); console.warn(' 跳过原因可关:unset LPM_SKIP_SKILLS / 不设 CI / 不带 --ignore-scripts,再重装。\n'); } function printManualHint(skillsDir, reason) { console.warn(`\n⚠ 自动注册 skills 失败(${reason})`); console.warn(' 可以手动注册(任选其一):'); console.warn(` npx skills add ${skillsDir} -g --all`); console.warn(` # 或如果已有全局 skills: skills add ${skillsDir} -g --all`); console.warn(' 或重装时设置 LPM_SKIP_SKILLS=1 跳过此步骤。\n'); } module.exports = { main, runSkillsCommand, removeLegacy, resolveRunner, LEGACY_SKILLS }; // 仅作为 postinstall 脚本直接执行时跑 main;被 require(测试)时只导出、不执行。 if (require.main === module) { main().catch(err => { console.warn(`⚠ install-skills 发生异常:${err?.message || err}`); }); }