@second196/skillhub-cli 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/clients/api-client.d.ts +3 -0
- package/dist/clients/api-client.js +29 -0
- package/dist/commands/install.d.ts +8 -0
- package/dist/commands/install.js +19 -0
- package/dist/commands/list.d.ts +6 -0
- package/dist/commands/list.js +8 -0
- package/dist/commands/upload.d.ts +7 -0
- package/dist/commands/upload.js +11 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +35 -0
- package/dist/platform/archive.d.ts +4 -0
- package/dist/platform/archive.js +193 -0
- package/dist/platform/paths.d.ts +2 -0
- package/dist/platform/paths.js +41 -0
- package/dist/services/skill-package-service.d.ts +2 -0
- package/dist/services/skill-package-service.js +192 -0
- package/dist/shared/constants.d.ts +9 -0
- package/dist/shared/constants.js +9 -0
- package/dist/shared/errors.d.ts +9 -0
- package/dist/shared/errors.js +19 -0
- package/dist/shared/output.d.ts +1 -0
- package/dist/shared/output.js +9 -0
- package/dist/shared/types.d.ts +32 -0
- package/dist/shared/types.js +7 -0
- package/package.json +30 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { CliError } from '../shared/errors.js';
|
|
2
|
+
import { EXIT_CODE } from '../shared/constants.js';
|
|
3
|
+
export function serviceUrl(value) { return value.replace(/\/+$/, ''); }
|
|
4
|
+
export async function apiRequest(base, path, init) {
|
|
5
|
+
let response;
|
|
6
|
+
try {
|
|
7
|
+
response = await fetch(`${serviceUrl(base)}${path}`, init);
|
|
8
|
+
}
|
|
9
|
+
catch (_error) {
|
|
10
|
+
throw new CliError('无法连接 Skill Hub 服务', 'SERVICE_UNREACHABLE', EXIT_CODE.network);
|
|
11
|
+
}
|
|
12
|
+
if (!response.ok) {
|
|
13
|
+
const body = await response.json().catch(() => ({}));
|
|
14
|
+
throw new CliError(body.message || `服务返回 ${response.status}`, `HTTP_${response.status}`, response.status >= 500 ? EXIT_CODE.network : EXIT_CODE.generic);
|
|
15
|
+
}
|
|
16
|
+
return await response.json();
|
|
17
|
+
}
|
|
18
|
+
export async function download(base, path) {
|
|
19
|
+
let response;
|
|
20
|
+
try {
|
|
21
|
+
response = await fetch(`${serviceUrl(base)}${path}`);
|
|
22
|
+
}
|
|
23
|
+
catch (_error) {
|
|
24
|
+
throw new CliError('无法连接 Skill Hub 服务', 'SERVICE_UNREACHABLE', EXIT_CODE.network);
|
|
25
|
+
}
|
|
26
|
+
if (!response.ok)
|
|
27
|
+
throw new CliError(`下载失败(${response.status})`, `HTTP_${response.status}`, response.status >= 500 ? EXIT_CODE.network : EXIT_CODE.generic);
|
|
28
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
29
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { unzipSync } from 'fflate';
|
|
4
|
+
import { download } from '../clients/api-client.js';
|
|
5
|
+
export async function installCommand(options) {
|
|
6
|
+
const query = options.version ? `?version=${encodeURIComponent(options.version)}` : '';
|
|
7
|
+
const archive = await download(options.serviceUrl, `/api/skills/${encodeURIComponent(options.slug)}/download${query}`);
|
|
8
|
+
const files = unzipSync(archive);
|
|
9
|
+
const root = resolve(options.target, options.slug);
|
|
10
|
+
await mkdir(root, { recursive: true });
|
|
11
|
+
for (const [path, content] of Object.entries(files)) {
|
|
12
|
+
const output = resolve(root, path);
|
|
13
|
+
if (!output.startsWith(root + '/') && output !== root)
|
|
14
|
+
throw new Error('安装包路径不安全');
|
|
15
|
+
await mkdir(dirname(output), { recursive: true });
|
|
16
|
+
await writeFile(output, content);
|
|
17
|
+
}
|
|
18
|
+
return options.json ? JSON.stringify({ ok: true, slug: options.slug, target: root, fileCount: Object.keys(files).length }) : `安装成功:${options.slug}\n目录:${root}\n文件:${Object.keys(files).length} 个`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { apiRequest } from '../clients/api-client.js';
|
|
2
|
+
export async function listCommand(options) {
|
|
3
|
+
const params = new URLSearchParams({ includeOffline: 'true' });
|
|
4
|
+
if (options.category)
|
|
5
|
+
params.set('category', options.category);
|
|
6
|
+
const items = await apiRequest(options.serviceUrl, `/api/skills?${params}`);
|
|
7
|
+
return options.json ? JSON.stringify(items) : (items.length ? items.map(item => `${item.name} ${item.slug} [${item.category}] v${item.version_label} ${item.status === 'ACTIVE' ? '已上架' : '已下架'}`).join('\n') : '暂无技能');
|
|
8
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { basename } from 'node:path';
|
|
2
|
+
import { prepareSkillPackage } from '../services/skill-package-service.js';
|
|
3
|
+
import { apiRequest } from '../clients/api-client.js';
|
|
4
|
+
export async function uploadCommand(options) {
|
|
5
|
+
const prepared = await prepareSkillPackage(options.inputPath);
|
|
6
|
+
const form = new FormData();
|
|
7
|
+
form.append('file', new Blob([prepared.archive]), `${basename(options.inputPath)}.zip`);
|
|
8
|
+
form.append('category', options.category);
|
|
9
|
+
const result = await apiRequest(options.serviceUrl, '/api/skills', { method: 'POST', body: form });
|
|
10
|
+
return options.json ? JSON.stringify({ ok: true, ...result }) : `上传成功:${String(result.name)}\n标识:${String(result.slug)}\n版本:${String(result.version_label)}`;
|
|
11
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cac } from 'cac';
|
|
3
|
+
import { uploadCommand } from './commands/upload.js';
|
|
4
|
+
import { listCommand } from './commands/list.js';
|
|
5
|
+
import { installCommand } from './commands/install.js';
|
|
6
|
+
import { formatError } from './shared/output.js';
|
|
7
|
+
const cli = cac('skillhub');
|
|
8
|
+
cli.command('upload <input-path>', '上传 ZIP、目录或单个 SKILL.md')
|
|
9
|
+
.option('--service-url <url>', 'Skill Hub 服务地址', { default: 'http://127.0.0.1:8080' })
|
|
10
|
+
.option('--category <category>', '技能分类', { default: '其他' })
|
|
11
|
+
.option('--json', '输出 JSON')
|
|
12
|
+
.action(async (inputPath, options) => run(() => uploadCommand({ inputPath, serviceUrl: options.serviceUrl, category: options.category, json: Boolean(options.json) }), Boolean(options.json)));
|
|
13
|
+
cli.command('list', '查询平台中的全部技能')
|
|
14
|
+
.option('--service-url <url>', 'Skill Hub 服务地址', { default: 'http://127.0.0.1:8080' })
|
|
15
|
+
.option('--category <category>', '按分类筛选')
|
|
16
|
+
.option('--json', '输出 JSON')
|
|
17
|
+
.action(async (options) => run(() => listCommand({ serviceUrl: options.serviceUrl, category: options.category, json: Boolean(options.json) }), Boolean(options.json)));
|
|
18
|
+
cli.command('install <slug>', '下载并安装技能到本地目录')
|
|
19
|
+
.option('--service-url <url>', 'Skill Hub 服务地址', { default: 'http://127.0.0.1:8080' })
|
|
20
|
+
.option('--target <directory>', '安装目录', { default: '.skills' })
|
|
21
|
+
.option('--version <digest>', '指定版本摘要')
|
|
22
|
+
.option('--json', '输出 JSON')
|
|
23
|
+
.action(async (slug, options) => run(() => installCommand({ slug, serviceUrl: options.serviceUrl, target: options.target, version: options.version, json: Boolean(options.json) }), Boolean(options.json)));
|
|
24
|
+
cli.help();
|
|
25
|
+
cli.version('0.1.2');
|
|
26
|
+
cli.parse();
|
|
27
|
+
async function run(action, json) {
|
|
28
|
+
try {
|
|
29
|
+
process.stdout.write(`${await action()}\n`);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
process.stderr.write(`${formatError(error, json)}\n`);
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type PackageFile, type PackageLimitOverrides, type PackageLimits } from '../shared/types.js';
|
|
2
|
+
export declare function createArchive(files: PackageFile[], overrides?: PackageLimitOverrides): Uint8Array;
|
|
3
|
+
export declare function readArchive(archive: Uint8Array, overrides?: PackageLimitOverrides): PackageFile[];
|
|
4
|
+
export declare function resolvePackageLimits(overrides?: PackageLimitOverrides): PackageLimits;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { unzipSync, zipSync } from 'fflate';
|
|
2
|
+
import { normalizePackagePath } from './paths.js';
|
|
3
|
+
import { PackageValidationError } from '../shared/errors.js';
|
|
4
|
+
import { DEFAULT_PACKAGE_LIMITS } from '../shared/types.js';
|
|
5
|
+
const END_OF_CENTRAL_DIRECTORY = 0x06054b50;
|
|
6
|
+
const CENTRAL_DIRECTORY_ENTRY = 0x02014b50;
|
|
7
|
+
const LOCAL_FILE_HEADER = 0x04034b50;
|
|
8
|
+
const ZIP64_16 = 0xffff;
|
|
9
|
+
const ZIP64_32 = 0xffffffff;
|
|
10
|
+
const FIXED_ZIP_TIME = new Date(1980, 0, 1, 0, 0, 0);
|
|
11
|
+
export function createArchive(files, overrides = {}) {
|
|
12
|
+
const limits = resolvePackageLimits(overrides);
|
|
13
|
+
validateFiles(files, limits);
|
|
14
|
+
const entries = {};
|
|
15
|
+
for (const file of [...files].sort((left, right) => compareUtf8(left.path, right.path))) {
|
|
16
|
+
entries[file.path] = [file.content, {
|
|
17
|
+
attrs: 0o100644 << 16,
|
|
18
|
+
mtime: FIXED_ZIP_TIME,
|
|
19
|
+
os: 3
|
|
20
|
+
}];
|
|
21
|
+
}
|
|
22
|
+
const archive = zipSync(entries, { level: 6, mtime: FIXED_ZIP_TIME, os: 3 });
|
|
23
|
+
if (archive.byteLength > limits.maxArchiveBytes) {
|
|
24
|
+
throw new PackageValidationError('Skill 压缩包超过大小限制', 'PACKAGE_ARCHIVE_TOO_LARGE');
|
|
25
|
+
}
|
|
26
|
+
return archive;
|
|
27
|
+
}
|
|
28
|
+
export function readArchive(archive, overrides = {}) {
|
|
29
|
+
const limits = resolvePackageLimits(overrides);
|
|
30
|
+
if (archive.byteLength > limits.maxArchiveBytes) {
|
|
31
|
+
throw new PackageValidationError('Skill 压缩包超过大小限制', 'PACKAGE_ARCHIVE_TOO_LARGE');
|
|
32
|
+
}
|
|
33
|
+
const entries = inspectCentralDirectory(archive, limits);
|
|
34
|
+
let unzipped;
|
|
35
|
+
try {
|
|
36
|
+
unzipped = unzipSync(archive);
|
|
37
|
+
}
|
|
38
|
+
catch (_error) {
|
|
39
|
+
throw new PackageValidationError('Skill ZIP 无法解压', 'INVALID_SKILL_ARCHIVE');
|
|
40
|
+
}
|
|
41
|
+
const files = entries
|
|
42
|
+
.filter((entry) => !entry.directory)
|
|
43
|
+
.map((entry) => {
|
|
44
|
+
const content = unzipped[entry.rawPath];
|
|
45
|
+
if (content === undefined || content.byteLength !== entry.uncompressedSize) {
|
|
46
|
+
throw new PackageValidationError('Skill ZIP 条目大小不一致', 'INVALID_SKILL_ARCHIVE', { path: entry.path });
|
|
47
|
+
}
|
|
48
|
+
return { path: entry.path, content };
|
|
49
|
+
});
|
|
50
|
+
validateFiles(files, limits);
|
|
51
|
+
return files.sort((left, right) => compareUtf8(left.path, right.path));
|
|
52
|
+
}
|
|
53
|
+
export function resolvePackageLimits(overrides = {}) {
|
|
54
|
+
return { ...DEFAULT_PACKAGE_LIMITS, ...overrides };
|
|
55
|
+
}
|
|
56
|
+
function inspectCentralDirectory(archive, limits) {
|
|
57
|
+
const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength);
|
|
58
|
+
const eocdOffset = findEndOfCentralDirectory(view);
|
|
59
|
+
if (eocdOffset < 0)
|
|
60
|
+
invalidArchive();
|
|
61
|
+
const disk = view.getUint16(eocdOffset + 4, true);
|
|
62
|
+
const centralDisk = view.getUint16(eocdOffset + 6, true);
|
|
63
|
+
const entriesOnDisk = view.getUint16(eocdOffset + 8, true);
|
|
64
|
+
const entryCount = view.getUint16(eocdOffset + 10, true);
|
|
65
|
+
const centralSize = view.getUint32(eocdOffset + 12, true);
|
|
66
|
+
const centralOffset = view.getUint32(eocdOffset + 16, true);
|
|
67
|
+
const commentLength = view.getUint16(eocdOffset + 20, true);
|
|
68
|
+
if (entryCount === ZIP64_16 || centralSize === ZIP64_32 || centralOffset === ZIP64_32) {
|
|
69
|
+
throw new PackageValidationError('暂不支持 ZIP64 Skill 包', 'UNSUPPORTED_SKILL_ARCHIVE');
|
|
70
|
+
}
|
|
71
|
+
if (disk !== 0 || centralDisk !== 0 || entriesOnDisk !== entryCount) {
|
|
72
|
+
throw new PackageValidationError('暂不支持分卷 Skill ZIP', 'UNSUPPORTED_SKILL_ARCHIVE');
|
|
73
|
+
}
|
|
74
|
+
if (entryCount > limits.maxFiles + limits.maxFiles) {
|
|
75
|
+
throw new PackageValidationError('Skill 包文件数量超过限制', 'PACKAGE_FILE_COUNT_EXCEEDED');
|
|
76
|
+
}
|
|
77
|
+
if (eocdOffset + 22 + commentLength !== archive.byteLength || centralOffset + centralSize > eocdOffset) {
|
|
78
|
+
invalidArchive();
|
|
79
|
+
}
|
|
80
|
+
const paths = new Set();
|
|
81
|
+
const entries = [];
|
|
82
|
+
let fileCount = 0;
|
|
83
|
+
let expandedBytes = 0;
|
|
84
|
+
let offset = centralOffset;
|
|
85
|
+
for (let index = 0; index < entryCount; index += 1) {
|
|
86
|
+
if (offset + 46 > eocdOffset || view.getUint32(offset, true) !== CENTRAL_DIRECTORY_ENTRY)
|
|
87
|
+
invalidArchive();
|
|
88
|
+
const flags = view.getUint16(offset + 8, true);
|
|
89
|
+
if ((flags & 0x1) !== 0) {
|
|
90
|
+
throw new PackageValidationError('不支持加密 Skill ZIP', 'UNSUPPORTED_SKILL_ARCHIVE');
|
|
91
|
+
}
|
|
92
|
+
const uncompressedSize = view.getUint32(offset + 24, true);
|
|
93
|
+
const pathLength = view.getUint16(offset + 28, true);
|
|
94
|
+
const extraLength = view.getUint16(offset + 30, true);
|
|
95
|
+
const entryCommentLength = view.getUint16(offset + 32, true);
|
|
96
|
+
const externalAttributes = view.getUint32(offset + 38, true);
|
|
97
|
+
const localHeaderOffset = view.getUint32(offset + 42, true);
|
|
98
|
+
if (uncompressedSize === ZIP64_32 || localHeaderOffset === ZIP64_32) {
|
|
99
|
+
throw new PackageValidationError('暂不支持 ZIP64 Skill 包', 'UNSUPPORTED_SKILL_ARCHIVE');
|
|
100
|
+
}
|
|
101
|
+
const pathStart = offset + 46;
|
|
102
|
+
const pathEnd = pathStart + pathLength;
|
|
103
|
+
const nextOffset = pathEnd + extraLength + entryCommentLength;
|
|
104
|
+
if (pathEnd > eocdOffset || nextOffset > eocdOffset)
|
|
105
|
+
invalidArchive();
|
|
106
|
+
const rawPath = decodePath(archive.subarray(pathStart, pathEnd));
|
|
107
|
+
const directory = rawPath.endsWith('/');
|
|
108
|
+
const path = normalizePackagePath(directory ? rawPath.slice(0, -1) : rawPath, limits.maxPathLength);
|
|
109
|
+
if (paths.has(path)) {
|
|
110
|
+
throw new PackageValidationError('Skill 包包含重复路径', 'DUPLICATE_PACKAGE_PATH', { path });
|
|
111
|
+
}
|
|
112
|
+
paths.add(path);
|
|
113
|
+
const unixMode = externalAttributes >>> 16;
|
|
114
|
+
if ((unixMode & 0xf000) === 0xa000) {
|
|
115
|
+
throw new PackageValidationError('Skill 包不允许符号链接', 'SYMLINK_NOT_ALLOWED', { path });
|
|
116
|
+
}
|
|
117
|
+
validateLocalHeader(view, archive, localHeaderOffset, rawPath);
|
|
118
|
+
if (!directory) {
|
|
119
|
+
fileCount += 1;
|
|
120
|
+
expandedBytes += uncompressedSize;
|
|
121
|
+
if (fileCount > limits.maxFiles) {
|
|
122
|
+
throw new PackageValidationError('Skill 包文件数量超过限制', 'PACKAGE_FILE_COUNT_EXCEEDED');
|
|
123
|
+
}
|
|
124
|
+
if (uncompressedSize > limits.maxSingleFileBytes) {
|
|
125
|
+
throw new PackageValidationError('Skill 包内单个文件超过大小限制', 'PACKAGE_FILE_TOO_LARGE', { path });
|
|
126
|
+
}
|
|
127
|
+
if (expandedBytes > limits.maxExpandedBytes) {
|
|
128
|
+
throw new PackageValidationError('Skill 包解压后超过大小限制', 'PACKAGE_EXPANDED_TOO_LARGE');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
entries.push({ rawPath, path, directory, uncompressedSize, localHeaderOffset });
|
|
132
|
+
offset = nextOffset;
|
|
133
|
+
}
|
|
134
|
+
if (offset !== centralOffset + centralSize)
|
|
135
|
+
invalidArchive();
|
|
136
|
+
return entries;
|
|
137
|
+
}
|
|
138
|
+
function validateLocalHeader(view, archive, offset, expectedPath) {
|
|
139
|
+
if (offset + 30 > archive.byteLength || view.getUint32(offset, true) !== LOCAL_FILE_HEADER)
|
|
140
|
+
invalidArchive();
|
|
141
|
+
const pathLength = view.getUint16(offset + 26, true);
|
|
142
|
+
const extraLength = view.getUint16(offset + 28, true);
|
|
143
|
+
const pathStart = offset + 30;
|
|
144
|
+
const pathEnd = pathStart + pathLength;
|
|
145
|
+
if (pathEnd + extraLength > archive.byteLength || decodePath(archive.subarray(pathStart, pathEnd)) !== expectedPath) {
|
|
146
|
+
invalidArchive();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function validateFiles(files, limits) {
|
|
150
|
+
if (files.length > limits.maxFiles) {
|
|
151
|
+
throw new PackageValidationError('Skill 包文件数量超过限制', 'PACKAGE_FILE_COUNT_EXCEEDED');
|
|
152
|
+
}
|
|
153
|
+
const paths = new Set();
|
|
154
|
+
let expandedBytes = 0;
|
|
155
|
+
for (const file of files) {
|
|
156
|
+
const path = normalizePackagePath(file.path, limits.maxPathLength);
|
|
157
|
+
if (path !== file.path || paths.has(path)) {
|
|
158
|
+
throw new PackageValidationError('Skill 包包含重复路径', 'DUPLICATE_PACKAGE_PATH', { path });
|
|
159
|
+
}
|
|
160
|
+
paths.add(path);
|
|
161
|
+
if (file.content.byteLength > limits.maxSingleFileBytes) {
|
|
162
|
+
throw new PackageValidationError('Skill 包内单个文件超过大小限制', 'PACKAGE_FILE_TOO_LARGE', { path });
|
|
163
|
+
}
|
|
164
|
+
expandedBytes += file.content.byteLength;
|
|
165
|
+
if (expandedBytes > limits.maxExpandedBytes) {
|
|
166
|
+
throw new PackageValidationError('Skill 包解压后超过大小限制', 'PACKAGE_EXPANDED_TOO_LARGE');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function decodePath(bytes) {
|
|
171
|
+
try {
|
|
172
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
173
|
+
}
|
|
174
|
+
catch (_error) {
|
|
175
|
+
throw new PackageValidationError('Skill 包路径必须使用 UTF-8', 'INVALID_PACKAGE_PATH_ENCODING');
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function findEndOfCentralDirectory(view) {
|
|
179
|
+
if (view.byteLength < 22)
|
|
180
|
+
return -1;
|
|
181
|
+
const minimumOffset = Math.max(0, view.byteLength - 0xffff - 22);
|
|
182
|
+
for (let offset = view.byteLength - 22; offset >= minimumOffset; offset -= 1) {
|
|
183
|
+
if (view.getUint32(offset, true) === END_OF_CENTRAL_DIRECTORY)
|
|
184
|
+
return offset;
|
|
185
|
+
}
|
|
186
|
+
return -1;
|
|
187
|
+
}
|
|
188
|
+
function invalidArchive() {
|
|
189
|
+
throw new PackageValidationError('Skill ZIP 目录结构无效', 'INVALID_SKILL_ARCHIVE');
|
|
190
|
+
}
|
|
191
|
+
function compareUtf8(left, right) {
|
|
192
|
+
return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8'));
|
|
193
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { PackageValidationError } from '../shared/errors.js';
|
|
2
|
+
const WINDOWS_ABSOLUTE_PATH = /^[a-zA-Z]:/;
|
|
3
|
+
const EXCLUDED_DIRECTORIES = new Set(['.git', '.skillhub']);
|
|
4
|
+
const EXCLUDED_FILES = new Set([
|
|
5
|
+
'.env',
|
|
6
|
+
'.npmrc',
|
|
7
|
+
'.pypirc',
|
|
8
|
+
'.netrc',
|
|
9
|
+
'credentials.json',
|
|
10
|
+
'id_rsa',
|
|
11
|
+
'id_ed25519'
|
|
12
|
+
]);
|
|
13
|
+
export function normalizePackagePath(value, maxLength) {
|
|
14
|
+
if (value.length === 0 || value.includes('\0') || value.includes('\\')) {
|
|
15
|
+
throw unsafePath(value);
|
|
16
|
+
}
|
|
17
|
+
if (value.startsWith('/') || WINDOWS_ABSOLUTE_PATH.test(value)) {
|
|
18
|
+
throw unsafePath(value);
|
|
19
|
+
}
|
|
20
|
+
const normalized = value.normalize('NFC');
|
|
21
|
+
const segments = normalized.split('/');
|
|
22
|
+
if (segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..')) {
|
|
23
|
+
throw unsafePath(value);
|
|
24
|
+
}
|
|
25
|
+
if (Array.from(normalized).length > maxLength) {
|
|
26
|
+
throw new PackageValidationError('Skill 包内路径超过长度限制', 'PACKAGE_PATH_TOO_LONG', { path: normalized });
|
|
27
|
+
}
|
|
28
|
+
return normalized;
|
|
29
|
+
}
|
|
30
|
+
export function shouldExcludePackagePath(value) {
|
|
31
|
+
const segments = value.replace(/\\/g, '/').split('/');
|
|
32
|
+
const fileName = segments[segments.length - 1]?.toLowerCase() ?? '';
|
|
33
|
+
return segments.some((segment) => EXCLUDED_DIRECTORIES.has(segment.toLowerCase()))
|
|
34
|
+
|| EXCLUDED_FILES.has(fileName)
|
|
35
|
+
|| fileName.startsWith('.env.')
|
|
36
|
+
|| fileName.endsWith('.pem')
|
|
37
|
+
|| fileName.endsWith('.key');
|
|
38
|
+
}
|
|
39
|
+
function unsafePath(path) {
|
|
40
|
+
return new PackageValidationError('Skill 包包含不安全路径', 'UNSAFE_PACKAGE_PATH', { path });
|
|
41
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, readFile, readdir } from 'node:fs/promises';
|
|
3
|
+
import { join, relative, resolve } from 'node:path';
|
|
4
|
+
import { parseDocument } from 'yaml';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { createArchive, readArchive, resolvePackageLimits } from '../platform/archive.js';
|
|
7
|
+
import { normalizePackagePath, shouldExcludePackagePath } from '../platform/paths.js';
|
|
8
|
+
import { PackageValidationError } from '../shared/errors.js';
|
|
9
|
+
const SEMANTIC_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
10
|
+
const metadataSchema = z.object({
|
|
11
|
+
name: z.string().trim().min(1).max(100),
|
|
12
|
+
description: z.string().trim().min(1).max(2000),
|
|
13
|
+
version: z.string().trim().regex(SEMANTIC_VERSION).optional().default('0.0.0')
|
|
14
|
+
}).passthrough();
|
|
15
|
+
export async function prepareSkillPackage(inputPath, overrides = {}) {
|
|
16
|
+
const limits = resolvePackageLimits(overrides);
|
|
17
|
+
let inputStat;
|
|
18
|
+
try {
|
|
19
|
+
inputStat = await lstat(inputPath);
|
|
20
|
+
}
|
|
21
|
+
catch (_error) {
|
|
22
|
+
throw new PackageValidationError('Skill 路径不存在或不可读', 'SKILL_PATH_NOT_READABLE');
|
|
23
|
+
}
|
|
24
|
+
if (inputStat.isSymbolicLink()) {
|
|
25
|
+
throw new PackageValidationError('Skill 路径不允许是符号链接', 'SYMLINK_NOT_ALLOWED');
|
|
26
|
+
}
|
|
27
|
+
let sourceType;
|
|
28
|
+
let archive;
|
|
29
|
+
let files;
|
|
30
|
+
if (inputStat.isDirectory()) {
|
|
31
|
+
sourceType = 'DIRECTORY';
|
|
32
|
+
files = await readDirectoryFiles(inputPath, overrides);
|
|
33
|
+
archive = createArchive(files, overrides);
|
|
34
|
+
}
|
|
35
|
+
else if (inputStat.isFile()) {
|
|
36
|
+
if (inputPath.toLowerCase().endsWith('.md')) {
|
|
37
|
+
sourceType = 'DIRECTORY';
|
|
38
|
+
const content = new Uint8Array(await readFile(inputPath));
|
|
39
|
+
files = [{ path: 'SKILL.md', content }];
|
|
40
|
+
archive = createArchive(files, overrides);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
sourceType = 'ZIP';
|
|
44
|
+
if (inputStat.size > limits.maxArchiveBytes) {
|
|
45
|
+
throw new PackageValidationError('Skill 压缩包超过大小限制', 'PACKAGE_ARCHIVE_TOO_LARGE');
|
|
46
|
+
}
|
|
47
|
+
archive = new Uint8Array(await readFile(inputPath));
|
|
48
|
+
files = readArchive(archive, overrides);
|
|
49
|
+
const sensitiveEntry = files.find((file) => shouldExcludePackagePath(file.path));
|
|
50
|
+
if (sensitiveEntry !== undefined) {
|
|
51
|
+
throw new PackageValidationError('Skill ZIP 包含版本库或凭据文件', 'SENSITIVE_PACKAGE_PATH', { path: sensitiveEntry.path });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
throw new PackageValidationError('Skill 路径必须是目录或 ZIP 文件', 'UNSUPPORTED_SKILL_PATH');
|
|
57
|
+
}
|
|
58
|
+
if (files.length === 0) {
|
|
59
|
+
throw new PackageValidationError('Skill 包不能为空', 'EMPTY_SKILL_PACKAGE');
|
|
60
|
+
}
|
|
61
|
+
files = normalizePackageRoot(files);
|
|
62
|
+
if (sourceType === 'ZIP')
|
|
63
|
+
archive = createArchive(files, overrides);
|
|
64
|
+
const skillFile = files.find((file) => file.path === 'SKILL.md');
|
|
65
|
+
if (skillFile === undefined) {
|
|
66
|
+
throw new PackageValidationError('Skill 包根目录缺少 SKILL.md', 'SKILL_FILE_REQUIRED');
|
|
67
|
+
}
|
|
68
|
+
const metadata = parseMetadata(skillFile.content);
|
|
69
|
+
const manifest = createManifest(files);
|
|
70
|
+
const manifestJson = JSON.stringify(manifest);
|
|
71
|
+
const normalizedVersion = JSON.stringify({ metadata, manifest });
|
|
72
|
+
return {
|
|
73
|
+
sourceType,
|
|
74
|
+
archive,
|
|
75
|
+
artifactDigest: sha256(archive),
|
|
76
|
+
versionDigest: sha256(normalizedVersion),
|
|
77
|
+
manifestDigest: sha256(manifestJson),
|
|
78
|
+
metadata,
|
|
79
|
+
manifest
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function normalizePackageRoot(files) {
|
|
83
|
+
if (files.some((file) => file.path === 'SKILL.md'))
|
|
84
|
+
return files;
|
|
85
|
+
const skillFiles = files.filter((file) => file.path.endsWith('/SKILL.md'));
|
|
86
|
+
if (skillFiles.length !== 1)
|
|
87
|
+
return files;
|
|
88
|
+
const prefix = skillFiles[0].path.slice(0, -'SKILL.md'.length);
|
|
89
|
+
if (!files.every((file) => file.path.startsWith(prefix)))
|
|
90
|
+
return files;
|
|
91
|
+
return files.map((file) => ({ ...file, path: file.path.slice(prefix.length) }));
|
|
92
|
+
}
|
|
93
|
+
async function readDirectoryFiles(rootPath, overrides) {
|
|
94
|
+
const limits = resolvePackageLimits(overrides);
|
|
95
|
+
const root = resolve(rootPath);
|
|
96
|
+
const files = [];
|
|
97
|
+
let expandedBytes = 0;
|
|
98
|
+
async function visit(directory) {
|
|
99
|
+
let entries;
|
|
100
|
+
try {
|
|
101
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
102
|
+
}
|
|
103
|
+
catch (_error) {
|
|
104
|
+
throw new PackageValidationError('Skill 目录不可读', 'SKILL_PATH_NOT_READABLE');
|
|
105
|
+
}
|
|
106
|
+
entries.sort((left, right) => Buffer.compare(Buffer.from(left.name, 'utf8'), Buffer.from(right.name, 'utf8')));
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
const absolutePath = join(directory, entry.name);
|
|
109
|
+
const relativePath = normalizePackagePath(relative(root, absolutePath).replace(/\\/g, '/'), limits.maxPathLength);
|
|
110
|
+
if (shouldExcludePackagePath(relativePath))
|
|
111
|
+
continue;
|
|
112
|
+
const entryStat = await lstat(absolutePath);
|
|
113
|
+
if (entryStat.isSymbolicLink()) {
|
|
114
|
+
throw new PackageValidationError('Skill 包不允许符号链接', 'SYMLINK_NOT_ALLOWED', { path: relativePath });
|
|
115
|
+
}
|
|
116
|
+
if (entryStat.isDirectory()) {
|
|
117
|
+
await visit(absolutePath);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (!entryStat.isFile())
|
|
121
|
+
continue;
|
|
122
|
+
if (files.length + 1 > limits.maxFiles) {
|
|
123
|
+
throw new PackageValidationError('Skill 包文件数量超过限制', 'PACKAGE_FILE_COUNT_EXCEEDED');
|
|
124
|
+
}
|
|
125
|
+
if (entryStat.size > limits.maxSingleFileBytes) {
|
|
126
|
+
throw new PackageValidationError('Skill 包内单个文件超过大小限制', 'PACKAGE_FILE_TOO_LARGE', { path: relativePath });
|
|
127
|
+
}
|
|
128
|
+
expandedBytes += entryStat.size;
|
|
129
|
+
if (expandedBytes > limits.maxExpandedBytes) {
|
|
130
|
+
throw new PackageValidationError('Skill 包解压后超过大小限制', 'PACKAGE_EXPANDED_TOO_LARGE');
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const content = new Uint8Array(await readFile(absolutePath));
|
|
134
|
+
files.push({ path: relativePath, content });
|
|
135
|
+
}
|
|
136
|
+
catch (_error) {
|
|
137
|
+
throw new PackageValidationError('Skill 包包含不可读文件', 'SKILL_FILE_NOT_READABLE', { path: relativePath });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
await visit(root);
|
|
142
|
+
return files;
|
|
143
|
+
}
|
|
144
|
+
function parseMetadata(content) {
|
|
145
|
+
let markdown;
|
|
146
|
+
try {
|
|
147
|
+
markdown = new TextDecoder('utf-8', { fatal: true }).decode(content);
|
|
148
|
+
}
|
|
149
|
+
catch (_error) {
|
|
150
|
+
throw new PackageValidationError('SKILL.md 必须使用 UTF-8', 'INVALID_SKILL_ENCODING');
|
|
151
|
+
}
|
|
152
|
+
const lines = markdown.replace(/^\uFEFF/, '').split(/\r?\n/);
|
|
153
|
+
if (lines[0] !== '---') {
|
|
154
|
+
throw new PackageValidationError('SKILL.md 缺少 YAML frontmatter', 'INVALID_SKILL_FRONTMATTER');
|
|
155
|
+
}
|
|
156
|
+
const closingIndex = lines.findIndex((line, index) => index > 0 && (line === '---' || line === '...'));
|
|
157
|
+
if (closingIndex < 0) {
|
|
158
|
+
throw new PackageValidationError('SKILL.md 的 YAML frontmatter 未闭合', 'INVALID_SKILL_FRONTMATTER');
|
|
159
|
+
}
|
|
160
|
+
const document = parseDocument(lines.slice(1, closingIndex).join('\n'), {
|
|
161
|
+
schema: 'core',
|
|
162
|
+
strict: true,
|
|
163
|
+
uniqueKeys: true
|
|
164
|
+
});
|
|
165
|
+
if (document.errors.length > 0) {
|
|
166
|
+
throw new PackageValidationError('SKILL.md 的 YAML frontmatter 无法解析', 'INVALID_SKILL_FRONTMATTER');
|
|
167
|
+
}
|
|
168
|
+
let value;
|
|
169
|
+
try {
|
|
170
|
+
value = document.toJS({ maxAliasCount: 0 });
|
|
171
|
+
}
|
|
172
|
+
catch (_error) {
|
|
173
|
+
throw new PackageValidationError('SKILL.md 的 YAML frontmatter 不安全', 'INVALID_SKILL_FRONTMATTER');
|
|
174
|
+
}
|
|
175
|
+
const result = metadataSchema.safeParse(value);
|
|
176
|
+
if (!result.success) {
|
|
177
|
+
throw new PackageValidationError('SKILL.md 的名称、描述或版本无效', 'INVALID_SKILL_METADATA');
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
name: result.data.name,
|
|
181
|
+
description: result.data.description,
|
|
182
|
+
version: result.data.version
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function createManifest(files) {
|
|
186
|
+
return [...files]
|
|
187
|
+
.sort((left, right) => Buffer.compare(Buffer.from(left.path, 'utf8'), Buffer.from(right.path, 'utf8')))
|
|
188
|
+
.map((file) => ({ path: file.path, size: file.content.byteLength, digest: sha256(file.content) }));
|
|
189
|
+
}
|
|
190
|
+
function sha256(value) {
|
|
191
|
+
return createHash('sha256').update(value).digest('hex');
|
|
192
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare class CliError extends Error {
|
|
2
|
+
readonly code: string;
|
|
3
|
+
readonly exitCode: number;
|
|
4
|
+
readonly details: Record<string, unknown>;
|
|
5
|
+
constructor(message: string, code: string, exitCode: number, details?: Record<string, unknown>);
|
|
6
|
+
}
|
|
7
|
+
export declare class PackageValidationError extends CliError {
|
|
8
|
+
constructor(message: string, code: string, details?: Record<string, unknown>);
|
|
9
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { EXIT_CODE } from './constants.js';
|
|
2
|
+
export class CliError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
exitCode;
|
|
5
|
+
details;
|
|
6
|
+
constructor(message, code, exitCode, details = {}) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.exitCode = exitCode;
|
|
10
|
+
this.details = details;
|
|
11
|
+
this.name = 'CliError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class PackageValidationError extends CliError {
|
|
15
|
+
constructor(message, code, details = {}) {
|
|
16
|
+
super(message, code, EXIT_CODE.validation, details);
|
|
17
|
+
this.name = 'PackageValidationError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function formatError(error: unknown, json: boolean): string;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { CliError } from './errors.js';
|
|
2
|
+
export function formatError(error, json) {
|
|
3
|
+
const cliError = error instanceof CliError
|
|
4
|
+
? error
|
|
5
|
+
: new CliError(error instanceof Error ? error.message : '未知错误', 'UNEXPECTED_ERROR', 1);
|
|
6
|
+
return json
|
|
7
|
+
? JSON.stringify({ ok: false, code: cliError.code, message: cliError.message, ...cliError.details })
|
|
8
|
+
: `错误:${cliError.message}`;
|
|
9
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface PackageLimits {
|
|
2
|
+
maxArchiveBytes: number;
|
|
3
|
+
maxExpandedBytes: number;
|
|
4
|
+
maxSingleFileBytes: number;
|
|
5
|
+
maxFiles: number;
|
|
6
|
+
maxPathLength: number;
|
|
7
|
+
}
|
|
8
|
+
export interface PackageFile {
|
|
9
|
+
path: string;
|
|
10
|
+
content: Uint8Array;
|
|
11
|
+
}
|
|
12
|
+
export interface SkillPackageMetadata {
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
version: string;
|
|
16
|
+
}
|
|
17
|
+
export interface SkillPackageManifestEntry {
|
|
18
|
+
path: string;
|
|
19
|
+
size: number;
|
|
20
|
+
digest: string;
|
|
21
|
+
}
|
|
22
|
+
export interface PreparedSkillPackage {
|
|
23
|
+
sourceType: 'DIRECTORY' | 'ZIP';
|
|
24
|
+
archive: Uint8Array;
|
|
25
|
+
artifactDigest: string;
|
|
26
|
+
versionDigest: string;
|
|
27
|
+
manifestDigest: string;
|
|
28
|
+
metadata: SkillPackageMetadata;
|
|
29
|
+
manifest: SkillPackageManifestEntry[];
|
|
30
|
+
}
|
|
31
|
+
export declare const DEFAULT_PACKAGE_LIMITS: PackageLimits;
|
|
32
|
+
export type PackageLimitOverrides = Partial<PackageLimits>;
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@second196/skillhub-cli",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Skill Hub CLI for uploading, discovering, and installing skills",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"skillhub": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc -p tsconfig.json && chmod +x dist/index.js",
|
|
14
|
+
"test": "tsc -p tsconfig.json --noEmit",
|
|
15
|
+
"prepublishOnly": "npm run build"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20.0.0"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"cac": "6.7.14",
|
|
22
|
+
"fflate": "0.8.2",
|
|
23
|
+
"yaml": "2.8.3",
|
|
24
|
+
"zod": "3.24.1"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "20.12.7",
|
|
28
|
+
"typescript": "5.4.2"
|
|
29
|
+
}
|
|
30
|
+
}
|