skillhub/cli/src/platform/archive.ts
dongmucat 351dddc912 feat(cli): add SkillHub CLI v1 with full command suite
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)
2026-04-29 15:37:04 +08:00

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
}