skill-interface/skills/skill-interface/scripts/skill-pack.mjs
2026-10-02 09:26:45 +08:00

245 lines
14 KiB
JavaScript
Executable File
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
/**
* 可分发契约:把单个技能打成「别人拿到就能用」的包(库 + CLI)。
*
* 白名单打包(只装技能本体:SKILL.md / REFERENCE.md / interface.json / contract.lock.json /
* CHANGELOG.md / scripts 等),绝不带本机数据(local 配置、state、日志、缓存、.env)。
* 打包前做发布门禁:skillcheck 无 error、契约基线不漂移、泄漏扫描无 error 级发现。
* 产出:<out>/<技能>-<版本>/ + MANIFEST.json(版本/平台/命令/依赖/配置 schema/文件哈希)+ tar.gz。
*
* 用法:
* skill-pack <技能> [--out <目录>] [--json] [--force] [--dry-run]
*
* 退出码:0 已打包;3 有阻断(泄漏/门禁不过,加 --force 也只跳过泄漏之外的阻断);2 调用错误。
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import crypto from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { contractStatus } from './skill-contract.mjs';
export const MANIFEST_VERSION = 1;
/** 只装这些(技能是「流程 + 脚本」,不是数据)。 */
const WHITELIST_FILES = ['SKILL.md', 'REFERENCE.md', 'interface.json', 'contract.lock.json', 'CHANGELOG.md', 'LICENSE', 'LICENSE.md', 'VERSION'];
const WHITELIST_DIRS = ['scripts', 'assets', 'templates', 'references'];
/** 目录内也绝不装这些(本机数据/缓存/密钥)。 */
const DENY = [/^\./, /^node_modules$/, /^__pycache__$/, /\.pyc$/i, /\.log$/i, /\.jsonl$/i, /^config\.json$/i, /^state\.json$/i, /\.local\.json$/i, /^\.env/i, /\.bak$/i, /\.tmp$/i, /^\.DS_Store$/, /\.sqlite$/i];
/** 键名列表之类的“假家目录”(如 up/down/home/end)不算泄漏。 */
const HOME_STOPWORDS = new Set(['end', 'home', 'space', 'down', 'left', 'right', 'up', 'del', 'enter', 'esc', 'tab', 'user', 'usr', 'you', 'me', 'name', 'yourname', 'username', 'xxx']);
const benignHome = capture => /[<>{}*$]/.test(capture) || HOME_STOPWORDS.has(String(capture).toLowerCase());
/** 泄漏扫描:error 阻断打包,warn 只提示。 */
const LEAK_PATTERNS = [
{ level: 'error', name: 'secret', re: /\b(sk-[A-Za-z0-9]{16,}|ghp_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{16,}|AKIA[0-9A-Z]{12,})\b/ },
{ level: 'error', name: 'private-key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
{ level: 'error', name: 'password-literal', re: /\b(password|passwd|secret|token|api[_-]?key)\s*[:=]\s*["'][^"'{}$][^"']{7,}["']/i },
{ level: 'error', name: 'home-path', re: /\/(?:Users|home)\/([A-Za-z0-9._-]{2,})\//, allow: benignHome },
{ level: 'error', name: 'windows-home-path', re: /[A-Za-z]:\\Users\\([^\\\s"']+)\\/, allow: benignHome },
{ level: 'error', name: 'dmp-ticket', re: /\b[A-Z]{2,}[A-Z0-9]*_[A-Z0-9]+_\d{8}_\d{4}\b/, allow: capture => /^(DEMO|EXAMPLE|SAMPLE|TEST|FAKE|XXX)/.test(String(capture)) },
{ level: 'warn', name: 'email', re: /\b[\w.+-]+@[\w-]+\.[A-Za-z]{2,}\b/ },
{ level: 'warn', name: 'internal-domain', re: /\.(internal|corp|intra)\b/i },
];
const SKIP_DIRS = new Set(['node_modules', '__pycache__', '.git']);
function listFiles(dir, base = dir) {
const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === '.DS_Store') continue;
const full = path.join(dir, entry.name);
const rel = path.relative(base, full);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name) || DENY.some(pattern => pattern.test(entry.name))) continue;
out.push(...listFiles(full, base));
continue;
}
if (DENY.some(pattern => pattern.test(entry.name))) continue;
out.push(rel);
}
return out.sort();
}
/** 包内文件清单:白名单顶层文件 + 白名单目录(排除数据/缓存)。 */
export function packageFiles(skillDir) {
const files = WHITELIST_FILES.filter(name => fs.existsSync(path.join(skillDir, name)));
for (const dir of WHITELIST_DIRS) {
const full = path.join(skillDir, dir);
if (!fs.existsSync(full)) continue;
for (const rel of listFiles(full, skillDir)) files.push(rel);
}
return [...new Set(files)].sort();
}
function scanText(text, rel) {
const findings = [];
const lines = text.split('\n');
for (const [index, line] of lines.entries()) {
for (const pattern of LEAK_PATTERNS) {
for (const match of line.matchAll(new RegExp(pattern.re.source, pattern.re.flags.includes('g') ? pattern.re.flags : `${pattern.re.flags}g`))) {
// allow(capture):有些命中是占位符/示例(C:\Users\<你>、DEMO_FUNC_…、键名表里的 home/end),不算泄漏
const capture = match[1] ?? match[0];
if (pattern.allow && pattern.allow(capture, match)) continue;
findings.push({ level: pattern.level, kind: pattern.name, file: rel, line: index + 1, excerpt: match[0].slice(0, 60) });
}
}
}
return findings;
}
export function scanLeaks(skillDir, files) {
const findings = [];
for (const rel of files) {
if (!/\.(md|mjs|js|cjs|ts|py|sh|zsh|ps1|cmd|bat|json|txt|ya?ml)$/i.test(rel)) continue;
let text = '';
try { text = fs.readFileSync(path.join(skillDir, rel), 'utf8'); } catch { continue; }
findings.push(...scanText(text, rel));
}
return findings;
}
/** 跨技能相对 import(打包后必须一起带上,或换成 pi-skill 调用)。 */
export function externalImports(skillDir, files) {
const found = [];
for (const rel of files) {
if (!/\.(mjs|js|cjs|ts)$/i.test(rel)) continue;
let text = '';
try { text = fs.readFileSync(path.join(skillDir, rel), 'utf8'); } catch { continue; }
for (const match of text.matchAll(/(?:from\s+|require\(\s*|import\s+\(?\s*)['"](\.[^'"]+)['"]/g)) {
const target = path.resolve(path.dirname(path.join(skillDir, rel)), match[1]);
if (!target.startsWith(skillDir + path.sep)) found.push({ from: rel, import: match[1], target: path.relative(skillDir, target) });
}
}
return found;
}
const sha256 = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex').slice(0, 16);
function git(repo, args) {
const result = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
return result.status === 0 ? result.stdout.trim() : '';
}
export function packSkill(skill, { outDir, force = false, dryRun = false } = {}) {
const skillDir = skill.dir;
const files = packageFiles(skillDir);
const leaks = scanLeaks(skillDir, files);
const imports = externalImports(skillDir, files);
const repo = skill.repo ?? path.resolve(skillDir, '../..');
const describe = git(repo, ['describe', '--tags', '--always']) || 'dev';
const commit = git(repo, ['rev-parse', '--short', 'HEAD']) || 'nogit';
const dirty = Boolean(git(repo, ['status', '--porcelain', '--', skillDir]));
const short = describe.replace(/^v/, '');
const tagged = !/^[0-9a-f]{7,}$/.test(describe);
const version = `${short}${tagged ? `+${commit}` : ''}${dirty ? '.dirty' : ''}`;
const contract = contractStatus(skill);
const blockers = [];
const errors = leaks.filter(leak => leak.level === 'error');
if (errors.length && !force) blockers.push(`泄漏扫描发现 ${errors.length} 处 error 级内容(密钥/家目录/真实工单号)`);
if (contract.state === 'drift') blockers.push('契约基线漂移:先 skill-contract diff 处理并更新基线');
if (skill.errors?.length) blockers.push(`interface.json 有 ${skill.errors.length} 个结构错误:skillcheck ${skill.name}`);
const manifest = {
manifest_version: MANIFEST_VERSION,
name: skill.name,
summary: skill.declared?.summary ?? '',
tier: skill.declared?.tier ?? 0,
status: skill.declared?.status ?? 'stable',
version,
source: { path: `skills/${skill.name}`, commit, describe, dirty, packed_at: new Date().toISOString() },
platforms: skill.platforms ?? [],
entry: skill.declared?.entry ?? null,
commands: (skill.declared?.commands ?? []).map(command => command.name),
errors: skill.declared?.errors ?? [],
aliases: skill.declared?.aliases ?? [],
admin: (skill.declared?.admin ?? []).map(item => item.path),
depends_on: skill.declared?.dependsOn ?? [],
requires: skill.declared?.requires ?? [],
config: skill.declared?.config ?? [],
external_imports: imports,
contract: contract.current,
files: files.map(rel => ({ path: rel, bytes: fs.statSync(path.join(skillDir, rel)).size, sha256: sha256(path.join(skillDir, rel)) })),
leak_scan: { errors: errors.length, warnings: leaks.length - errors.length, findings: leaks },
};
if (blockers.length || dryRun) return { ok: !blockers.length, blocked: blockers, manifest, leaks, imports, files, version };
const destination = path.join(outDir, `${skill.name}-${version}`);
fs.rmSync(destination, { recursive: true, force: true });
fs.mkdirSync(destination, { recursive: true });
for (const rel of files) {
const target = path.join(destination, rel);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(path.join(skillDir, rel), target);
}
fs.writeFileSync(path.join(destination, 'MANIFEST.json'), `${JSON.stringify(manifest, null, 2)}\n`);
let tarball = '';
// 包内顶层目录用技能名(不是 <技能>-<版本>):对方解包后直接是 skills/<技能>/,不用改名。
// 不用 tar 的改名选项(macOS 是 bsdtar、GNU tar 与 bsdtar 语法不同):先搭一个临时 staging 目录再打包。
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'skill-pack-'));
try {
fs.cpSync(destination, path.join(staging, skill.name), { recursive: true });
const tar = spawnSync('tar', ['-czf', `${destination}.tar.gz`, '-C', staging, skill.name], { encoding: 'utf8' });
if (tar.status === 0) tarball = `${destination}.tar.gz`;
} finally {
fs.rmSync(staging, { recursive: true, force: true });
}
return { ok: true, blocked: [], dir: destination, tarball, manifest, leaks, imports, files, version, dependsOn: skill.declared?.dependsOn ?? [] };
}
// ---- CLI ----
const isMain = (function () {{ try {{ return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }} catch {{ return false; }} }})(); // realpath:macOS 的 /tmp、/var 是符号链接,直接比字符串会让入口静默不执行(退出码 0)
/** 把打包结果格式化成给人看的几行(CLI 与 pi-skill pack 共用,避免两份输出漂移)。 */
export function packReport(result, skill) {
const errors = (result.leaks ?? []).filter(leak => leak.level === 'error');
const warnings = (result.leaks ?? []).filter(leak => leak.level === 'warn');
const bytes = result.files.reduce((sum, rel) => sum + fs.statSync(path.join(skill.dir, rel)).size, 0);
const head = result.ok && result.dir
? `pack ${skill.name} — 已打包 ${result.files.length} 个文件(${(bytes / 1024).toFixed(1)} KB),版本 ${result.version}`
: `pack ${skill.name} — ${result.dir ? '已打包' : result.blocked?.length ? '未打包' : '预览(未写出)'}:${result.files.length} 个文件,版本 ${result.version}`;
const lines = [head];
for (const leak of [...errors, ...warnings]) lines.push(`${leak.level === 'error' ? 'LEAK ' : 'warn '} ${leak.kind} ${leak.file}:${leak.line} ${leak.excerpt}`);
for (const blocker of result.blocked ?? []) lines.push(`blocked: ${blocker}`);
if (result.imports?.length) lines.push(`跨技能 import:${result.imports.map(item => item.target).join('、')}(对方也需要这些技能或一起打包)`);
const deps = (result.dependsOn ?? []).map(item => (typeof item === 'string' ? item : item.skill)).filter(Boolean);
if (deps.length) lines.push(`依赖技能:${deps.join('、')}(要一起给:pi-skill pack ${deps.join(' . pi-skill pack '.replace(' . ', ';'))})`);
if (result.ok && result.dir) {
lines.push(`dir=${result.dir}`);
if (result.tarball) lines.push(`tarball=${result.tarball}`);
lines.push(deps.length
? `hint=对方把${deps.length ? '这两个(技能 + 依赖)' : ''}目录都放进 <shared>/skills/ 后跑 pi-skill doctor;缺配置它会自己说要什么`
: 'hint=对方把目录放进 <shared>/skills/ 后跑 pi-skill list 与 pi-skill doctor;缺什么它会自己说');
} else if (result.blocked?.length) lines.push('hint=按上面逐条处理(泄漏项请先删掉再打;确认是误报才用 --force)');
lines.push(`字段: skill=${skill.name} files=${result.files.length} version=${result.version} leaks=${errors.length} warnings=${warnings.length} blocked=${(result.blocked ?? []).length}${result.dir ? ` dir=${result.dir}` : ''}`);
return lines;
}
export function packUsage() {
return `skill-pack — 把单个技能打成可分发产物(白名单 + 泄漏扫描 + 发布门禁 + MANIFEST)\n\nskill-pack <技能> [--out <目录>] [--json] [--force] [--dry-run]\n\n默认输出到 ~/.pi/agent/local/pack/<技能>-<版本>/(附 .tar.gz);包内只含技能本体,不含本机配置/日志/缓存。\n泄漏扫描:密钥字面量、绝对家目录、真实工单号(error 阻断);邮箱、内网域名(warn)。\n退出码:0 已打包;3 被阻断;2 调用错误。`;
}
if (isMain) {
process.env.PI_SKILL_LOG = '0';
const { loadSkills, repoRoot } = await import('./skill-registry.mjs');
const { agentDirOf } = await import('./skill-config.mjs');
const args = process.argv.slice(2);
if (!args.length || args.includes('help') || args.includes('-h')) { console.log(packUsage()); process.exit(args.length ? 0 : 2); }
const unknown = args.filter(value => value.startsWith('-') && !['--json', '--force', '--dry-run', '--out'].includes(value));
if (unknown.length) { console.error(`error=usage 未知参数 ${unknown.join(' ')}\nhint=skill-pack help`); process.exit(2); }
const target = args.find(value => !value.startsWith('-'));
const outIndex = args.indexOf('--out');
const outDir = outIndex >= 0 ? path.resolve(args[outIndex + 1] ?? '') : path.join(agentDirOf(), 'local', 'pack');
const skill = loadSkills(repoRoot()).find(candidate => candidate.name === target || (candidate.declared?.aliases ?? []).includes(target));
if (!skill) { console.error(`error=notfound 没有技能「${target}」\nhint=pi-skill list`); process.exit(2); }
const result = packSkill(skill, { outDir, force: args.includes('--force'), dryRun: args.includes('--dry-run') });
if (args.includes('--json')) console.log(JSON.stringify(result, null, 2));
else console.log(packReport(result, skill).join('\n'));
process.exit(result.ok ? 0 : 3);
}