363 lines
16 KiB
JavaScript
363 lines
16 KiB
JavaScript
#!/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);
|
||
}
|
||
}
|