@lark-project/cli
Version:
飞书项目插件开发工具
280 lines (254 loc) • 10.1 kB
JavaScript
/**
* 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 安装,会打印手动命令给用户兜底。
*/
;
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}`);
});
}