mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-11 22:51:04 +00:00
Implement complete CLI tool for SkillHub with 12 commands, 7 backend API endpoints, and comprehensive documentation. CLI Commands: - help, version: Basic information - login, logout, whoami: Authentication management - search: Discover published skills - install: Install skills to agent directories (14 Tier 1 agents supported) - list, remove, doctor: Local skill management - publish: Publish skill packages - update: Self-update mechanism Backend API: - Add /api/cli/v1 endpoints for auth, search, resolve, download, delete, publish - Implement CliAuthController and CliSkillController - Add security policies for CLI routes - Full test coverage (19 backend tests) CLI Implementation: - TypeScript with strict mode, Bun runtime - Pure JS zip handling (fflate) for cross-platform compatibility - 15 agent profiles (14 Tier 1 + generic fallback) - Secure token storage (0600 permissions) - Path safety validation for remove operations - Comprehensive error handling (404/403/network distinction) - 41 unit and integration tests Documentation: - CLI user guide (Chinese and English) - README updates with quick start - GitHub Actions workflow for cross-platform CI Quality: - lint: 0 errors - typecheck: pass - test: 41/41 pass - build: 0.30 MB (target=node for npm/npx compatibility)
73 lines
2.5 KiB
TypeScript
73 lines
2.5 KiB
TypeScript
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
|
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
import { zipSync, unzipSync } from 'fflate'
|
|
|
|
/**
|
|
* Extract a zip archive buffer into the target directory.
|
|
* Pure JS implementation using fflate — no system commands needed.
|
|
*/
|
|
export async function extractZip(buffer: ArrayBuffer, targetDir: string): Promise<void> {
|
|
await mkdir(targetDir, { recursive: true })
|
|
const files = unzipSync(new Uint8Array(buffer))
|
|
for (const [name, data] of Object.entries(files)) {
|
|
const filePath = safeJoin(targetDir, name)
|
|
if (name.endsWith('/')) {
|
|
await mkdir(filePath, { recursive: true })
|
|
continue
|
|
}
|
|
await mkdir(dirname(filePath), { recursive: true })
|
|
await writeFile(filePath, data)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a zip archive from a directory.
|
|
* Returns the archive as a Blob.
|
|
* Pure JS implementation using fflate — no system commands needed.
|
|
*/
|
|
export async function createZip(dirPath: string): Promise<Blob> {
|
|
const entries: Record<string, Uint8Array> = {}
|
|
await collectFiles(dirPath, dirPath, entries)
|
|
const zipped = zipSync(entries, { level: 6 })
|
|
return new Blob([zipped.buffer as ArrayBuffer], { type: 'application/zip' })
|
|
}
|
|
|
|
async function collectFiles(basePath: string, currentPath: string, entries: Record<string, Uint8Array>): Promise<void> {
|
|
const items = await readdir(currentPath, { withFileTypes: true })
|
|
for (const item of items) {
|
|
const fullPath = join(currentPath, item.name)
|
|
const relPath = relative(basePath, fullPath)
|
|
if (item.isDirectory()) {
|
|
entries[relPath + '/'] = new Uint8Array(0)
|
|
await collectFiles(basePath, fullPath, entries)
|
|
} else if (item.isFile()) {
|
|
entries[relPath] = new Uint8Array(await readFile(fullPath))
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect whether a path is a zip file by checking magic bytes.
|
|
*/
|
|
export async function isZipFile(filePath: string): Promise<boolean> {
|
|
try {
|
|
const buf = new Uint8Array(await readFile(filePath))
|
|
return buf.length >= 4 && buf[0] === 0x50 && buf[1] === 0x4B && buf[2] === 0x03 && buf[3] === 0x04
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function safeJoin(targetDir: string, entryName: string): string {
|
|
if (isAbsolute(entryName)) {
|
|
throw new Error(`unsafe zip entry path: ${entryName}`)
|
|
}
|
|
|
|
const root = resolve(targetDir)
|
|
const target = resolve(root, entryName)
|
|
const rel = relative(root, target)
|
|
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
|
|
throw new Error(`unsafe zip entry path: ${entryName}`)
|
|
}
|
|
return target
|
|
}
|