mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
fix(cli): preserve download error contract (#606)
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
ad4a2dbc2f
commit
a9007a4e8c
5 changed files with 122 additions and 17 deletions
|
|
@ -179,7 +179,9 @@ export class SkillHubClient {
|
|||
} else if (response.status === 404) {
|
||||
fallback = kind === 'download' ? 'skill or version not found' : 'resource not found'
|
||||
} else if (response.status === 502 || response.status === 503) {
|
||||
fallback = `registry returned ${response.status}`
|
||||
fallback = kind === 'download'
|
||||
? `download failed with status ${response.status}`
|
||||
: `registry returned ${response.status}`
|
||||
exitCode = EXIT.network
|
||||
} else {
|
||||
fallback = kind === 'download'
|
||||
|
|
|
|||
|
|
@ -92,6 +92,17 @@ describe('SkillHubClient', () => {
|
|||
await err.toHaveProperty('exitCode', EXIT.generic)
|
||||
})
|
||||
|
||||
test('download() retains its fallback while classifying 502 as a network error', async () => {
|
||||
const fetchImpl = (async () => new Response(null, { status: 502 })) as unknown as typeof fetch
|
||||
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
|
||||
|
||||
await expect(client.download('ns', 'slug')).rejects.toMatchObject({
|
||||
message: 'download failed with status 502',
|
||||
exitCode: EXIT.network,
|
||||
details: { registry: 'http://registry.test' }
|
||||
})
|
||||
})
|
||||
|
||||
test('download() throws network error on fetch failure', async () => {
|
||||
const fetchImpl = (async () => { throw new TypeError('fetch failed') }) as unknown as typeof fetch
|
||||
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
|
||||
|
|
@ -179,6 +190,27 @@ describe('SkillHubClient', () => {
|
|||
|
||||
// --- handleJsonResponse() non-2xx classification ---
|
||||
|
||||
test('whoami() preserves public fields and ignores unknown fields on a structured 401', async () => {
|
||||
const fetchImpl = (async () => Response.json({
|
||||
code: 401,
|
||||
msg: 'token has been revoked',
|
||||
requestId: 'req-401',
|
||||
detail: 'internal token state',
|
||||
stack: 'internal stack trace'
|
||||
}, { status: 401 })) as unknown as typeof fetch
|
||||
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
|
||||
|
||||
const error = await client.whoami().catch((caught: unknown) => caught)
|
||||
expect(error).toBeInstanceOf(CliError)
|
||||
expect((error as CliError).message).toBe('token has been revoked')
|
||||
expect((error as CliError).exitCode).toBe(EXIT.auth)
|
||||
expect((error as CliError).details).toEqual({
|
||||
registry: 'http://registry.test',
|
||||
requestId: 'req-401',
|
||||
next: 'run `skillhub login`'
|
||||
})
|
||||
})
|
||||
|
||||
test('search() preserves a public 403 message and request ID', async () => {
|
||||
const fetchImpl = (async () => Response.json({
|
||||
code: 403,
|
||||
|
|
@ -262,6 +294,17 @@ describe('SkillHubClient', () => {
|
|||
}
|
||||
})
|
||||
|
||||
test('whoami() uses the resource fallback on an unstructured 404', async () => {
|
||||
const fetchImpl = (async () => new Response(null, { status: 404 })) as unknown as typeof fetch
|
||||
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
|
||||
|
||||
await expect(client.whoami()).rejects.toMatchObject({
|
||||
message: 'resource not found',
|
||||
exitCode: EXIT.generic,
|
||||
details: { registry: 'http://registry.test' }
|
||||
})
|
||||
})
|
||||
|
||||
test('download() preserves a structured 403 message and request ID', async () => {
|
||||
const fetchImpl = (async () => Response.json({
|
||||
code: 403,
|
||||
|
|
@ -313,20 +356,63 @@ describe('SkillHubClient', () => {
|
|||
})
|
||||
})
|
||||
|
||||
test('whoami() throws generic error on 500', async () => {
|
||||
const fetchImpl = (async () => new Response(null, { status: 500 })) as unknown as typeof fetch
|
||||
test('whoami() preserves public fields on a structured 500', async () => {
|
||||
const fetchImpl = (async () => Response.json({
|
||||
code: 500,
|
||||
msg: 'registry operation failed',
|
||||
requestId: 'req-500',
|
||||
detail: 'internal database error'
|
||||
}, { status: 500 })) as unknown as typeof fetch
|
||||
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
|
||||
const err = expect(client.whoami()).rejects
|
||||
await err.toBeInstanceOf(CliError)
|
||||
await err.toHaveProperty('exitCode', EXIT.generic)
|
||||
|
||||
await expect(client.whoami()).rejects.toMatchObject({
|
||||
message: 'registry operation failed',
|
||||
exitCode: EXIT.generic,
|
||||
details: {
|
||||
registry: 'http://registry.test',
|
||||
requestId: 'req-500'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('search() throws network error on 502', async () => {
|
||||
test('whoami() does not expose a raw non-JSON 500 body', async () => {
|
||||
const fetchImpl = (async () => new Response('internal stack trace', { status: 500 })) as unknown as typeof fetch
|
||||
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
|
||||
|
||||
await expect(client.whoami()).rejects.toMatchObject({
|
||||
message: 'registry returned 500',
|
||||
exitCode: EXIT.generic,
|
||||
details: { registry: 'http://registry.test' }
|
||||
})
|
||||
})
|
||||
|
||||
test('search() preserves public fields and network classification on a structured 502', async () => {
|
||||
const fetchImpl = (async () => Response.json({
|
||||
code: 502,
|
||||
msg: 'registry upstream unavailable',
|
||||
requestId: 'req-502'
|
||||
}, { status: 502 })) as unknown as typeof fetch
|
||||
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
|
||||
|
||||
await expect(client.search('test', 20)).rejects.toMatchObject({
|
||||
message: 'registry upstream unavailable',
|
||||
exitCode: EXIT.network,
|
||||
details: {
|
||||
registry: 'http://registry.test',
|
||||
requestId: 'req-502'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('search() uses the network fallback on an unstructured 502', async () => {
|
||||
const fetchImpl = (async () => new Response(null, { status: 502 })) as unknown as typeof fetch
|
||||
const client = new SkillHubClient('http://registry.test', 'token', fetchImpl)
|
||||
const err = expect(client.search('test', 20)).rejects
|
||||
await err.toBeInstanceOf(CliError)
|
||||
await err.toHaveProperty('exitCode', EXIT.network)
|
||||
|
||||
await expect(client.search('test', 20)).rejects.toMatchObject({
|
||||
message: 'registry returned 502',
|
||||
exitCode: EXIT.network,
|
||||
details: { registry: 'http://registry.test' }
|
||||
})
|
||||
})
|
||||
|
||||
// --- deleteRemote() (P1) ---
|
||||
|
|
|
|||
|
|
@ -125,15 +125,24 @@ Output format: `namespace/slug version summary`
|
|||
|
||||
## Install Skills
|
||||
|
||||
Install coordinates accept a bare slug (resolved to `global` by default) and
|
||||
three equivalent explicit namespace forms. When an explicit coordinate and
|
||||
`--namespace` are both present, they must match.
|
||||
|
||||
```bash
|
||||
# Install to auto-detected Agent directory
|
||||
skillhub install pdf-parser
|
||||
|
||||
# Equivalent namespace coordinates
|
||||
skillhub install team/my-skill
|
||||
skillhub install @team/my-skill
|
||||
skillhub install team--my-skill
|
||||
|
||||
# Choose install scope explicitly
|
||||
skillhub install pdf-parser --scope user
|
||||
skillhub install pdf-parser --scope project --agent codex
|
||||
|
||||
# Specify namespace (default: global)
|
||||
# Specify a namespace for a bare slug
|
||||
skillhub install pdf-parser --namespace myspace
|
||||
|
||||
# Specify version
|
||||
|
|
|
|||
|
|
@ -125,15 +125,23 @@ skillhub search pdf --json
|
|||
|
||||
## 安装技能
|
||||
|
||||
安装坐标支持裸 slug(默认解析到 `global`)和三种等价的显式 namespace
|
||||
形式。显式坐标与 `--namespace` 同时出现时,两者必须一致。
|
||||
|
||||
```bash
|
||||
# 安装到自动探测的 Agent 目录
|
||||
skillhub install pdf-parser
|
||||
|
||||
# 等价的 namespace 坐标
|
||||
skillhub install team/my-skill
|
||||
skillhub install @team/my-skill
|
||||
skillhub install team--my-skill
|
||||
|
||||
# 显式指定安装范围
|
||||
skillhub install pdf-parser --scope user
|
||||
skillhub install pdf-parser --scope project --agent codex
|
||||
|
||||
# 指定 namespace(默认 global)
|
||||
# 为裸 slug 指定 namespace
|
||||
skillhub install pdf-parser --namespace myspace
|
||||
|
||||
# 指定版本
|
||||
|
|
|
|||
|
|
@ -16,14 +16,14 @@ revalidation did not recreate historical RED states; it reran the current
|
|||
GREEN gates with the repository-pinned Bun 1.3.13:
|
||||
|
||||
- Focused namespace/error/help regression: 142 tests passed.
|
||||
- Complete CLI regression: 373 tests passed with
|
||||
`bun test --max-concurrency=1` (peak RSS 170224 KiB).
|
||||
- Complete CLI regression: 378 tests passed with
|
||||
`bun test --max-concurrency=1` (peak RSS 152128 KiB).
|
||||
- Typecheck, lint, and build passed.
|
||||
- The packed `@astron-team/skillhub@0.1.9` artifact contained `dist/index.js`,
|
||||
`README.md`, `CHANGELOG.md`, `LICENSE`, and `package.json`.
|
||||
- Packed Node artifact smoke passed for `version`, `help install`, and
|
||||
`remove --help`; the install help listed all three namespaced coordinate
|
||||
forms.
|
||||
- Packed Node artifact smoke passed for `version`, `help install`, all three
|
||||
namespaced coordinate forms, coordinate/`--namespace` conflict handling, and
|
||||
structured 403 message/request-ID rendering.
|
||||
- The Chinese and English VitePress documentation build passed.
|
||||
|
||||
---
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue