mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-28 11:25:00 +00:00
变更摘要: - doctor 命令重构:从重建改为扫描并合并语义,保留扫描范围外的条目 - 修复字段命名:itemsRestored → itemsScanned/itemsPreserved,语义更清晰 - 改进用户提示:输出区分扫描到的和保留的条目,帮助文档补充保留行为说明 - 补充代码注释:说明同 slug 不同 installDir 允许并存的设计意图 - 统一错误码:download/handleJsonResponse 的非 2xx 响应统一使用 EXIT.generic - 新增测试覆盖:合并场景、刷新场景、冲突不删除无关条目等边界情况 关键文件: - cli/src/services/doctor-service.ts - cli/src/commands/doctor.ts - cli/src/commands/help.ts - cli/test/unit/services/doctor-service.test.ts - cli/test/integration/doctor-command.test.ts
239 lines
8.3 KiB
TypeScript
239 lines
8.3 KiB
TypeScript
import { mkdtemp, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, test } from 'bun:test'
|
|
import { zipSync } from 'fflate'
|
|
import { createTempHome } from '../helpers/temp-env'
|
|
import { startFakeRegistry } from '../helpers/fake-registry'
|
|
import { runCli } from '../helpers/run-cli'
|
|
|
|
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
|
|
|
afterEach(() => {
|
|
registry?.stop()
|
|
registry = undefined
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function login(env: { home: string }, registryUrl: string) {
|
|
const result = await runCli(['login', '--registry', registryUrl, '--token', 'sk_ok'], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
if (result.exitCode !== 0) {
|
|
throw new Error(`login failed: ${result.stderr}`)
|
|
}
|
|
}
|
|
|
|
async function makeTempDir(...files: Array<[string, string]>) {
|
|
const dir = await mkdtemp(join(tmpdir(), 'skillhub-publish-dir-'))
|
|
for (const [name, content] of files) {
|
|
await writeFile(join(dir, name), content)
|
|
}
|
|
return dir
|
|
}
|
|
|
|
async function makeTempZip(name: string) {
|
|
const dir = await mkdtemp(join(tmpdir(), 'skillhub-publish-zip-'))
|
|
const zipPath = join(dir, name)
|
|
const bytes = zipSync({ 'SKILL.md': new TextEncoder().encode('# Demo') })
|
|
await writeFile(zipPath, bytes)
|
|
return zipPath
|
|
}
|
|
|
|
async function makeTempTxt(name: string) {
|
|
const dir = await mkdtemp(join(tmpdir(), 'skillhub-publish-txt-'))
|
|
const txtPath = join(dir, name)
|
|
await writeFile(txtPath, 'not a zip')
|
|
return txtPath
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// P0 — must-have
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('publish command — P0', () => {
|
|
test('unauthenticated publish is rejected with EXIT.auth', async () => {
|
|
const env = await createTempHome()
|
|
registry = await startFakeRegistry({ token: 'sk_ok' })
|
|
|
|
const dir = await makeTempDir(['SKILL.md', '# Demo'])
|
|
const result = await runCli(['publish', dir, '--registry', registry.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
|
|
expect(result.exitCode).toBe(2)
|
|
expect(result.stderr).toContain('authentication')
|
|
})
|
|
|
|
test('path not found returns filesystem error', async () => {
|
|
const env = await createTempHome()
|
|
registry = await startFakeRegistry({ token: 'sk_ok' })
|
|
await login(env, registry.url)
|
|
|
|
const result = await runCli(['publish', '/does/not/exist', '--registry', registry.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
|
|
expect(result.exitCode).not.toBe(0)
|
|
expect(result.stderr).toContain('not found')
|
|
})
|
|
|
|
test('non-zip file is rejected', async () => {
|
|
const env = await createTempHome()
|
|
registry = await startFakeRegistry({ token: 'sk_ok' })
|
|
await login(env, registry.url)
|
|
|
|
const txtPath = await makeTempTxt('skill.txt')
|
|
const result = await runCli(['publish', txtPath, '--registry', registry.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
|
|
expect(result.exitCode).not.toBe(0)
|
|
expect(result.stderr).toContain('zip')
|
|
})
|
|
|
|
test('directory happy path: exit 0, default namespace=global, fileName ends in .zip, visibility=PUBLIC', async () => {
|
|
const env = await createTempHome()
|
|
registry = await startFakeRegistry({ token: 'sk_ok' })
|
|
await login(env, registry.url)
|
|
|
|
const dir = await makeTempDir(['SKILL.md', '# Demo'], ['index.js', 'console.log("hi")'])
|
|
const result = await runCli(['publish', dir, '--registry', registry.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
|
|
expect(result.exitCode).toBe(0)
|
|
expect(registry.received.publish).not.toBeNull()
|
|
expect(registry.received.publish!.namespace).toBe('global')
|
|
expect(registry.received.publish!.fileName).toMatch(/\.zip$/)
|
|
expect(registry.received.publish!.visibility).toBe('PUBLIC')
|
|
})
|
|
|
|
test('zip file happy path: exit 0, fileName matches passed file', async () => {
|
|
const env = await createTempHome()
|
|
registry = await startFakeRegistry({ token: 'sk_ok' })
|
|
await login(env, registry.url)
|
|
|
|
const zipPath = await makeTempZip('my-skill.zip')
|
|
const result = await runCli(['publish', zipPath, '--registry', registry.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
|
|
expect(result.exitCode).toBe(0)
|
|
expect(registry.received.publish).not.toBeNull()
|
|
expect(registry.received.publish!.fileName).toBe('my-skill.zip')
|
|
})
|
|
|
|
describe('visibility mapping', () => {
|
|
test('--visibility public → PUBLIC', async () => {
|
|
const env = await createTempHome()
|
|
registry = await startFakeRegistry({ token: 'sk_ok' })
|
|
await login(env, registry.url)
|
|
|
|
const dir = await makeTempDir(['SKILL.md', '# Demo'])
|
|
await runCli(['publish', dir, '--registry', registry.url, '--visibility', 'public'], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
|
|
expect(registry.received.publish!.visibility).toBe('PUBLIC')
|
|
})
|
|
|
|
test('--visibility namespace-only → NAMESPACE_ONLY', async () => {
|
|
const env = await createTempHome()
|
|
registry = await startFakeRegistry({ token: 'sk_ok' })
|
|
await login(env, registry.url)
|
|
|
|
const dir = await makeTempDir(['SKILL.md', '# Demo'])
|
|
await runCli(['publish', dir, '--registry', registry.url, '--visibility', 'namespace-only'], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
|
|
expect(registry.received.publish!.visibility).toBe('NAMESPACE_ONLY')
|
|
})
|
|
|
|
test('--visibility private → PRIVATE', async () => {
|
|
const env = await createTempHome()
|
|
registry = await startFakeRegistry({ token: 'sk_ok' })
|
|
await login(env, registry.url)
|
|
|
|
const dir = await makeTempDir(['SKILL.md', '# Demo'])
|
|
await runCli(['publish', dir, '--registry', registry.url, '--visibility', 'private'], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
|
|
expect(registry.received.publish!.visibility).toBe('PRIVATE')
|
|
})
|
|
})
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// P1 — should-have
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('publish command — P1', () => {
|
|
test('--namespace override is forwarded to server', async () => {
|
|
const env = await createTempHome()
|
|
registry = await startFakeRegistry({ token: 'sk_ok' })
|
|
await login(env, registry.url)
|
|
|
|
const dir = await makeTempDir(['SKILL.md', '# Demo'])
|
|
const result = await runCli(['publish', dir, '--registry', registry.url, '--namespace', 'myteam'], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
|
|
expect(result.exitCode).toBe(0)
|
|
expect(registry.received.publish!.namespace).toBe('myteam')
|
|
})
|
|
|
|
test('--json output has correct shape including detailUrl', async () => {
|
|
const env = await createTempHome()
|
|
registry = await startFakeRegistry({ token: 'sk_ok' })
|
|
await login(env, registry.url)
|
|
|
|
const dir = await makeTempDir(['SKILL.md', '# Demo'])
|
|
const result = await runCli(['publish', dir, '--registry', registry.url, '--json'], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
|
|
expect(result.exitCode).toBe(0)
|
|
const json = JSON.parse(result.stdout)
|
|
expect(json.ok).toBe(true)
|
|
expect(json.namespace).toBe('global')
|
|
expect(typeof json.slug).toBe('string')
|
|
expect(typeof json.version).toBe('string')
|
|
expect(typeof json.visibility).toBe('string')
|
|
expect(json.detailUrl).toContain(registry.url)
|
|
expect(json.detailUrl).toContain('global')
|
|
expect(json.detailUrl).toContain(encodeURIComponent(json.slug))
|
|
})
|
|
|
|
test('server error during publish returns EXIT.generic', async () => {
|
|
const env = await createTempHome()
|
|
// 'server_error' returns HTTP 500; request reached registry but failed, so EXIT.generic.
|
|
registry = await startFakeRegistry({ token: 'sk_ok', failures: { publish: 'server_error' } })
|
|
await login(env, registry.url)
|
|
|
|
const dir = await makeTempDir(['SKILL.md', '# Demo'])
|
|
const result = await runCli(['publish', dir, '--registry', registry.url], {
|
|
HOME: env.home,
|
|
USERPROFILE: env.home
|
|
})
|
|
|
|
expect(result.exitCode).toBe(1)
|
|
expect(result.stderr).toContain('registry')
|
|
})
|
|
})
|