245 lines
14 KiB
JavaScript
Executable File
245 lines
14 KiB
JavaScript
Executable File
#!/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);
|
||
}
|