skill-interface/skills/skill-interface/scripts/skill-release.mjs
2026-10-02 09:30:37 +08:00

363 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* 发布技能的两个出口(同一个源,同一道门禁;镜像仓/包仓都是生成物):
*
* skill-release cli <技能> 发布 CLI 仓:<工具>-cli(给所有人,不依赖 Pi)
* skill-release package <技能> 发布 Pi package 仓:<工具>(pi install git:… 一条命令装技能)
* skill-release list 清点两个出口的发布状态
*
* 门禁复用 skill-pack(白名单 / 泄漏扫描 / skillcheck 结构 / 契约不漂移),并且
* 只在技能目录已提交(没有未提交改动)时发布——记账的提交要对得上发布内容。
*
* 声明(interface.json):`"publish": { "repo": "<CLI 仓 URL>", "mirrors"?: ["…"],
* "package_repo"?: "<包仓 URL>" }`;包仓默认由 CLI 仓名去掉 `-cli` 得到(命名约定)。
*
* 载荷:
* cli = scripts/ + tests/ + VERSION + REFERENCE.md + 生成的 README.md + MANIFEST.json
* (纯工具,不带 Pi 元数据)
* package = skills/<技能>/(skill-pack 的技能本体)+ 生成的 package.json + README.md + MANIFEST.json
*
* 退出码:0 成功;3 门禁被拦或全部推送失败;2 调用错误。
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { packSkill, scanLeaks } from './skill-pack.mjs';
import { loadSkills, repoRoot, agentDir } from './skill-registry.mjs';
export const EXIT_OK = 0;
export const EXIT_USAGE = 2;
export const EXIT_BLOCKED = 3;
const PI_METADATA = ['SKILL.md', 'interface.json', 'contract.lock.json'];
const git = (cwd, args) => {
const result = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8' });
return { ok: result.status === 0, out: (result.stdout ?? '').trim(), err: (result.stderr ?? '').trim() };
};
export const stateFile = repo => path.join(agentDir(repo), 'local', 'skills', 'skill-interface', 'release.json');
export function readState(repo) {
for (const name of ['release.json', 'publish.json']) {
try {
return JSON.parse(fs.readFileSync(path.join(agentDir(repo), 'local', 'skills', 'skill-interface', name), 'utf8'));
} catch {
/* 换名前的记账在 publish.json,读到就沿用 */
}
}
return {};
}
function writeState(repo, state) {
const file = stateFile(repo);
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
fs.renameSync(tmp, file);
}
export const versionOf = skill => {
try {
return fs.readFileSync(path.join(skill.dir, 'VERSION'), 'utf8').trim();
} catch {
return '';
}
};
/** CLI 仓名 → 包仓名:去掉 `-cli`(命名约定)。 */
export const packageRepoOf = cliRepo => cliRepo.replace(/-cli(\.git)?$/, '$1');
export const releaseDeclaration = skill => {
const declared = skill.declared?.publish;
const cliRepo = typeof declared?.repo === 'string' ? declared.repo.trim() : '';
const packageOnly = typeof declared?.package_repo === 'string' ? declared.package_repo.trim() : '';
if (!declared || (!cliRepo && !packageOnly)) return null;
const cli = [cliRepo, ...(Array.isArray(declared.mirrors) ? declared.mirrors : [])]
.filter(value => typeof value === 'string' && value.trim())
.map(value => value.trim());
const packageRepo = packageOnly || (cliRepo ? packageRepoOf(cliRepo) : '');
return { cli, packageRepo, version: versionOf(skill) };
};
/** 技能目录在共享仓库里的最后一次提交;list 用它判断「发布后又改过」。 */
export const subtreeCommit = (repo, skill) => {
const relative = path.relative(repo, skill.dir) || '.';
return git(repo, ['log', '-1', '--format=%H', '--', relative]).out;
};
function outletStatus(record, declaration, commit) {
if (!record) return 'unpublished';
if (record.commit && commit && record.commit !== commit) return 'changed';
if (record.version !== declaration.version) return 'version-changed';
return 'published';
}
export function listReleases(repo = repoRoot()) {
const state = readState(repo);
return loadSkills(repo)
.filter(skill => releaseDeclaration(skill))
.map(skill => {
const declaration = releaseDeclaration(skill);
const record = state[skill.name] ?? {};
const commit = subtreeCommit(repo, skill);
return {
name: skill.name,
version: declaration.version,
cli: {
repo: declaration.cli[0] ?? '',
mirrors: declaration.cli.slice(1),
status: outletStatus(record.cli, declaration, commit),
published_at: record.cli?.at ?? null,
published_version: record.cli?.version ?? null,
},
package: {
repo: declaration.packageRepo,
status: outletStatus(record.package, declaration, commit),
published_at: record.package?.at ?? null,
published_version: record.package?.version ?? null,
},
};
});
}
function cliReadme(skill, declaration) {
const name = skill.name;
return `# ${path.basename(declaration.cli[0].replace(/\.git$/, ''))}
${skill.declared?.summary ?? name}
由 Pi 技能 \`${name}\` 发布(同一个源还有技能包出口),当前版本 v${declaration.version}。
命令名是 \`${name}\`:仓库名带 \`-cli\`,命令不带。
## 安装
\`\`\`sh
./scripts/install-cli.sh # 链接到 ~/.local/bin/${name} 并检查 node/mpv/python3
${name} check # 自检:依赖 / 登录
${name} login # 未登录时(手机扫码)
${name} help # 完整命令面
\`\`\`
消费方契约(JSON 字段 / 环境变量 / 状态目录 / 退出码)见 [REFERENCE.md](REFERENCE.md)。
## 许可
见仓库内的许可声明(与 Pi 技能 \`${name}\` 相同)。
`;
}
function packageReadme(skill, declaration) {
const name = skill.name;
const url = declaration.packageRepo;
const cliNote = declaration.cli.length
? `;同一个源还有 CLI 出口 \`${path.basename(declaration.cli[0].replace(/\.git$/, ''))}\``
: '';
return `# ${path.basename(url.replace(/\.git$/, ''))}
${skill.declared?.summary ?? name}
以 Pi package(技能形态)发布${cliNote}。
技能本体在 \`skills/${name}/\`,用法见其 \`SKILL.md\` 与 \`REFERENCE.md\`。
## 安装
\`\`\`sh
pi install git:${url.replace(/^https?:\/\//, '')}@v${declaration.version}
\`\`\`
装完 \`pi-skill list\` 能看到 \`${name}\`,\`pi-skill ${name} check\` 会告诉还缺什么。
`;
}
const packageJsonOf = (skill, declaration) => ({
name: skill.name,
version: declaration.version,
description: skill.declared?.summary ?? '',
});
/** 领域文件:打包白名单 + 技能自己的 tests/(tests 也过一遍泄漏扫描)。 */
const extraFiles = skill => (fs.existsSync(path.join(skill.dir, 'tests')) ? ['tests'] : []);
/**
* 发布 SKILL 的一个出口(outlet: 'cli' | 'package')。
* 返回 { ok, blocked[], outlet, remotes, version }。
*/
export function releaseOutlet(skill, { repo = repoRoot(), outlet = 'cli', dryRun = false } = {}) {
const declaration = releaseDeclaration(skill);
if (!declaration) throw new Error(`技能 ${skill.name} 没有 publish 声明`);
if (outlet === 'cli' && !declaration.cli.length) throw new Error(`技能 ${skill.name} 只声明了包仓(package_repo),没有 CLI 仓可发`);
if (outlet === 'package' && !declaration.packageRepo) throw new Error(`技能 ${skill.name} 没有包仓(publish.package_repo 或由 CLI 仓去 -cli 推导)`);
if (!declaration.version) throw new Error(`技能 ${skill.name} 缺少 VERSION(发布用它打 tag)`);
const tag = `v${declaration.version}`;
// 发布的是可复现状态:技能目录有未提交改动先拦住(否则记账的提交对不上发布内容)。
const relative = path.relative(repo, skill.dir) || '.';
const dirty = git(repo, ['status', '--porcelain', '--', relative]).out;
if (dirty) {
return { ok: false, blocked: [`技能目录有未提交改动:先提交再发布(${dirty.split('\n').length} 个文件)`], remotes: [], outlet, version: declaration.version };
}
const extra = outlet === 'cli' ? extraFiles(skill) : [];
const leaks = scanLeaks(skill.dir, extra).filter(leak => leak.level === 'error');
if (leaks.length) {
return { ok: false, blocked: [`tests/ 泄漏扫描 error:${leaks.map(leak => `${leak.file}:${leak.line}`).join(', ')}`], remotes: [], outlet, version: declaration.version };
}
const packOut = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-release-pack-'));
const packed = packSkill(skill, { outDir: packOut });
if (!packed.ok) return { ok: false, blocked: packed.blocked, remotes: [], outlet, version: declaration.version };
// 组装载荷目录(生成的 README/package.json 也在里面,整体覆盖镜像仓)。
const payload = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-release-payload-'));
if (outlet === 'cli') {
// CLI 仓 = 纯工具:打包内容去掉 Pi 元数据,再加 tests 与 README。
fs.cpSync(packed.dir, payload, { recursive: true });
for (const file of PI_METADATA) fs.rmSync(path.join(payload, file), { force: true });
for (const name of extra) fs.cpSync(path.join(skill.dir, name), path.join(payload, name), { recursive: true });
fs.writeFileSync(path.join(payload, 'README.md'), cliReadme(skill, declaration));
} else {
// 包仓 = 技能形态:技能本体进 skills/<技能>/(Pi 按约定目录发现),根上只有
// package.json / README / MANIFEST(溯源),不把工具文件摊在根目录。
const skillDir = path.join(payload, 'skills', skill.name);
fs.mkdirSync(path.dirname(skillDir), { recursive: true });
fs.cpSync(packed.dir, skillDir, { recursive: true });
fs.rmSync(path.join(skillDir, 'MANIFEST.json'), { force: true });
fs.cpSync(path.join(packed.dir, 'MANIFEST.json'), path.join(payload, 'MANIFEST.json'));
fs.writeFileSync(path.join(payload, 'package.json'), `${JSON.stringify(packageJsonOf(skill, declaration), null, 2)}\n`);
fs.writeFileSync(path.join(payload, 'README.md'), packageReadme(skill, declaration));
}
const remotes = outlet === 'cli' ? declaration.cli : [declaration.packageRepo];
const results = [];
for (const remote of remotes) {
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-release-work-'));
const cloned = git(work, ['clone', '--quiet', remote, work]);
const result = { url: remote, ok: false, message: '' };
if (!cloned.ok) result.message = '仓库不存在或无法访问:先在 gitea 建同名空仓(或检查凭据/网络)';
else {
for (const entry of fs.readdirSync(work)) {
if (entry !== '.git') fs.rmSync(path.join(work, entry), { recursive: true, force: true });
}
fs.cpSync(payload, work, { recursive: true });
git(work, ['add', '-A']);
// MANIFEST 带打包时刻,属易变元数据:只有真实内容变了才算「新版本内容」。
const changed = git(work, ['status', '--porcelain']).out
.split('\n')
.filter(line => line && !line.endsWith('MANIFEST.json'))
.join('\n');
const hasTag = git(work, ['rev-parse', '-q', '--verify', `refs/tags/${tag}`]).ok;
if (changed && hasTag) {
result.message = `${tag} 已发布过不同内容:抬 VERSION 再发(版本号已发布就不再改写)`;
} else if (dryRun) {
result.ok = true;
result.message = `dry-run:将推送 ${changed ? '新提交' : '无变更'} + tag ${tag}`;
} else {
if (changed) git(work, ['commit', '-q', '-m', `release ${tag}`]);
if (!hasTag) git(work, ['tag', tag]);
const pushHead = git(work, ['push', '--quiet', 'origin', 'HEAD:main']);
const pushTag = pushHead.ok && (hasTag || git(work, ['push', '--quiet', 'origin', `refs/tags/${tag}`]).ok);
result.ok = pushHead.ok && pushTag;
result.message = result.ok ? '已推送' : (pushHead.err || '推送失败').split('\n')[0];
}
if (!dryRun && result.ok) result.commit = git(work, ['rev-parse', 'HEAD']).out;
}
fs.rmSync(work, { recursive: true, force: true });
results.push(result);
}
fs.rmSync(payload, { recursive: true, force: true });
fs.rmSync(packOut, { recursive: true, force: true });
const ok = results[0]?.ok ?? false;
if (!dryRun && ok) {
const state = readState(repo);
const record = state[skill.name] ?? {};
// 每个出口各记自己的发布提交:先发 CLI、改完再发包,两条状态互不掩盖。
record[outlet] = {
repo: results[0].url,
mirrors: outlet === 'cli' ? declaration.cli.slice(1) : [],
version: declaration.version,
commit: subtreeCommit(repo, skill),
mirror_commit: results[0].commit ?? null,
at: new Date().toISOString(),
};
state[skill.name] = record;
writeState(repo, state);
}
return { ok, blocked: ok ? [] : results.map(item => `${item.url}:${item.message}`), outlet, remotes: results, version: declaration.version };
}
// ---- CLI ----
const USAGE = `skill-release — 发布技能的两个出口(同一个源,同一道门禁)
skill-release cli <技能> [--dry-run] [--json] 发布 CLI 仓 <工具>-cli(给所有人)
skill-release package <技能> [--dry-run] [--json] 发布 Pi package 仓 <工具>(pi install 一条命令装)
skill-release list [--json] 清点两个出口的发布状态
声明:interface.json 的 \`publish\`(repo / mirrors? / package_repo?);仓名约定 <工具>-cli 与 <工具>。
门禁:skill-pack(白名单/泄漏/skillcheck+契约)+ 技能目录必须已提交;版本号已发布就不再改写。
退出码:0 成功;3 门禁被拦或全部推送失败;2 调用错误。`;
const isMain = (() => {
try {
return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
})();
if (isMain) {
const args = process.argv.slice(2);
const json = args.includes('--json');
const dryRun = args.includes('--dry-run');
const positional = args.filter(arg => !arg.startsWith('-'));
const [target, name] = positional;
const repo = repoRoot();
const usageExit = message => {
console.error(`error=usage ${message}`);
console.error('hint=skill-release help');
process.exit(EXIT_USAGE);
};
if (!target || target === 'help' || target === '--help' || target === '-h') {
console.log(USAGE);
process.exit(EXIT_OK);
}
if (target === 'list') {
const rows = listReleases(repo);
if (json) console.log(JSON.stringify({ skills: rows }, null, 2));
else if (!rows.length) console.log('没有技能声明 publish(interface.json 的 publish.repo)');
else
for (const row of rows) {
console.log(`${row.name} v${row.version}`);
console.log(` cli ${row.cli.status.padEnd(15)} ${row.cli.repo}${row.cli.published_at ? ` (上次 ${row.cli.published_at.slice(0, 16)})` : ''}`);
console.log(` package ${row.package.status.padEnd(15)} ${row.package.repo}`);
}
process.exit(EXIT_OK);
}
if (target !== 'cli' && target !== 'package') usageExit(`第一个参数必须是 cli | package | list,收到「${target}」`);
if (!name) usageExit(`skill-release ${target} 需要技能名`);
const skill = loadSkills(repo).find(item => item.name === name);
if (!skill) usageExit(`没有技能「${name}」(skill-release list 看可发布的技能)`);
if (!skill.declared?.publish) usageExit(`技能「${name}」没有声明 publish.repo(在 interface.json 里加)`);
try {
const result = releaseOutlet(skill, { repo, outlet: target, dryRun });
if (json) console.log(JSON.stringify(result, null, 2));
else {
console.log(`release ${target} ${skill.name} v${result.version}${dryRun ? '(dry-run)' : ''}`);
for (const remote of result.remotes) console.log(` ${remote.ok ? 'ok ' : 'fail'} ${remote.url} ${remote.message}`);
if (!result.ok) for (const line of result.blocked) console.log(` blocked: ${line}`);
}
process.exit(result.ok ? EXIT_OK : EXIT_BLOCKED);
} catch (problem) {
console.error(`error=internal ${problem.message}`);
console.error(`hint=skill-release list;门禁细节跑 skill-pack ${name} --dry-run --json`);
process.exit(EXIT_BLOCKED);
}
}