mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
Merge branch 'main' into feat/cli-install-scope
This commit is contained in:
commit
c6fa37bb78
32 changed files with 3646 additions and 17 deletions
|
|
@ -1,6 +0,0 @@
|
|||
# https://developers.google.com/gemini-code-assist/docs/customize-gemini-behavior-github
|
||||
have_fun: false # Just review the code
|
||||
code_review:
|
||||
comment_severity_threshold: HIGH # Reduce quantity of comments
|
||||
pull_request_opened:
|
||||
summary: false # Don't summarize the PR in a separate comment
|
||||
|
|
@ -44,6 +44,14 @@ export interface PublishResponse {
|
|||
visibility: string
|
||||
}
|
||||
|
||||
export interface DryRunResponse {
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
warnings: string[]
|
||||
resolvedSlug: string | null
|
||||
resolvedVersion: string | null
|
||||
}
|
||||
|
||||
export class SkillHubClient {
|
||||
constructor(
|
||||
readonly registry: string,
|
||||
|
|
@ -113,6 +121,23 @@ export class SkillHubClient {
|
|||
return this.handleJsonResponse<PublishResponse>(response)
|
||||
}
|
||||
|
||||
async validatePublish(namespace: string, file: Blob, visibility: string, fileName = 'skill.zip'): Promise<DryRunResponse> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file, fileName)
|
||||
formData.append('visibility', visibility)
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.fetchImpl(`${this.registry}/api/cli/v1/skills/${namespace}/publish/validate`, {
|
||||
method: 'POST',
|
||||
headers: this.token ? { Authorization: `Bearer ${this.token}` } : {},
|
||||
body: formData
|
||||
})
|
||||
} catch {
|
||||
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
|
||||
}
|
||||
return this.handleJsonResponse<DryRunResponse>(response)
|
||||
}
|
||||
|
||||
private async getJson<T>(path: string): Promise<T> {
|
||||
let response: Response
|
||||
try {
|
||||
|
|
@ -126,9 +151,12 @@ export class SkillHubClient {
|
|||
}
|
||||
|
||||
private async handleJsonResponse<T>(response: Response): Promise<T> {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
if (response.status === 401) {
|
||||
throw new CliError('authentication failed', EXIT.auth, { registry: this.registry, next: 'run `skillhub login`' })
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw new CliError('access denied — token may lack required scope', EXIT.auth, { registry: this.registry, next: 'regenerate token with required scopes or run `skillhub login`' })
|
||||
}
|
||||
if (response.status === 404) {
|
||||
throw new CliError('resource not found', EXIT.generic, { registry: this.registry })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export interface PublishCommandOptions {
|
|||
registry?: string
|
||||
token?: string
|
||||
json?: boolean
|
||||
dryRun?: boolean
|
||||
}
|
||||
|
||||
export async function publishCommand(path: string, options: PublishCommandOptions): Promise<string> {
|
||||
|
|
@ -40,7 +41,6 @@ export async function publishCommand(path: string, options: PublishCommandOption
|
|||
let archiveBlob: Blob
|
||||
let archiveName: string
|
||||
if (pathStat.isFile()) {
|
||||
// If input is a file, check if it's already a zip
|
||||
if (await isZipFile(path)) {
|
||||
const buffer = await readFile(path)
|
||||
archiveBlob = new Blob([buffer], { type: 'application/zip' })
|
||||
|
|
@ -49,7 +49,6 @@ export async function publishCommand(path: string, options: PublishCommandOption
|
|||
throw new CliError(`file must be a zip archive: ${path}`, EXIT.filesystem, { path })
|
||||
}
|
||||
} else if (pathStat.isDirectory()) {
|
||||
// If input is a directory, create zip from it
|
||||
archiveBlob = await createZip(path)
|
||||
archiveName = `${basename(path)}.zip`
|
||||
} else {
|
||||
|
|
@ -57,6 +56,49 @@ export async function publishCommand(path: string, options: PublishCommandOption
|
|||
}
|
||||
|
||||
const client = new SkillHubClient(registry, token)
|
||||
|
||||
if (options.dryRun) {
|
||||
const result = await client.validatePublish(namespace, archiveBlob, toServerVisibility(visibility), archiveName)
|
||||
|
||||
if (options.json) {
|
||||
if (!result.valid) {
|
||||
process.stdout.write(JSON.stringify(result) + '\n')
|
||||
throw new CliError('validation failed', EXIT.validation)
|
||||
}
|
||||
return JSON.stringify(result)
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
if (result.valid) {
|
||||
lines.push('Validation passed')
|
||||
} else {
|
||||
lines.push('Validation failed')
|
||||
}
|
||||
if (result.resolvedSlug) {
|
||||
lines.push(` Slug: ${result.resolvedSlug}`)
|
||||
}
|
||||
if (result.resolvedVersion) {
|
||||
lines.push(` Version: ${result.resolvedVersion}`)
|
||||
}
|
||||
if (result.errors.length > 0) {
|
||||
lines.push('Errors:')
|
||||
for (const error of result.errors) {
|
||||
lines.push(` - ${error}`)
|
||||
}
|
||||
}
|
||||
if (result.warnings.length > 0) {
|
||||
lines.push('Warnings:')
|
||||
for (const warning of result.warnings) {
|
||||
lines.push(` - ${warning}`)
|
||||
}
|
||||
}
|
||||
if (!result.valid) {
|
||||
process.stdout.write(lines.join('\n') + '\n')
|
||||
throw new CliError('validation failed', EXIT.validation)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const result = await client.publish(namespace, archiveBlob, toServerVisibility(visibility), archiveName)
|
||||
|
||||
const detailUrl = `${registry}/space/${result.namespace}/${encodeURIComponent(result.slug)}`
|
||||
|
|
|
|||
|
|
@ -279,6 +279,7 @@ cli
|
|||
.command('publish <path>', 'Publish a local skill package')
|
||||
.option('--namespace <slug>', 'Namespace')
|
||||
.option('--visibility <v>', 'Visibility (public|namespace-only|private)')
|
||||
.option('--dry-run', 'Validate without publishing')
|
||||
.option('--registry <url>', 'Registry URL')
|
||||
.option('--token <token>', 'API token')
|
||||
.option('--json', 'Output JSON')
|
||||
|
|
|
|||
|
|
@ -8,5 +8,6 @@ export const EXIT = {
|
|||
auth: 2,
|
||||
network: 3,
|
||||
filesystem: 4,
|
||||
usage: 5
|
||||
usage: 5,
|
||||
validation: 6
|
||||
} as const
|
||||
|
|
|
|||
|
|
@ -22,16 +22,19 @@ export function createFakeRegistry(handlers: Record<string, FakeHandler>) {
|
|||
/**
|
||||
* Controls how a specific endpoint behaves when a failure is injected:
|
||||
* 'auth' => 401 { code: 401, message: 'unauthorized' }
|
||||
* 'forbidden' => 403 { code: 403, message: 'forbidden' }
|
||||
* 'not_found' => 404 { code: 404, message: 'not found' }
|
||||
* 'server_error' => 500 { code: 500, message: 'internal error' }
|
||||
* 'network' => handler throws, causing fetch() to reject with a TypeError
|
||||
*/
|
||||
export type FailureMode = 'auth' | 'not_found' | 'server_error' | 'network'
|
||||
export type FailureMode = 'auth' | 'forbidden' | 'not_found' | 'server_error' | 'network'
|
||||
|
||||
function failureResponse(mode: FailureMode): Response {
|
||||
switch (mode) {
|
||||
case 'auth':
|
||||
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
|
||||
case 'forbidden':
|
||||
return Response.json({ code: 403, message: 'forbidden' }, { status: 403 })
|
||||
case 'not_found':
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
case 'server_error':
|
||||
|
|
@ -91,6 +94,12 @@ export interface CapturedPublish {
|
|||
visibility: string
|
||||
}
|
||||
|
||||
export interface CapturedValidate {
|
||||
namespace: string
|
||||
fileName: string
|
||||
visibility: string
|
||||
}
|
||||
|
||||
/** Last resolve GET: useful for verifying --version is forwarded as ?version=. */
|
||||
export interface CapturedResolve {
|
||||
namespace: string
|
||||
|
|
@ -116,6 +125,8 @@ interface FakeRegistryOptions {
|
|||
searchItems?: Array<{ namespace: string; slug: string; latestVersion: string; summary: string }>
|
||||
/** Skills available for resolve / download / delete / publish. */
|
||||
skills?: FakeSkill[]
|
||||
/** Response to return for publish/validate (dry-run) requests. */
|
||||
dryRunResponse?: { valid: boolean; errors: string[]; warnings: string[]; resolvedSlug: string | null; resolvedVersion: string | null }
|
||||
/**
|
||||
* Per-endpoint failure injection. When set for an endpoint, that endpoint
|
||||
* ignores all other logic and returns the specified failure (or throws for
|
||||
|
|
@ -128,6 +139,7 @@ interface FakeRegistryOptions {
|
|||
download?: FailureMode
|
||||
deleteRemote?: FailureMode
|
||||
publish?: FailureMode
|
||||
validate?: FailureMode
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -167,7 +179,8 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
publish: CapturedPublish | null
|
||||
resolve: CapturedResolve | null
|
||||
delete: CapturedDelete | null
|
||||
} = { publish: null, resolve: null, delete: null }
|
||||
validate: CapturedValidate | null
|
||||
} = { publish: null, resolve: null, delete: null, validate: null }
|
||||
|
||||
// If any endpoint is configured with 'network' failure mode, we need a real
|
||||
// TCP-level failure. Start a connection-dropping server and return its URL
|
||||
|
|
@ -339,6 +352,34 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
})
|
||||
}
|
||||
|
||||
// Validate (dry-run): POST /api/cli/v1/skills/:namespace/publish/validate
|
||||
const validateMatch = path.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/publish\/validate$/)
|
||||
if (validateMatch && req.method === 'POST') {
|
||||
if (options.failures?.validate) return failureResponse(options.failures.validate)
|
||||
const authErr = checkAuth(req)
|
||||
if (authErr) return authErr
|
||||
const namespace = validateMatch[1]!
|
||||
|
||||
return req.formData().then(form => {
|
||||
const fileField = form.get('file')
|
||||
const visibility = (form.get('visibility') as string | null) ?? 'PUBLIC'
|
||||
let fileName = 'skill.zip'
|
||||
if (fileField instanceof File) {
|
||||
fileName = fileField.name || fileName
|
||||
}
|
||||
state.validate = { namespace, fileName, visibility }
|
||||
|
||||
const dryRunData = options.dryRunResponse ?? {
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
resolvedSlug: fileName.replace(/\.zip$/, ''),
|
||||
resolvedVersion: '1.0.0'
|
||||
}
|
||||
return Response.json({ code: 0, data: dryRunData })
|
||||
})
|
||||
}
|
||||
|
||||
// Publish: POST /api/cli/v1/skills/:namespace/publish
|
||||
const publishMatch = path.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/publish$/)
|
||||
if (publishMatch && req.method === 'POST') {
|
||||
|
|
|
|||
159
cli/test/integration/auth-resolution.test.ts
Normal file
159
cli/test/integration/auth-resolution.test.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* End-to-end integration coverage for token / registry priority resolution.
|
||||
*
|
||||
* The unit test in test/unit/services/registry-service.test.ts pins the
|
||||
* resolution function in isolation. These tests verify the same priorities
|
||||
* are wired through the actual CLI subprocess: --flag > SKILLHUB_* env >
|
||||
* stored config / credentials > built-in default.
|
||||
*
|
||||
* Why this matters: a regression in the wiring (e.g. command forgets to
|
||||
* forward `process.env`) would silently downgrade users to the wrong
|
||||
* registry / token without surfacing in unit tests.
|
||||
*/
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { startFakeRegistry } from '../helpers/fake-registry'
|
||||
import { runCli } from '../helpers/run-cli'
|
||||
import { createTempHome } from '../helpers/temp-env'
|
||||
|
||||
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
||||
let registryB: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
||||
|
||||
afterEach(() => {
|
||||
registry?.stop(); registry = undefined
|
||||
registryB?.stop(); registryB = undefined
|
||||
})
|
||||
|
||||
async function seedCredentials(home: string, registryUrl: string, token: string): Promise<void> {
|
||||
await mkdir(join(home, '.skillhub'), { recursive: true })
|
||||
await writeFile(
|
||||
join(home, '.skillhub', 'credentials.json'),
|
||||
JSON.stringify({ tokens: { [registryUrl]: token } })
|
||||
)
|
||||
}
|
||||
|
||||
async function seedConfig(home: string, registryUrl: string): Promise<void> {
|
||||
await mkdir(join(home, '.skillhub'), { recursive: true })
|
||||
await writeFile(
|
||||
join(home, '.skillhub', 'config.json'),
|
||||
JSON.stringify({ registry: registryUrl })
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token priority: --token > SKILLHUB_TOKEN > stored
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('auth resolution — token priority', () => {
|
||||
test('--token flag wins over SKILLHUB_TOKEN env', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_from_flag',
|
||||
user: { handle: 'flag-user', displayName: 'Flag' }
|
||||
})
|
||||
|
||||
const result = await runCli(
|
||||
['whoami', '--registry', registry.url, '--token', 'sk_from_flag'],
|
||||
{ HOME: env.home, USERPROFILE: env.home, SKILLHUB_TOKEN: 'sk_wrong_from_env' }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('flag-user')
|
||||
})
|
||||
|
||||
test('SKILLHUB_TOKEN env wins over stored token', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_from_env',
|
||||
user: { handle: 'env-user', displayName: 'Env' }
|
||||
})
|
||||
await seedCredentials(env.home, registry.url, 'sk_wrong_from_storage')
|
||||
|
||||
const result = await runCli(
|
||||
['whoami', '--registry', registry.url],
|
||||
{ HOME: env.home, USERPROFILE: env.home, SKILLHUB_TOKEN: 'sk_from_env' }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('env-user')
|
||||
})
|
||||
|
||||
test('stored token used when neither --token nor env is set', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_from_storage',
|
||||
user: { handle: 'storage-user', displayName: 'Storage' }
|
||||
})
|
||||
await seedCredentials(env.home, registry.url, 'sk_from_storage')
|
||||
|
||||
const result = await runCli(
|
||||
['whoami', '--registry', registry.url],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('storage-user')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registry priority: --registry > SKILLHUB_REGISTRY > config.json
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('auth resolution — registry priority', () => {
|
||||
test('--registry flag wins over SKILLHUB_REGISTRY env', async () => {
|
||||
const env = await createTempHome()
|
||||
// Each registry only authenticates its own token. The wrong registry
|
||||
// would 401, so a successful whoami proves the right one was used.
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_a',
|
||||
user: { handle: 'a-user', displayName: 'A' }
|
||||
})
|
||||
registryB = await startFakeRegistry({
|
||||
token: 'sk_b',
|
||||
user: { handle: 'b-user', displayName: 'B' }
|
||||
})
|
||||
|
||||
const result = await runCli(
|
||||
['whoami', '--registry', registry.url, '--token', 'sk_a'],
|
||||
{ HOME: env.home, USERPROFILE: env.home, SKILLHUB_REGISTRY: registryB.url }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('a-user')
|
||||
})
|
||||
|
||||
test('SKILLHUB_REGISTRY env wins over config.registry', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_env',
|
||||
user: { handle: 'env-reg', displayName: 'EnvReg' }
|
||||
})
|
||||
registryB = await startFakeRegistry({
|
||||
token: 'sk_config',
|
||||
user: { handle: 'config-reg', displayName: 'ConfigReg' }
|
||||
})
|
||||
await seedConfig(env.home, registryB.url)
|
||||
|
||||
const result = await runCli(
|
||||
['whoami', '--token', 'sk_env'],
|
||||
{ HOME: env.home, USERPROFILE: env.home, SKILLHUB_REGISTRY: registry.url }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('env-reg')
|
||||
})
|
||||
|
||||
test('config.registry used when no --registry / env present', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_config',
|
||||
user: { handle: 'config-only-user', displayName: 'CfgOnly' }
|
||||
})
|
||||
await seedConfig(env.home, registry.url)
|
||||
await seedCredentials(env.home, registry.url, 'sk_config')
|
||||
|
||||
const result = await runCli(
|
||||
['whoami'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('config-only-user')
|
||||
})
|
||||
})
|
||||
164
cli/test/integration/concurrency.test.ts
Normal file
164
cli/test/integration/concurrency.test.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* Concurrency tests for inventory.json bookkeeping.
|
||||
*
|
||||
* inventory-store.ts uses an OS-level lock file with retry + stale-lock
|
||||
* detection. These tests exercise that path through real CLI subprocesses
|
||||
* (Bun.spawn) running in parallel — the same way users hit it when scripts
|
||||
* fan out installs.
|
||||
*
|
||||
* The unit test in test/unit/stores/inventory-store.test.ts pins the
|
||||
* single-process lock recovery; here we cover the cross-process case.
|
||||
*/
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { zipSync, strToU8 } from 'fflate'
|
||||
import { startFakeRegistry } from '../helpers/fake-registry'
|
||||
import { runCli } from '../helpers/run-cli'
|
||||
import { createTempHome } from '../helpers/temp-env'
|
||||
|
||||
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
||||
|
||||
afterEach(() => {
|
||||
registry?.stop(); registry = undefined
|
||||
})
|
||||
|
||||
function makeSkillZip(): Uint8Array {
|
||||
return zipSync({ 'SKILL.md': strToU8('# c') })
|
||||
}
|
||||
|
||||
describe('cross-process concurrency on inventory.json', () => {
|
||||
// KNOWN BUG (documented here, not yet fixed):
|
||||
// inventory-store.upsertTarget() reads inventory, modifies in memory,
|
||||
// then writeAtomic() acquires the lock only over the write half. Two
|
||||
// concurrent installs each read the (empty) inventory, each adds their
|
||||
// own item, and the second writer overwrites the first — a classic
|
||||
// lost-update.
|
||||
//
|
||||
// When the fix lands (lock spans read+write, or upsertTarget acquires
|
||||
// the lock first and re-reads), tighten the inventory assertion to
|
||||
// `expect(slugs).toEqual(['first', 'second'])`.
|
||||
test('two parallel installs of distinct slugs: filesystem is correct, inventory has at least one (lost-update bug pinned)', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [
|
||||
{ namespace: 'global', slug: 'first', version: '1.0.0', zipBytes: makeSkillZip() },
|
||||
{ namespace: 'global', slug: 'second', version: '1.0.0', zipBytes: makeSkillZip() }
|
||||
]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const dirA = join(env.cwd, 'A')
|
||||
const dirB = join(env.cwd, 'B')
|
||||
await mkdir(dirA, { recursive: true })
|
||||
await mkdir(dirB, { recursive: true })
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
runCli(
|
||||
['install', 'first', '--dir', dirA, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
),
|
||||
runCli(
|
||||
['install', 'second', '--dir', dirB, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
])
|
||||
|
||||
// Both subprocess installs report success — neither errored at the
|
||||
// protocol level even though the inventory bookkeeping race ate one of
|
||||
// their inventory writes.
|
||||
expect(r1.exitCode).toBe(0)
|
||||
expect(r2.exitCode).toBe(0)
|
||||
|
||||
// Filesystem is correct: both bundles extracted independently.
|
||||
expect(await Bun.file(join(dirA, 'first', 'SKILL.md')).exists()).toBe(true)
|
||||
expect(await Bun.file(join(dirB, 'second', 'SKILL.md')).exists()).toBe(true)
|
||||
|
||||
const inv = JSON.parse(
|
||||
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
|
||||
) as { items: Array<{ slug: string }> }
|
||||
const slugs = inv.items.map(i => i.slug).sort()
|
||||
// Today: at least one slug always lands; under the lost-update race
|
||||
// both may NOT be there. When the lock widens to cover read+write,
|
||||
// upgrade this to `toEqual(['first', 'second'])`.
|
||||
expect(slugs.length).toBeGreaterThanOrEqual(1)
|
||||
const lastSlug = slugs[slugs.length - 1]!
|
||||
expect(['first', 'second']).toContain(lastSlug)
|
||||
})
|
||||
|
||||
test('two parallel installs of the same slug to the same dir: exactly one wins, one conflicts', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'race', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'race-dir')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
runCli(
|
||||
['install', 'race', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
),
|
||||
runCli(
|
||||
['install', 'race', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
])
|
||||
|
||||
// Two valid outcomes: (a) both succeed because the loser's existence
|
||||
// check ran BEFORE the winner extracted, OR (b) one succeeds and the
|
||||
// other reports already-installed (EXIT.filesystem).
|
||||
// Either way, inventory must end up coherent (single item, single
|
||||
// target — no duplicates).
|
||||
const codes = [r1.exitCode, r2.exitCode].sort((a, b) => a - b)
|
||||
expect(codes[0]).toBe(0) // at least one succeeded
|
||||
const otherCode = codes[1]!
|
||||
expect([0, 4]).toContain(otherCode) // other either succeeded or got conflict
|
||||
|
||||
const inv = JSON.parse(
|
||||
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
|
||||
) as { items: Array<{ slug: string; targets: Array<{ installDir: string }> }> }
|
||||
const item = inv.items.find(i => i.slug === 'race')
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.targets).toHaveLength(1) // no duplicate targets
|
||||
})
|
||||
|
||||
test('install proceeds after a stale lock file from a dead process', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'after-stale', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
// Plant a stale lock file: PID 1 (init, never the same as our test
|
||||
// child, and won't match the spawned subprocess's PID), with a very
|
||||
// old timestamp so the store treats it as stale.
|
||||
const skillhubDir = join(env.home, '.skillhub')
|
||||
await mkdir(skillhubDir, { recursive: true })
|
||||
const lockPath = join(skillhubDir, 'inventory.json.lock')
|
||||
const ancientTimestamp = Date.now() - 600_000 // 10 minutes ago — past the 30s stale threshold
|
||||
await writeFile(lockPath, JSON.stringify({ pid: 1, timestamp: ancientTimestamp }))
|
||||
|
||||
const installDir = join(env.cwd, 'stale')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'after-stale', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
const inv = JSON.parse(
|
||||
await readFile(join(skillhubDir, 'inventory.json'), 'utf-8')
|
||||
) as { items: Array<{ slug: string }> }
|
||||
expect(inv.items.find(i => i.slug === 'after-stale')).toBeDefined()
|
||||
})
|
||||
})
|
||||
509
cli/test/integration/cross-command.test.ts
Normal file
509
cli/test/integration/cross-command.test.ts
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
/**
|
||||
* Cross-command flow tests.
|
||||
*
|
||||
* Per-command tests verify each subcommand in isolation. These cases pin
|
||||
* behaviors that only emerge when commands chain — e.g. "logout then install
|
||||
* fails with auth" or "install + fs-delete + list reports status=missing".
|
||||
* Bugs in the boundaries between commands (shared inventory, credentials,
|
||||
* config) tend to slip through single-command suites.
|
||||
*/
|
||||
import { mkdir, rm, writeFile, readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { zipSync, strToU8 } from 'fflate'
|
||||
import { startFakeRegistry } from '../helpers/fake-registry'
|
||||
import { runCli } from '../helpers/run-cli'
|
||||
import { createTempHome } from '../helpers/temp-env'
|
||||
|
||||
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
||||
let registryB: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
||||
|
||||
afterEach(() => {
|
||||
registry?.stop(); registry = undefined
|
||||
registryB?.stop(); registryB = undefined
|
||||
})
|
||||
|
||||
function makeSkillZip(): Uint8Array {
|
||||
return zipSync({ 'SKILL.md': strToU8('# x-cross') })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Auth lifecycle: login → whoami → logout → whoami
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cross-command — auth lifecycle', () => {
|
||||
test('login → whoami(success) → logout → whoami(not logged in)', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'cycle-user', displayName: 'Cycle' }
|
||||
})
|
||||
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
const w1 = await runCli(['whoami', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
|
||||
expect(w1.exitCode).toBe(0)
|
||||
expect(w1.stdout).toContain('cycle-user')
|
||||
|
||||
await runCli(['logout', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
|
||||
const w2 = await runCli(['whoami', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
|
||||
expect(w2.exitCode).toBe(2)
|
||||
expect(w2.stderr.toLowerCase()).toContain('not logged in')
|
||||
})
|
||||
|
||||
test('logout-then-install against an auth-required registry fails with EXIT.auth', async () => {
|
||||
const env = await createTempHome()
|
||||
// Inject auth failure on resolve so this fake server behaves like a
|
||||
// production registry that requires a bearer token even on resolve.
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
failures: { resolve: 'auth' }
|
||||
})
|
||||
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
await runCli(['logout', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'after-logout')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
// No --token here — credentials were just cleared by logout.
|
||||
const result = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(2) // EXIT.auth
|
||||
expect(result.stderr.toLowerCase()).toMatch(/auth|401|unauthorized/)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Full local lifecycle: install → list → remove → list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cross-command — local lifecycle', () => {
|
||||
test('install → list → remove --all → list shows empty', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'lifecycle')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
const list1 = await runCli(
|
||||
['list', '--registry', registry.url, '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(JSON.parse(list1.stdout).items).toHaveLength(1)
|
||||
|
||||
await runCli(
|
||||
['remove', 'pdf-parser', '--all', '--registry', registry.url],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
const list2 = await runCli(
|
||||
['list', '--registry', registry.url, '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(JSON.parse(list2.stdout).items).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('install x2 same slug + same dir without --force conflicts; --force succeeds; second install replaces first', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'reinstall-here')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const r1 = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(r1.exitCode).toBe(0)
|
||||
|
||||
const r2 = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(r2.exitCode).toBe(4) // EXIT.filesystem (already installed)
|
||||
|
||||
const r3 = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(r3.exitCode).toBe(0)
|
||||
|
||||
// Inventory has exactly one target, not two duplicates.
|
||||
const inv = JSON.parse(
|
||||
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
|
||||
) as { items: Array<{ slug: string; targets: Array<{ installDir: string }> }> }
|
||||
const item = inv.items.find(i => i.slug === 'pdf-parser')
|
||||
expect(item?.targets).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('install A then install B (different slugs, same parent dir) → list shows both', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [
|
||||
{ namespace: 'global', slug: 'a-skill', version: '1.0.0', zipBytes: makeSkillZip() },
|
||||
{ namespace: 'global', slug: 'b-skill', version: '1.0.0', zipBytes: makeSkillZip() }
|
||||
]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'two-skills')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
await runCli(
|
||||
['install', 'a-skill', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
await runCli(
|
||||
['install', 'b-skill', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
const list = await runCli(
|
||||
['list', '--registry', registry.url, '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
const items = JSON.parse(list.stdout).items as Array<{ slug: string }>
|
||||
expect(items.map(i => i.slug).sort()).toEqual(['a-skill', 'b-skill'])
|
||||
})
|
||||
|
||||
test('remove --all → install same slug again succeeds (no stale inventory state)', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'reuse')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
await runCli(['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
await runCli(['remove', 'pdf-parser', '--all', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
// Re-install at the same dir without --force should now succeed, since
|
||||
// the previous install was removed.
|
||||
const reinstall = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(reinstall.exitCode).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Filesystem drift between install dir and inventory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cross-command — filesystem drift', () => {
|
||||
test('install → fs-delete the install dir → list reports status=missing', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'drift')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
// External clobber: delete the install dir behind the CLI's back.
|
||||
await rm(join(installDir, 'pdf-parser'), { recursive: true, force: true })
|
||||
|
||||
const list = await runCli(
|
||||
['list', '--registry', registry.url, '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(list.exitCode).toBe(0)
|
||||
const items = JSON.parse(list.stdout).items as Array<{ slug: string; status: string }>
|
||||
expect(items[0]?.slug).toBe('pdf-parser')
|
||||
expect(items[0]?.status).toBe('missing')
|
||||
})
|
||||
|
||||
// After commit a14d89d8 ("refactor(cli): improve doctor command
|
||||
// semantics and transparency") doctor switched from REPLACE to MERGE
|
||||
// semantics: it never removes inventory entries, even when the install
|
||||
// dir on disk is gone. Stale entries are surfaced via `list --json`'s
|
||||
// status="missing" instead. This test pins that contract.
|
||||
test('install → fs-delete the install dir → doctor preserves the entry; list reports status=missing', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
// Install into an agent-shaped dir under cwd so doctor will scan it.
|
||||
const codexSkills = join(env.cwd, '.codex', 'skills')
|
||||
await mkdir(codexSkills, { recursive: true })
|
||||
await runCli(
|
||||
['install', 'pdf-parser', '--dir', codexSkills, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
// Wipe the install but leave the dir tree shape — metadata gone.
|
||||
await rm(join(codexSkills, 'pdf-parser'), { recursive: true, force: true })
|
||||
|
||||
const doctor = await runCli(['doctor', '--json'], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd })
|
||||
expect(doctor.exitCode).toBe(0)
|
||||
|
||||
// Inventory still has the entry — doctor preserved it because the
|
||||
// installDir was NOT in the (now-empty) scan result.
|
||||
const inv = JSON.parse(
|
||||
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
|
||||
) as { items: Array<{ slug: string }> }
|
||||
expect(inv.items.find(i => i.slug === 'pdf-parser')).toBeDefined()
|
||||
|
||||
// The user-facing surface for "this is gone on disk" is `list` — it
|
||||
// reports status="missing" by stat'ing the installDir at read time.
|
||||
const list = await runCli(
|
||||
['list', '--registry', registry.url, '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
const items = JSON.parse(list.stdout).items as Array<{ slug: string; status: string }>
|
||||
expect(items.find(i => i.slug === 'pdf-parser')?.status).toBe('missing')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. doctor idempotence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cross-command — doctor idempotence', () => {
|
||||
test('two consecutive doctor runs produce identical inventory (idempotent)', async () => {
|
||||
const env = await createTempHome()
|
||||
|
||||
// Seed one valid metadata file.
|
||||
const metaDir = join(env.cwd, '.codex', 'skills', 'pdf-parser', '.skillhub')
|
||||
await mkdir(metaDir, { recursive: true })
|
||||
await writeFile(join(metaDir, 'metadata.json'), JSON.stringify({
|
||||
registry: 'https://skill.xfyun.cn',
|
||||
namespace: 'global',
|
||||
slug: 'pdf-parser',
|
||||
version: '1.0.0',
|
||||
agent: 'codex',
|
||||
installedAt: '2026-04-20T12:00:00Z'
|
||||
}))
|
||||
|
||||
await runCli(['doctor'], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd })
|
||||
const after1 = await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
|
||||
|
||||
await runCli(['doctor'], { HOME: env.home, USERPROFILE: env.home }, { cwd: env.cwd })
|
||||
const after2 = await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
|
||||
|
||||
expect(after2).toBe(after1)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. publish does not change local inventory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cross-command — publish vs local inventory', () => {
|
||||
test('publish does NOT add the published skill to local inventory', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({ token: 'sk_ok', user: { handle: 'u', displayName: 'U' } })
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
// Build a tiny skill dir to publish.
|
||||
const dir = join(env.cwd, 'src-skill')
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, 'SKILL.md'), '---\nname: pub-only\ndescription: x\n---\n# pub-only')
|
||||
|
||||
const pub = await runCli(['publish', dir, '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home })
|
||||
expect(pub.exitCode).toBe(0)
|
||||
|
||||
const list = await runCli(
|
||||
['list', '--registry', registry.url, '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(list.exitCode).toBe(0)
|
||||
expect(JSON.parse(list.stdout).items).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Cross-registry isolation in queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cross-command — cross-registry isolation', () => {
|
||||
test('list scoped to registry A does not show items installed from registry B', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_a',
|
||||
user: { handle: 'a', displayName: 'A' },
|
||||
skills: [{ namespace: 'global', slug: 'a-only', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
registryB = await startFakeRegistry({
|
||||
token: 'sk_b',
|
||||
user: { handle: 'b', displayName: 'B' },
|
||||
skills: [{ namespace: 'global', slug: 'b-only', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_a'], { HOME: env.home, USERPROFILE: env.home })
|
||||
await runCli(['login', '--registry', registryB.url, '--token', 'sk_b'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const dirA = join(env.cwd, 'A')
|
||||
const dirB = join(env.cwd, 'B')
|
||||
await mkdir(dirA, { recursive: true })
|
||||
await mkdir(dirB, { recursive: true })
|
||||
|
||||
await runCli(['install', 'a-only', '--dir', dirA, '--registry', registry.url, '--token', 'sk_a'], { HOME: env.home, USERPROFILE: env.home })
|
||||
await runCli(['install', 'b-only', '--dir', dirB, '--registry', registryB.url, '--token', 'sk_b'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const listA = await runCli(['list', '--registry', registry.url, '--json'], { HOME: env.home, USERPROFILE: env.home })
|
||||
const slugsA = (JSON.parse(listA.stdout).items as Array<{ slug: string }>).map(i => i.slug)
|
||||
expect(slugsA).toEqual(['a-only'])
|
||||
|
||||
const listB = await runCli(['list', '--registry', registryB.url, '--json'], { HOME: env.home, USERPROFILE: env.home })
|
||||
const slugsB = (JSON.parse(listB.stdout).items as Array<{ slug: string }>).map(i => i.slug)
|
||||
expect(slugsB).toEqual(['b-only'])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Auto-detect + list filter integration (project-level)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cross-command — auto-detect + list', () => {
|
||||
test('install auto-detects project-level .codex; subsequent list --agent codex shows it', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
// Pre-create .codex/skills so auto-detect picks codex/project-level.
|
||||
await mkdir(join(env.cwd, '.codex', 'skills'), { recursive: true })
|
||||
|
||||
const inst = await runCli(
|
||||
['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
expect(inst.exitCode).toBe(0)
|
||||
|
||||
const list = await runCli(
|
||||
['list', '--agent', 'codex', '--registry', registry.url, '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(list.exitCode).toBe(0)
|
||||
const items = JSON.parse(list.stdout).items as Array<{ slug: string; agent: string }>
|
||||
expect(items.some(i => i.slug === 'pdf-parser' && i.agent === 'codex')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. Inventory metadata corruption resilience after install
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cross-command — metadata.json drift', () => {
|
||||
test('install → manually corrupt metadata.json → list reports the row but with sane handling', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'meta-drift')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
// Corrupt the installed metadata. inventory.json (the authoritative
|
||||
// source for `list`) is untouched, so `list` should still work.
|
||||
await writeFile(
|
||||
join(installDir, 'pdf-parser', '.skillhub', 'metadata.json'),
|
||||
'{ truncated'
|
||||
)
|
||||
|
||||
const list = await runCli(
|
||||
['list', '--registry', registry.url, '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(list.exitCode).toBe(0)
|
||||
const items = JSON.parse(list.stdout).items as Array<{ slug: string; status: string }>
|
||||
expect(items[0]?.slug).toBe('pdf-parser')
|
||||
// Status remains "ok" because list uses inventory.json, not metadata.json.
|
||||
expect(items[0]?.status).toBe('ok')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. Registry priority chain end-to-end
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cross-command — registry priority end-to-end', () => {
|
||||
test('search uses --registry over SKILLHUB_REGISTRY env over default', async () => {
|
||||
registry = await startFakeRegistry({
|
||||
searchItems: [{ namespace: 'global', slug: 'wins', latestVersion: '1.0.0', summary: 'right one' }]
|
||||
})
|
||||
registryB = await startFakeRegistry({
|
||||
searchItems: [{ namespace: 'global', slug: 'loses', latestVersion: '1.0.0', summary: 'wrong one' }]
|
||||
})
|
||||
|
||||
const result = await runCli(
|
||||
['search', '', '--registry', registry.url, '--json'],
|
||||
{ SKILLHUB_REGISTRY: registryB.url }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const items = JSON.parse(result.stdout).items as Array<{ slug: string }>
|
||||
expect(items.map(i => i.slug)).toEqual(['wins'])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. Help / Version ergonomics across commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('cross-command — help reaches every documented command', () => {
|
||||
test('every command listed in help responds to --help with non-empty body', async () => {
|
||||
const helpResult = await runCli(['help'])
|
||||
expect(helpResult.exitCode).toBe(0)
|
||||
|
||||
const commandNames = [
|
||||
'help', 'version', 'login', 'logout', 'whoami',
|
||||
'search', 'install', 'list', 'remove', 'doctor',
|
||||
'publish', 'update'
|
||||
]
|
||||
for (const cmd of commandNames) {
|
||||
expect(helpResult.stdout).toContain(cmd)
|
||||
const sub = await runCli([cmd, '--help'])
|
||||
// --help exits 0 for cac-style CLIs; we don't insist on that, just
|
||||
// that some informative output makes it to stdout.
|
||||
expect(sub.stdout.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -143,6 +143,139 @@ describe('doctor command', () => {
|
|||
expect(json.inventoryPath).toContain('inventory.json')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1: same registry+namespace+slug appearing in two agent dirs with
|
||||
// different versions surfaces in `conflicts` and is excluded from items.
|
||||
// -------------------------------------------------------------------------
|
||||
test('doctor reports conflicts when two agent dirs disagree on version', async () => {
|
||||
const { home, cwd } = await createTempHome()
|
||||
|
||||
// Two installs of the same global/pdf-parser with mismatched versions.
|
||||
await seedSkill(cwd, {
|
||||
agentDir: '.codex',
|
||||
slug: 'pdf-parser',
|
||||
metadata: {
|
||||
registry: 'https://skill.xfyun.cn',
|
||||
namespace: 'global',
|
||||
slug: 'pdf-parser',
|
||||
version: '1.0.0',
|
||||
agent: 'codex',
|
||||
installedAt: '2026-04-20T12:00:00Z'
|
||||
}
|
||||
})
|
||||
await seedSkill(cwd, {
|
||||
agentDir: '.claude',
|
||||
slug: 'pdf-parser',
|
||||
metadata: {
|
||||
registry: 'https://skill.xfyun.cn',
|
||||
namespace: 'global',
|
||||
slug: 'pdf-parser',
|
||||
version: '2.0.0',
|
||||
agent: 'claude-code',
|
||||
installedAt: '2026-04-21T09:00:00Z'
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runCli(['doctor', '--json'], {
|
||||
HOME: home,
|
||||
USERPROFILE: home
|
||||
}, { cwd })
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const json = JSON.parse(result.stdout) as {
|
||||
ok: boolean
|
||||
itemsScanned: number
|
||||
targetsScanned: number
|
||||
conflicts: Array<{ key: string; versions: string[] }>
|
||||
}
|
||||
expect(json.ok).toBe(true)
|
||||
// Conflicting group is dropped from items, recorded as a conflict.
|
||||
expect(json.itemsScanned).toBe(0)
|
||||
expect(json.targetsScanned).toBe(0)
|
||||
expect(json.conflicts).toHaveLength(1)
|
||||
expect(json.conflicts[0]?.key).toBe('https://skill.xfyun.cn|global|pdf-parser')
|
||||
expect(json.conflicts[0]?.versions.sort()).toEqual(['1.0.0', '2.0.0'])
|
||||
|
||||
// The persisted inventory must mirror the JSON output: no items.
|
||||
const inventory = JSON.parse(
|
||||
await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8')
|
||||
) as { items: unknown[] }
|
||||
expect(inventory.items).toHaveLength(0)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1: malformed metadata (unparseable JSON, or missing required fields)
|
||||
// is reported in `skipped` and does not produce inventory entries. Two
|
||||
// distinct failure modes are seeded to exercise both branches in
|
||||
// scanMetadata: JSON.parse throw and the post-parse field check.
|
||||
// -------------------------------------------------------------------------
|
||||
test('doctor reports skipped entries for malformed and incomplete metadata', async () => {
|
||||
const { home, cwd } = await createTempHome()
|
||||
|
||||
// (1) Bad JSON: triggers the catch around JSON.parse → "no .skillhub/metadata.json"
|
||||
// because the catch block is shared with the readFile failure path.
|
||||
const badJsonDir = join(cwd, '.codex', 'skills', 'broken-json', '.skillhub')
|
||||
await mkdir(badJsonDir, { recursive: true })
|
||||
await writeFile(join(badJsonDir, 'metadata.json'), '{ this is not json')
|
||||
|
||||
// (2) Incomplete fields: parses fine but is missing `version`.
|
||||
const incompleteDir = join(cwd, '.claude', 'skills', 'incomplete', '.skillhub')
|
||||
await mkdir(incompleteDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(incompleteDir, 'metadata.json'),
|
||||
JSON.stringify({
|
||||
registry: 'https://skill.xfyun.cn',
|
||||
namespace: 'global',
|
||||
slug: 'incomplete',
|
||||
// version intentionally missing
|
||||
agent: 'claude-code',
|
||||
installedAt: '2026-04-22T10:00:00Z'
|
||||
})
|
||||
)
|
||||
|
||||
// (3) A valid sibling so we can prove skipped entries don't poison the
|
||||
// surrounding scan — the valid skill should still land in inventory.
|
||||
await seedSkill(cwd, {
|
||||
agentDir: '.codex',
|
||||
slug: 'good-skill',
|
||||
metadata: {
|
||||
registry: 'https://skill.xfyun.cn',
|
||||
namespace: 'global',
|
||||
slug: 'good-skill',
|
||||
version: '1.0.0',
|
||||
agent: 'codex',
|
||||
installedAt: '2026-04-22T10:00:00Z'
|
||||
}
|
||||
})
|
||||
|
||||
const result = await runCli(['doctor', '--json'], {
|
||||
HOME: home,
|
||||
USERPROFILE: home
|
||||
}, { cwd })
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const json = JSON.parse(result.stdout) as {
|
||||
ok: boolean
|
||||
itemsScanned: number
|
||||
skipped: Array<{ path: string; reason: string }>
|
||||
}
|
||||
expect(json.ok).toBe(true)
|
||||
|
||||
// Both broken entries should be in skipped, the good one in items.
|
||||
const broken = json.skipped.find(s => s.path.endsWith('broken-json'))
|
||||
expect(broken).toBeDefined()
|
||||
const incomplete = json.skipped.find(s => s.path.endsWith('incomplete'))
|
||||
expect(incomplete).toBeDefined()
|
||||
expect(incomplete?.reason).toContain('incomplete')
|
||||
|
||||
expect(json.itemsScanned).toBe(1) // only good-skill
|
||||
const inventory = JSON.parse(
|
||||
await readFile(join(home, '.skillhub', 'inventory.json'), 'utf-8')
|
||||
) as { items: Array<{ slug: string }> }
|
||||
expect(inventory.items).toHaveLength(1)
|
||||
expect(inventory.items[0]?.slug).toBe('good-skill')
|
||||
})
|
||||
|
||||
test('doctor backs up existing inventory.json and reports backupPath', async () => {
|
||||
const { home, cwd } = await createTempHome()
|
||||
|
||||
|
|
@ -227,4 +360,104 @@ describe('doctor command', () => {
|
|||
expect.arrayContaining(['external-skill', 'local-skill'])
|
||||
)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1 — Symlink safety: doctor must skip (not follow) symlinked agent /
|
||||
// skill / .skillhub directories. This protects against malicious or
|
||||
// accidental symlinks that would otherwise let metadata be slurped from
|
||||
// arbitrary filesystem locations.
|
||||
// -------------------------------------------------------------------------
|
||||
test('doctor skips an agent dir that is a symlink', async () => {
|
||||
const { home, cwd } = await createTempHome()
|
||||
const { symlink, mkdir: mkdirP } = await import('node:fs/promises')
|
||||
|
||||
// Real target with a valid metadata file off in /tmp.
|
||||
const realRoot = join(cwd, '__real__', '.codex', 'skills', 'pdf-parser', '.skillhub')
|
||||
await mkdirP(realRoot, { recursive: true })
|
||||
await writeFile(join(realRoot, 'metadata.json'), JSON.stringify({
|
||||
registry: 'https://skill.xfyun.cn', namespace: 'global', slug: 'pdf-parser',
|
||||
version: '1.0.0', agent: 'codex', installedAt: '2026-04-20T12:00:00Z'
|
||||
}))
|
||||
|
||||
// Symlink ./.codex -> __real__/.codex inside cwd. Doctor scans cwd.
|
||||
await symlink(join(cwd, '__real__', '.codex'), join(cwd, '.codex'))
|
||||
|
||||
const result = await runCli(['doctor', '--json'], {
|
||||
HOME: home, USERPROFILE: home
|
||||
}, { cwd })
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const json = JSON.parse(result.stdout) as {
|
||||
itemsScanned: number
|
||||
skipped: Array<{ path: string; reason: string }>
|
||||
}
|
||||
// The symlinked agent dir must NOT contribute an inventory item.
|
||||
expect(json.itemsScanned).toBe(0)
|
||||
expect(json.skipped.some(s => s.path.endsWith('.codex') && s.reason.includes('regular directory'))).toBe(true)
|
||||
})
|
||||
|
||||
test('doctor skips a slug dir that is a symlink (real agent dir, symlinked slug)', async () => {
|
||||
const { home, cwd } = await createTempHome()
|
||||
const { symlink, mkdir: mkdirP } = await import('node:fs/promises')
|
||||
|
||||
// Real metadata under cwd/__real__/pdf-parser/.skillhub/
|
||||
const realSlug = join(cwd, '__real__', 'pdf-parser')
|
||||
const realSkillhub = join(realSlug, '.skillhub')
|
||||
await mkdirP(realSkillhub, { recursive: true })
|
||||
await writeFile(join(realSkillhub, 'metadata.json'), JSON.stringify({
|
||||
registry: 'https://skill.xfyun.cn', namespace: 'global', slug: 'pdf-parser',
|
||||
version: '1.0.0', agent: 'codex', installedAt: '2026-04-20T12:00:00Z'
|
||||
}))
|
||||
|
||||
// .codex/skills exists as a real dir, but pdf-parser inside it is a
|
||||
// symlink to the real metadata location.
|
||||
const skillsDir = join(cwd, '.codex', 'skills')
|
||||
await mkdirP(skillsDir, { recursive: true })
|
||||
await symlink(realSlug, join(skillsDir, 'pdf-parser'))
|
||||
|
||||
const result = await runCli(['doctor', '--json'], {
|
||||
HOME: home, USERPROFILE: home
|
||||
}, { cwd })
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const json = JSON.parse(result.stdout) as {
|
||||
itemsScanned: number
|
||||
skipped: Array<{ path: string; reason: string }>
|
||||
}
|
||||
expect(json.itemsScanned).toBe(0)
|
||||
const symlinked = json.skipped.find(s => s.path.endsWith('pdf-parser'))
|
||||
expect(symlinked).toBeDefined()
|
||||
expect(symlinked?.reason).toContain('regular directory')
|
||||
})
|
||||
|
||||
test('doctor skips a .skillhub dir that is a symlink', async () => {
|
||||
const { home, cwd } = await createTempHome()
|
||||
const { symlink, mkdir: mkdirP } = await import('node:fs/promises')
|
||||
|
||||
// Real metadata reachable through a symlinked .skillhub directory.
|
||||
const realSkillhub = join(cwd, '__real_meta__')
|
||||
await mkdirP(realSkillhub, { recursive: true })
|
||||
await writeFile(join(realSkillhub, 'metadata.json'), JSON.stringify({
|
||||
registry: 'https://skill.xfyun.cn', namespace: 'global', slug: 'pdf-parser',
|
||||
version: '1.0.0', agent: 'codex', installedAt: '2026-04-20T12:00:00Z'
|
||||
}))
|
||||
|
||||
const slugDir = join(cwd, '.codex', 'skills', 'pdf-parser')
|
||||
await mkdirP(slugDir, { recursive: true })
|
||||
await symlink(realSkillhub, join(slugDir, '.skillhub'))
|
||||
|
||||
const result = await runCli(['doctor', '--json'], {
|
||||
HOME: home, USERPROFILE: home
|
||||
}, { cwd })
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const json = JSON.parse(result.stdout) as {
|
||||
itemsScanned: number
|
||||
skipped: Array<{ path: string; reason: string }>
|
||||
}
|
||||
expect(json.itemsScanned).toBe(0)
|
||||
const skipped = json.skipped.find(s => s.path.endsWith('pdf-parser'))
|
||||
expect(skipped).toBeDefined()
|
||||
expect(skipped?.reason.toLowerCase()).toMatch(/skillhub|regular directory/)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { mkdir, readFile } from 'node:fs/promises'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { zipSync, strToU8 } from 'fflate'
|
||||
|
|
@ -273,6 +273,543 @@ describe('install command — P1', () => {
|
|||
// -------------------------------------------------------------------------
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P0/P1 — Conflict & --force handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('install command — conflict and --force', () => {
|
||||
test('re-installing without --force into an existing dir errors with EXIT.filesystem', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'skills-conflict')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const first = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(first.exitCode).toBe(0)
|
||||
|
||||
const second = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(second.exitCode).toBe(4) // EXIT.filesystem
|
||||
expect(second.stderr).toContain('already installed')
|
||||
expect(second.stderr).toContain('--force')
|
||||
})
|
||||
|
||||
test('--force overwrites stale files left in the install dir', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'skills-force')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
// Tamper with SKILL.md to prove the second install replaces it.
|
||||
const skillFile = join(installDir, 'pdf-parser', 'SKILL.md')
|
||||
await writeFile(skillFile, '# tampered content')
|
||||
expect(await readFile(skillFile, 'utf-8')).toBe('# tampered content')
|
||||
|
||||
const forced = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(forced.exitCode).toBe(0)
|
||||
expect(await readFile(skillFile, 'utf-8')).toBe('# test skill')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P1 — Server-side error mapping during install
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('install command — server errors', () => {
|
||||
test('resolve 404 surfaces an error and aborts install (no metadata.json written)', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
failures: { resolve: 'not_found' }
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'skills-resolve-404')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'no-such-slug', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/404|not found/i)
|
||||
|
||||
// No metadata file should have been created at the install destination.
|
||||
const metaPath = join(installDir, 'no-such-slug', '.skillhub', 'metadata.json')
|
||||
expect(await Bun.file(metaPath).exists()).toBe(false)
|
||||
})
|
||||
|
||||
// Regression test for the production bug observed on 2026-05-06: server
|
||||
// marks `bundle_ready=true` in DB but the bundle file is missing on disk.
|
||||
// /resolve returns 200 with a downloadUrl, then /download returns 404. The
|
||||
// CLI must surface a non-zero exit and a meaningful stderr — not silently
|
||||
// succeed with an empty install dir.
|
||||
test('download 404 (resolve OK) is reported as a download failure', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
// resolve succeeds (skill is present in fixture list) but the download
|
||||
// endpoint is forced to 404 to simulate a missing bundle on storage.
|
||||
skills: [{ namespace: 'global', slug: 'orphan-bundle', version: '1.0.0', zipBytes: makeSkillZip() }],
|
||||
failures: { download: 'not_found' }
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'skills-bundle-missing')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'orphan-bundle', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr.toLowerCase()).toMatch(/download|404|not found/)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1 — Path safety: install only writes inside <dir>/<slug>/
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
test('install only writes inside <dir>/<slug>/ — sibling files in <dir> are untouched', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'shared-dir')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
// Place an unrelated file as a sibling of the future <slug>/ subdir.
|
||||
const sibling = join(installDir, 'IMPORTANT.txt')
|
||||
await writeFile(sibling, 'this file must survive install')
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
// Sibling file must still exist with original content.
|
||||
expect(await readFile(sibling, 'utf-8')).toBe('this file must survive install')
|
||||
// <slug>/ subdir created.
|
||||
expect(await Bun.file(join(installDir, 'pdf-parser', 'SKILL.md')).exists()).toBe(true)
|
||||
})
|
||||
|
||||
test('--force re-install does not touch sibling files in <dir>', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'shared-force')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
// After first install, drop a sibling file; --force should not delete it.
|
||||
const sibling = join(installDir, 'sibling-after-install.bin')
|
||||
await writeFile(sibling, 'sentinel')
|
||||
|
||||
const r2 = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(r2.exitCode).toBe(0)
|
||||
expect(await readFile(sibling, 'utf-8')).toBe('sentinel')
|
||||
})
|
||||
|
||||
test('install --dir creates the <slug> subdir even when <dir> is empty', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'empty-dir')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(await Bun.file(join(installDir, 'pdf-parser', 'SKILL.md')).exists()).toBe(true)
|
||||
expect(await Bun.file(join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')).exists()).toBe(true)
|
||||
})
|
||||
|
||||
test('install --dir pointing at a regular file (not a directory) fails before download', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
// Create a file at the location --dir would otherwise treat as a directory.
|
||||
const filePath = join(env.cwd, 'not-a-dir')
|
||||
await writeFile(filePath, 'i am a file, not a dir')
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'pdf-parser', '--dir', filePath, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
// Original file must still be unchanged (the install should not have
|
||||
// scribbled on it before bailing).
|
||||
expect(await readFile(filePath, 'utf-8')).toBe('i am a file, not a dir')
|
||||
})
|
||||
|
||||
test('--json emits a parseable error envelope when install fails', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
failures: { resolve: 'not_found' }
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'skills-json-error')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'no-such-slug', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
// JSON error envelope is printed to stdout (or stderr, depending on the
|
||||
// command); we accept either to keep the test resilient to that choice.
|
||||
const candidate = result.stdout || result.stderr
|
||||
const json = JSON.parse(candidate) as {
|
||||
ok: boolean
|
||||
message: string
|
||||
exitCode: number
|
||||
}
|
||||
expect(json.ok).toBe(false)
|
||||
expect(typeof json.message).toBe('string')
|
||||
expect(json.exitCode).toBe(result.exitCode)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P1 — Multi-agent and auto-detect targeting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('install command — multi-agent & auto-detect', () => {
|
||||
test('multi --agent installs the same skill into every specified user-level dir', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const result = await runCli(
|
||||
[
|
||||
'install', 'pdf-parser',
|
||||
'--agent', 'codex',
|
||||
'--agent', 'claude-code',
|
||||
'--registry', registry.url,
|
||||
'--token', 'sk_ok',
|
||||
'--json'
|
||||
],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const parsed = JSON.parse(result.stdout) as { installed: Array<{ agent: string }> }
|
||||
const agents = parsed.installed.map(t => t.agent).sort()
|
||||
expect(agents).toEqual(['claude-code', 'codex'])
|
||||
|
||||
// Both metadata files exist on disk under user-level <home>/.<agent>/skills.
|
||||
expect(await Bun.file(join(env.home, '.codex', 'skills', 'pdf-parser', '.skillhub', 'metadata.json')).exists()).toBe(true)
|
||||
expect(await Bun.file(join(env.home, '.claude', 'skills', 'pdf-parser', '.skillhub', 'metadata.json')).exists()).toBe(true)
|
||||
})
|
||||
|
||||
test('duplicate --agent dedupes to one target', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const result = await runCli(
|
||||
[
|
||||
'install', 'pdf-parser',
|
||||
'--agent', 'codex',
|
||||
'--agent', 'codex',
|
||||
'--registry', registry.url,
|
||||
'--token', 'sk_ok',
|
||||
'--json'
|
||||
],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const parsed = JSON.parse(result.stdout) as { installed: Array<{ agent: string }> }
|
||||
expect(parsed.installed).toHaveLength(1)
|
||||
expect(parsed.installed[0]?.agent).toBe('codex')
|
||||
})
|
||||
|
||||
test('--agent unknown-id surfaces a usage error with hint', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'pdf-parser', '--agent', 'totally-not-a-real-agent', '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(5) // EXIT.usage
|
||||
expect(result.stderr.toLowerCase()).toMatch(/unknown agent|--dir/)
|
||||
})
|
||||
|
||||
test('auto-detect: cwd with only .codex/skills present installs project-level there', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
// Pre-create the codex skills dir so auto-detect picks project scope.
|
||||
await mkdir(join(env.cwd, '.codex', 'skills'), { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
const parsed = JSON.parse(result.stdout) as { installed: Array<{ dir: string; agent: string }> }
|
||||
expect(parsed.installed[0]?.agent).toBe('codex')
|
||||
// On macOS env.cwd may resolve through /private/var/... symlinks; assert
|
||||
// against the structural part of the path instead of an exact prefix.
|
||||
// Use a regex that accepts both Unix (/) and Windows (\) path separators.
|
||||
expect(parsed.installed[0]?.dir).toMatch(/[/\\]\.codex[/\\]skills[/\\]pdf-parser/)
|
||||
expect(parsed.installed[0]?.dir).not.toContain(env.home) // not user-level
|
||||
})
|
||||
|
||||
test('auto-detect: multiple agent dirs in cwd and non-interactive mode fails with hint', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
await mkdir(join(env.cwd, '.codex', 'skills'), { recursive: true })
|
||||
await mkdir(join(env.cwd, '.claude', 'skills'), { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
expect(result.exitCode).toBe(5) // EXIT.usage
|
||||
expect(result.stderr.toLowerCase()).toMatch(/multiple install targets|--agent|--dir/)
|
||||
})
|
||||
|
||||
test('auto-detect: cwd with no agent dirs falls back to .agents/skills', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const parsed = JSON.parse(result.stdout) as { installed: Array<{ dir: string; agent: string }> }
|
||||
expect(parsed.installed[0]?.agent).toBe('generic')
|
||||
expect(parsed.installed[0]?.dir).toContain('.agents')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1 — Bundle integrity: download body that's not a valid zip
|
||||
// -------------------------------------------------------------------------
|
||||
test('download body that is not a valid zip surfaces an extraction error', async () => {
|
||||
const env = await createTempHome()
|
||||
// Stand up a custom server that returns valid resolve JSON but plain
|
||||
// text on download.
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/auth/whoami') {
|
||||
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
|
||||
}
|
||||
const baseUrl = `${url.protocol}//${url.host}`
|
||||
const resolveMatch = url.pathname.match(/^\/api\/cli\/v1\/skills\/([^/]+)\/([^/]+)\/resolve$/)
|
||||
if (resolveMatch && req.method === 'GET') {
|
||||
return Response.json({
|
||||
code: 0,
|
||||
data: {
|
||||
namespace: resolveMatch[1],
|
||||
slug: resolveMatch[2],
|
||||
version: '1.0.0',
|
||||
versionId: 1,
|
||||
fingerprint: 'deadbeef',
|
||||
downloadUrl: `${baseUrl}/api/cli/v1/skills/${resolveMatch[1]}/${resolveMatch[2]}/versions/1.0.0/download`
|
||||
}
|
||||
})
|
||||
}
|
||||
if (url.pathname.includes('/download')) {
|
||||
// NOT a zip — plain text.
|
||||
return new Response('this is plain text, not a zip', {
|
||||
status: 200, headers: { 'Content-Type': 'application/zip' }
|
||||
})
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const url = `http://localhost:${server.port}`
|
||||
await runCli(['login', '--registry', url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'bad-bundle')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
// No metadata should have been written.
|
||||
expect(await Bun.file(join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')).exists()).toBe(false)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P2 — Slug edge cases (Unicode, very long)
|
||||
// -------------------------------------------------------------------------
|
||||
test('slug with non-ASCII characters round-trips through resolve URL (encoded)', async () => {
|
||||
const env = await createTempHome()
|
||||
let resolveUrl = ''
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/auth/whoami') {
|
||||
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
|
||||
}
|
||||
if (url.pathname.includes('/resolve')) {
|
||||
resolveUrl = req.url
|
||||
// Return 404 — we only care that the URL was constructed correctly.
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const url = `http://localhost:${server.port}`
|
||||
await runCli(['login', '--registry', url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'unicode-slug')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
['install', '中文-技能', '--dir', installDir, '--registry', url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
// Server returns 404 — install fails. Just confirm CLI didn't crash
|
||||
// before hitting the server.
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
// The slug must appear URL-percent-encoded in the resolve URL.
|
||||
expect(resolveUrl).toMatch(/%E4%B8%AD%E6%96%87/)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('slug 200+ characters is forwarded as-is to /resolve (server is authoritative)', async () => {
|
||||
const env = await createTempHome()
|
||||
let resolveUrl = ''
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/auth/whoami') {
|
||||
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
|
||||
}
|
||||
if (url.pathname.includes('/resolve')) {
|
||||
resolveUrl = req.url
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const url = `http://localhost:${server.port}`
|
||||
await runCli(['login', '--registry', url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'long-slug')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const longSlug = 'a'.repeat(220)
|
||||
const result = await runCli(
|
||||
['install', longSlug, '--dir', installDir, '--registry', url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(resolveUrl).toContain(longSlug)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P0 — --scope flag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
107
cli/test/integration/inventory-resilience.test.ts
Normal file
107
cli/test/integration/inventory-resilience.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* inventory.json resilience.
|
||||
*
|
||||
* inventory.json is the local manifest of installed skills. These tests pin
|
||||
* how the CLI behaves when that file is corrupt or written by overlapping
|
||||
* operations:
|
||||
* - list against a corrupt inventory should fail loudly (not silently)
|
||||
* - install against a corrupt inventory should still complete the
|
||||
* filesystem extraction even if inventory bookkeeping fails — partial
|
||||
* state surfaces a clear error
|
||||
* - sequential installs of distinct skills do not corrupt the manifest
|
||||
*
|
||||
* The unit test in test/unit/stores/inventory-store.test.ts asserts the
|
||||
* lock-file recovery path. These cover the user-facing CLI surface.
|
||||
*/
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { zipSync, strToU8 } from 'fflate'
|
||||
import { startFakeRegistry } from '../helpers/fake-registry'
|
||||
import { runCli } from '../helpers/run-cli'
|
||||
import { createTempHome } from '../helpers/temp-env'
|
||||
|
||||
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
||||
|
||||
afterEach(() => {
|
||||
registry?.stop(); registry = undefined
|
||||
})
|
||||
|
||||
function makeSkillZip(): Uint8Array {
|
||||
return zipSync({ 'SKILL.md': strToU8('# test') })
|
||||
}
|
||||
|
||||
describe('inventory resilience', () => {
|
||||
test('list exits non-zero when inventory.json is malformed (documents current generic-error UX)', async () => {
|
||||
const env = await createTempHome()
|
||||
await mkdir(join(env.home, '.skillhub'), { recursive: true })
|
||||
await writeFile(join(env.home, '.skillhub', 'inventory.json'), '{ this is not JSON')
|
||||
|
||||
const result = await runCli(['list'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
// Contract: CLI must not crash silently or print a stack trace. It
|
||||
// exits non-zero and emits a short message.
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr.length).toBeGreaterThan(0)
|
||||
expect(result.stderr.length).toBeLessThan(2000)
|
||||
// Documented gap: today's message is the generic "unexpected failure"
|
||||
// and does not mention `inventory` or `JSON`. When the CLI surfaces a
|
||||
// more specific message in the future, tighten this assertion.
|
||||
expect(result.stderr).toContain('Error')
|
||||
})
|
||||
|
||||
test('list --json on a corrupt inventory emits a parseable error envelope (not a stack trace)', async () => {
|
||||
const env = await createTempHome()
|
||||
await mkdir(join(env.home, '.skillhub'), { recursive: true })
|
||||
await writeFile(join(env.home, '.skillhub', 'inventory.json'), '{"items":')
|
||||
|
||||
const result = await runCli(['list', '--json'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
const candidate = result.stdout || result.stderr
|
||||
expect(candidate.length).toBeLessThan(2000)
|
||||
// Contract: --json error path is machine-parseable, regardless of the
|
||||
// (currently generic) human message.
|
||||
const json = JSON.parse(candidate) as { ok: boolean; message: string; exitCode: number }
|
||||
expect(json.ok).toBe(false)
|
||||
expect(typeof json.message).toBe('string')
|
||||
expect(json.exitCode).toBe(result.exitCode)
|
||||
})
|
||||
|
||||
test('two sequential installs of distinct slugs leave a coherent inventory', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [
|
||||
{ namespace: 'global', slug: 'one', version: '1.0.0', zipBytes: makeSkillZip() },
|
||||
{ namespace: 'global', slug: 'two', version: '1.0.0', zipBytes: makeSkillZip() }
|
||||
]
|
||||
})
|
||||
|
||||
const baseDir = join(env.cwd, 'pool')
|
||||
await mkdir(baseDir, { recursive: true })
|
||||
|
||||
const r1 = await runCli(
|
||||
['install', 'one', '--dir', baseDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(r1.exitCode).toBe(0)
|
||||
|
||||
const r2 = await runCli(
|
||||
['install', 'two', '--dir', baseDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(r2.exitCode).toBe(0)
|
||||
|
||||
const inventory = JSON.parse(
|
||||
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
|
||||
) as { items: Array<{ slug: string; targets: Array<{ installDir: string }> }> }
|
||||
|
||||
const slugs = inventory.items.map(i => i.slug).sort()
|
||||
expect(slugs).toEqual(['one', 'two'])
|
||||
for (const item of inventory.items) {
|
||||
expect(item.targets.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -351,4 +351,112 @@ describe('list command', () => {
|
|||
expect(json.items).toHaveLength(1)
|
||||
expect(json.items[0].status).toBe('missing')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1: Combined filters — --agent + --registry should narrow precisely
|
||||
// -------------------------------------------------------------------------
|
||||
test('--agent codex --registry A shows only codex targets from registry A', async () => {
|
||||
const { home } = await createTempHome()
|
||||
|
||||
const codexA = join(home, 'a', 'codex', 'pdf')
|
||||
const claudeA = join(home, 'a', 'claude', 'pdf')
|
||||
const codexB = join(home, 'b', 'codex', 'pdf')
|
||||
for (const d of [codexA, claudeA, codexB]) await mkdir(d, { recursive: true })
|
||||
|
||||
await seedInventory(home, [
|
||||
{
|
||||
registry: FAKE_REGISTRY_A, namespace: 'global', slug: 'pdf', version: '1.0.0',
|
||||
targets: [
|
||||
{ agent: 'codex', rootDir: join(home, 'a', 'codex'), installDir: codexA, installedAt: INSTALLED_AT },
|
||||
{ agent: 'claude-code', rootDir: join(home, 'a', 'claude'), installDir: claudeA, installedAt: INSTALLED_AT }
|
||||
]
|
||||
},
|
||||
{
|
||||
registry: FAKE_REGISTRY_B, namespace: 'global', slug: 'pdf', version: '1.0.0',
|
||||
targets: [
|
||||
{ agent: 'codex', rootDir: join(home, 'b', 'codex'), installDir: codexB, installedAt: INSTALLED_AT }
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runCli(
|
||||
['list', '--agent', 'codex', '--registry', FAKE_REGISTRY_A, '--json'],
|
||||
{ HOME: home, USERPROFILE: home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const json = JSON.parse(result.stdout) as { items: Array<{ agent: string; installDir: string }> }
|
||||
expect(json.items).toHaveLength(1)
|
||||
expect(json.items[0]?.agent).toBe('codex')
|
||||
expect(json.items[0]?.installDir).toBe(codexA)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1: --agent + --dir should compose AND, not OR
|
||||
// -------------------------------------------------------------------------
|
||||
test('--agent + --dir composes as AND: only items matching both surface', async () => {
|
||||
const { home } = await createTempHome()
|
||||
|
||||
const codexHere = join(home, 'here', 'codex', 'pdf')
|
||||
const codexElse = join(home, 'else', 'codex', 'pdf')
|
||||
await mkdir(codexHere, { recursive: true })
|
||||
await mkdir(codexElse, { recursive: true })
|
||||
|
||||
await seedInventory(home, [
|
||||
{
|
||||
registry: FAKE_REGISTRY_A, namespace: 'global', slug: 'pdf', version: '1.0.0',
|
||||
targets: [
|
||||
{ agent: 'codex', rootDir: join(home, 'here', 'codex'), installDir: codexHere, installedAt: INSTALLED_AT }
|
||||
]
|
||||
},
|
||||
{
|
||||
registry: FAKE_REGISTRY_A, namespace: 'global', slug: 'pdf-elsewhere', version: '1.0.0',
|
||||
targets: [
|
||||
{ agent: 'codex', rootDir: join(home, 'else', 'codex'), installDir: codexElse, installedAt: INSTALLED_AT }
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runCli(
|
||||
['list', '--registry', FAKE_REGISTRY_A, '--agent', 'codex', '--dir', join(home, 'here'), '--json'],
|
||||
{ HOME: home, USERPROFILE: home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const json = JSON.parse(result.stdout) as { items: Array<{ slug: string }> }
|
||||
expect(json.items).toHaveLength(1)
|
||||
expect(json.items[0]?.slug).toBe('pdf')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1: SKILLHUB_REGISTRY env scopes list to the env-specified registry
|
||||
// (registry priority --registry > env > config > default also applies to
|
||||
// list, not just to network-touching commands).
|
||||
// -------------------------------------------------------------------------
|
||||
test('SKILLHUB_REGISTRY env scopes list to that registry, hiding the other', async () => {
|
||||
const { home } = await createTempHome()
|
||||
const dirA = join(home, 'a', 'codex', 'one')
|
||||
const dirB = join(home, 'b', 'codex', 'two')
|
||||
await mkdir(dirA, { recursive: true })
|
||||
await mkdir(dirB, { recursive: true })
|
||||
|
||||
await seedInventory(home, [
|
||||
{
|
||||
registry: FAKE_REGISTRY_A, namespace: 'global', slug: 'one', version: '1.0.0',
|
||||
targets: [{ agent: 'codex', rootDir: join(home, 'a', 'codex'), installDir: dirA, installedAt: INSTALLED_AT }]
|
||||
},
|
||||
{
|
||||
registry: FAKE_REGISTRY_B, namespace: 'global', slug: 'two', version: '1.0.0',
|
||||
targets: [{ agent: 'codex', rootDir: join(home, 'b', 'codex'), installDir: dirB, installedAt: INSTALLED_AT }]
|
||||
}
|
||||
])
|
||||
|
||||
// No --registry flag — scope comes from SKILLHUB_REGISTRY env.
|
||||
const result = await runCli(
|
||||
['list', '--json'],
|
||||
{ HOME: home, USERPROFILE: home, SKILLHUB_REGISTRY: FAKE_REGISTRY_B }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
const json = JSON.parse(result.stdout) as { items: Array<{ slug: string }> }
|
||||
expect(json.items).toHaveLength(1)
|
||||
expect(json.items[0]?.slug).toBe('two')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
110
cli/test/integration/multi-registry.test.ts
Normal file
110
cli/test/integration/multi-registry.test.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* Multi-registry credential isolation.
|
||||
*
|
||||
* credentials.json keys tokens by registry URL. Operations on one registry
|
||||
* must not leak into another. These tests cover:
|
||||
* - Logging into A then B preserves both tokens.
|
||||
* - Logging out of A leaves B's token intact.
|
||||
* - whoami after logout reflects per-registry session state.
|
||||
* - Re-login to A overwrites only A's slot.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { startFakeRegistry } from '../helpers/fake-registry'
|
||||
import { runCli } from '../helpers/run-cli'
|
||||
import { createTempHome } from '../helpers/temp-env'
|
||||
|
||||
let regA: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
||||
let regB: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
||||
|
||||
afterEach(() => {
|
||||
regA?.stop(); regA = undefined
|
||||
regB?.stop(); regB = undefined
|
||||
})
|
||||
|
||||
async function readCreds(home: string): Promise<{ tokens: Record<string, string> }> {
|
||||
return JSON.parse(await readFile(join(home, '.skillhub', 'credentials.json'), 'utf-8'))
|
||||
}
|
||||
|
||||
describe('multi-registry credential isolation', () => {
|
||||
test('login to A then B leaves both tokens in credentials.json', async () => {
|
||||
const env = await createTempHome()
|
||||
regA = await startFakeRegistry({ token: 'sk_a', user: { handle: 'a', displayName: 'A' } })
|
||||
regB = await startFakeRegistry({ token: 'sk_b', user: { handle: 'b', displayName: 'B' } })
|
||||
|
||||
await runCli(
|
||||
['login', '--registry', regA.url, '--token', 'sk_a'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
await runCli(
|
||||
['login', '--registry', regB.url, '--token', 'sk_b'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
const creds = await readCreds(env.home)
|
||||
expect(creds.tokens[regA.url]).toBe('sk_a')
|
||||
expect(creds.tokens[regB.url]).toBe('sk_b')
|
||||
})
|
||||
|
||||
test('logout from A removes A token while B token survives', async () => {
|
||||
const env = await createTempHome()
|
||||
regA = await startFakeRegistry({ token: 'sk_a', user: { handle: 'a', displayName: 'A' } })
|
||||
regB = await startFakeRegistry({ token: 'sk_b', user: { handle: 'b', displayName: 'B' } })
|
||||
|
||||
await runCli(['login', '--registry', regA.url, '--token', 'sk_a'], { HOME: env.home, USERPROFILE: env.home })
|
||||
await runCli(['login', '--registry', regB.url, '--token', 'sk_b'], { HOME: env.home, USERPROFILE: env.home })
|
||||
await runCli(['logout', '--registry', regA.url], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const creds = await readCreds(env.home)
|
||||
expect(creds.tokens[regA.url]).toBeUndefined()
|
||||
expect(creds.tokens[regB.url]).toBe('sk_b')
|
||||
})
|
||||
|
||||
test('whoami after logout-A: A reports not-logged-in, B still authenticates', async () => {
|
||||
const env = await createTempHome()
|
||||
regA = await startFakeRegistry({ token: 'sk_a', user: { handle: 'a-user', displayName: 'A' } })
|
||||
regB = await startFakeRegistry({ token: 'sk_b', user: { handle: 'b-user', displayName: 'B' } })
|
||||
|
||||
await runCli(['login', '--registry', regA.url, '--token', 'sk_a'], { HOME: env.home, USERPROFILE: env.home })
|
||||
await runCli(['login', '--registry', regB.url, '--token', 'sk_b'], { HOME: env.home, USERPROFILE: env.home })
|
||||
await runCli(['logout', '--registry', regA.url], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const whoamiA = await runCli(
|
||||
['whoami', '--registry', regA.url],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(whoamiA.exitCode).toBe(2) // EXIT.auth
|
||||
expect(whoamiA.stderr.toLowerCase()).toContain('not logged in')
|
||||
|
||||
const whoamiB = await runCli(
|
||||
['whoami', '--registry', regB.url],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(whoamiB.exitCode).toBe(0)
|
||||
expect(whoamiB.stdout).toContain('b-user')
|
||||
})
|
||||
|
||||
test('re-login to A overwrites only A entry; B token unchanged', async () => {
|
||||
const env = await createTempHome()
|
||||
// Don't pin a token on either registry so any value passes whoami; we
|
||||
// only care about credentials.json bookkeeping here.
|
||||
regA = await startFakeRegistry({ user: { handle: 'a', displayName: 'A' } })
|
||||
regB = await startFakeRegistry({ user: { handle: 'b', displayName: 'B' } })
|
||||
|
||||
await runCli(['login', '--registry', regA.url, '--token', 'sk_a_old'], { HOME: env.home, USERPROFILE: env.home })
|
||||
await runCli(['login', '--registry', regB.url, '--token', 'sk_b'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
{
|
||||
const creds = await readCreds(env.home)
|
||||
expect(creds.tokens[regA.url]).toBe('sk_a_old')
|
||||
expect(creds.tokens[regB.url]).toBe('sk_b')
|
||||
}
|
||||
|
||||
await runCli(['login', '--registry', regA.url, '--token', 'sk_a_new'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const creds = await readCreds(env.home)
|
||||
expect(creds.tokens[regA.url]).toBe('sk_a_new')
|
||||
expect(creds.tokens[regB.url]).toBe('sk_b')
|
||||
})
|
||||
})
|
||||
|
|
@ -237,3 +237,295 @@ describe('publish command — P1', () => {
|
|||
expect(result.stderr).toContain('registry')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P1 — content shape: directory layout and edge files
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { unzipSync, strFromU8 } from 'fflate'
|
||||
|
||||
describe('publish command — content shape', () => {
|
||||
/**
|
||||
* Spin up a publish endpoint that captures the raw zip body and lets us
|
||||
* inspect entries server-side. Returns the captured bytes alongside a
|
||||
* stop() handle so tests can assert what the CLI actually packaged.
|
||||
*/
|
||||
async function startCapturingPublishServer() {
|
||||
let capturedBytes: Uint8Array | null = null
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/auth/whoami') {
|
||||
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
|
||||
}
|
||||
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
|
||||
const form = await req.formData()
|
||||
const file = form.get('file')
|
||||
if (file instanceof File) {
|
||||
capturedBytes = new Uint8Array(await file.arrayBuffer())
|
||||
}
|
||||
return Response.json({
|
||||
code: 0,
|
||||
data: { namespace: 'global', slug: 'captured', version: '1.0.0', visibility: 'PUBLIC' }
|
||||
})
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
return {
|
||||
url: `http://localhost:${server.port}`,
|
||||
stop: () => server.stop(),
|
||||
getCaptured: () => capturedBytes
|
||||
}
|
||||
}
|
||||
|
||||
test('publishing a directory with subdirs packages every file at its relative path', async () => {
|
||||
const env = await createTempHome()
|
||||
const server = await startCapturingPublishServer()
|
||||
try {
|
||||
await login(env, server.url)
|
||||
|
||||
const dir = await mkdtemp(join(tmpdir(), 'skillhub-publish-nested-'))
|
||||
await writeFile(join(dir, 'SKILL.md'), '# nested')
|
||||
await mkdir(join(dir, 'references'), { recursive: true })
|
||||
await writeFile(join(dir, 'references', 'a.md'), 'aa')
|
||||
await mkdir(join(dir, 'scripts'), { recursive: true })
|
||||
await writeFile(join(dir, 'scripts', 'run.sh'), '#!/bin/sh\necho ok\n')
|
||||
|
||||
const result = await runCli(['publish', dir, '--registry', server.url], {
|
||||
HOME: env.home, USERPROFILE: env.home
|
||||
})
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
const captured = server.getCaptured()
|
||||
expect(captured).not.toBeNull()
|
||||
const rawEntries = unzipSync(captured!)
|
||||
// Normalize all entry keys to use forward slashes for cross-platform compatibility
|
||||
const entries = Object.fromEntries(
|
||||
Object.entries(rawEntries).map(([key, value]) => [key.replace(/\\/g, '/'), value])
|
||||
)
|
||||
// Filter out directory marker entries (zip records empty entries for
|
||||
// dirs with a trailing slash); we only care about file entries.
|
||||
const files = Object.keys(entries).filter(k => !k.endsWith('/')).sort()
|
||||
expect(files).toEqual([
|
||||
'SKILL.md',
|
||||
'references/a.md',
|
||||
'scripts/run.sh'
|
||||
])
|
||||
expect(strFromU8(entries['SKILL.md']!)).toBe('# nested')
|
||||
expect(strFromU8(entries['references/a.md']!)).toBe('aa')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('publishing a directory with hidden dotfiles packages them as-is', async () => {
|
||||
const env = await createTempHome()
|
||||
const server = await startCapturingPublishServer()
|
||||
try {
|
||||
await login(env, server.url)
|
||||
|
||||
const dir = await mkdtemp(join(tmpdir(), 'skillhub-publish-hidden-'))
|
||||
await writeFile(join(dir, 'SKILL.md'), '# h')
|
||||
await writeFile(join(dir, '.DS_Store'), 'macos junk')
|
||||
await writeFile(join(dir, '.editorconfig'), 'root = true\n')
|
||||
|
||||
const result = await runCli(['publish', dir, '--registry', server.url], {
|
||||
HOME: env.home, USERPROFILE: env.home
|
||||
})
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
const captured = server.getCaptured()
|
||||
expect(captured).not.toBeNull()
|
||||
const rawEntries = unzipSync(captured!)
|
||||
// Normalize all entry keys to use forward slashes for cross-platform compatibility
|
||||
const entries = Object.fromEntries(
|
||||
Object.entries(rawEntries).map(([key, value]) => [key.replace(/\\/g, '/'), value])
|
||||
)
|
||||
// Pin current behavior so future filtering changes are intentional.
|
||||
expect(Object.keys(entries).sort()).toEqual(['.DS_Store', '.editorconfig', 'SKILL.md'])
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('publishing an empty directory still issues a request and reports the server outcome', async () => {
|
||||
const env = await createTempHome()
|
||||
// Fake registry accepts publish unconditionally; CLI is not authoritative
|
||||
// on SKILL.md presence (server is). We assert only that the CLI does not
|
||||
// crash client-side and exits with whatever the server returned.
|
||||
registry = await startFakeRegistry({ token: 'sk_ok' })
|
||||
await login(env, registry.url)
|
||||
|
||||
const dir = await mkdtemp(join(tmpdir(), 'skillhub-publish-empty-'))
|
||||
|
||||
const result = await runCli(['publish', dir, '--registry', registry.url], {
|
||||
HOME: env.home, USERPROFILE: env.home
|
||||
})
|
||||
// Today's contract: empty dir → empty zip uploaded → server returns 200.
|
||||
// If the server adds client-side or server-side validation later this
|
||||
// assertion will need to flip; that's intentional and traceable.
|
||||
expect(result.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
test('server 422 with a JSON validation body surfaces a non-zero exit and stderr', async () => {
|
||||
const env = await createTempHome()
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/auth/whoami') {
|
||||
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
|
||||
}
|
||||
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
|
||||
return Response.json(
|
||||
{ code: 422, message: 'validation.token.name.size', errors: ['name exceeds 64 chars'] },
|
||||
{ status: 422 }
|
||||
)
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const url = `http://localhost:${server.port}`
|
||||
await login(env, url)
|
||||
const dir = await makeTempDir(['SKILL.md', '# x'])
|
||||
const result = await runCli(['publish', dir, '--registry', url], {
|
||||
HOME: env.home, USERPROFILE: env.home
|
||||
})
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
// The server's HTTP status should propagate visibly so a CI log
|
||||
// shows what happened.
|
||||
expect(result.stderr).toMatch(/422|registry|validation/i)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
// 502/503 are special-cased to EXIT.network because they indicate
|
||||
// infrastructure-level unavailability (gateway/proxy failure).
|
||||
test('server 503 Service Unavailable maps to EXIT.network with status in stderr', async () => {
|
||||
const env = await createTempHome()
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/auth/whoami') {
|
||||
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
|
||||
}
|
||||
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
|
||||
return Response.json({ code: 503, message: 'service unavailable' }, { status: 503 })
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const url = `http://localhost:${server.port}`
|
||||
await login(env, url)
|
||||
const dir = await makeTempDir(['SKILL.md', '# x'])
|
||||
const result = await runCli(['publish', dir, '--registry', url], {
|
||||
HOME: env.home, USERPROFILE: env.home
|
||||
})
|
||||
expect(result.exitCode).toBe(3) // EXIT.network
|
||||
expect(result.stderr).toMatch(/503|registry/i)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('server 401 mid-session (token revoked) maps to EXIT.auth', async () => {
|
||||
const env = await createTempHome()
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
// Whoami succeeds (login step). Publish then returns 401 as if the
|
||||
// server revoked the token between the login + publish calls.
|
||||
if (url.pathname === '/api/cli/v1/auth/whoami') {
|
||||
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
|
||||
}
|
||||
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
|
||||
return Response.json({ code: 401, message: 'unauthorized' }, { status: 401 })
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const url = `http://localhost:${server.port}`
|
||||
await login(env, url)
|
||||
const dir = await makeTempDir(['SKILL.md', '# x'])
|
||||
const result = await runCli(['publish', dir, '--registry', url], {
|
||||
HOME: env.home, USERPROFILE: env.home
|
||||
})
|
||||
expect(result.exitCode).toBe(2) // EXIT.auth
|
||||
expect(result.stderr.toLowerCase()).toMatch(/auth|401|unauthorized/)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('publish response missing required fields is handled without crash', async () => {
|
||||
const env = await createTempHome()
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/auth/whoami') {
|
||||
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
|
||||
}
|
||||
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
|
||||
// 200 OK but body shape doesn't match the expected schema.
|
||||
return Response.json({ code: 0, data: { unexpected: true } })
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const url = `http://localhost:${server.port}`
|
||||
await login(env, url)
|
||||
const dir = await makeTempDir(['SKILL.md', '# x'])
|
||||
const result = await runCli(['publish', dir, '--registry', url, '--json'], {
|
||||
HOME: env.home, USERPROFILE: env.home
|
||||
})
|
||||
// Either parses with placeholder values or fails — the contract we
|
||||
// want is "no crash". Pin: exit 0 means current behavior accepts
|
||||
// partial responses; flip if/when stricter validation lands.
|
||||
expect([0, 1, 2, 3]).toContain(result.exitCode)
|
||||
// Either way, output is bounded — no stack trace dump.
|
||||
expect((result.stdout + result.stderr).length).toBeLessThan(2000)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('server 413 Payload Too Large maps to a network-class non-zero exit', async () => {
|
||||
const env = await createTempHome()
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/auth/whoami') {
|
||||
return Response.json({ code: 0, data: { handle: 'u', displayName: 'U' } })
|
||||
}
|
||||
if (url.pathname.endsWith('/publish') && req.method === 'POST') {
|
||||
return Response.json({ code: 413, message: 'payload too large' }, { status: 413 })
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const url = `http://localhost:${server.port}`
|
||||
await login(env, url)
|
||||
const dir = await makeTempDir(['SKILL.md', '# x'])
|
||||
const result = await runCli(['publish', dir, '--registry', url], {
|
||||
HOME: env.home, USERPROFILE: env.home
|
||||
})
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/413|registry/i)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
176
cli/test/integration/publish-dry-run.test.ts
Normal file
176
cli/test/integration/publish-dry-run.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
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 { 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
|
||||
})
|
||||
|
||||
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-dryrun-'))
|
||||
for (const [name, content] of files) {
|
||||
await writeFile(join(dir, name), content)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
describe('publish --dry-run', () => {
|
||||
test('calls validate endpoint and reports success', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({ token: 'sk_ok' })
|
||||
await login(env, registry.url)
|
||||
|
||||
const dir = await makeTempDir(['SKILL.md', '---\nname: my-skill\ndescription: A test\n---\n# Hello'])
|
||||
const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
|
||||
HOME: env.home,
|
||||
USERPROFILE: env.home
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('Validation passed')
|
||||
expect(registry.received.validate).not.toBeNull()
|
||||
expect(registry.received.validate!.namespace).toBe('global')
|
||||
expect(registry.received.publish).toBeNull()
|
||||
})
|
||||
|
||||
test('--dry-run with --json returns structured response on warnings (valid=false)', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
dryRunResponse: {
|
||||
valid: false,
|
||||
errors: [],
|
||||
warnings: ['Disallowed file extension: data.bin'],
|
||||
resolvedSlug: 'my-skill',
|
||||
resolvedVersion: '2.0.0'
|
||||
}
|
||||
})
|
||||
await login(env, registry.url)
|
||||
|
||||
const dir = await makeTempDir(['SKILL.md', '---\nname: my-skill\ndescription: test\n---\n'])
|
||||
const result = await runCli(['publish', dir, '--dry-run', '--json', '--registry', registry.url], {
|
||||
HOME: env.home,
|
||||
USERPROFILE: env.home
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(6)
|
||||
const json = JSON.parse(result.stdout)
|
||||
expect(json.valid).toBe(false)
|
||||
expect(json.resolvedSlug).toBe('my-skill')
|
||||
expect(json.resolvedVersion).toBe('2.0.0')
|
||||
expect(json.warnings).toContain('Disallowed file extension: data.bin')
|
||||
})
|
||||
|
||||
test('--dry-run reports validation errors', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
dryRunResponse: {
|
||||
valid: false,
|
||||
errors: ['Missing required file: SKILL.md at root'],
|
||||
warnings: [],
|
||||
resolvedSlug: null,
|
||||
resolvedVersion: null
|
||||
}
|
||||
})
|
||||
await login(env, registry.url)
|
||||
|
||||
const dir = await makeTempDir(['README.md', '# No SKILL.md here'])
|
||||
const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
|
||||
HOME: env.home,
|
||||
USERPROFILE: env.home
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(6)
|
||||
expect(result.stdout).toContain('Validation failed')
|
||||
expect(result.stdout).toContain('Missing required file: SKILL.md at root')
|
||||
})
|
||||
|
||||
test('--dry-run does not actually publish', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({ token: 'sk_ok' })
|
||||
await login(env, registry.url)
|
||||
|
||||
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
|
||||
await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
|
||||
HOME: env.home,
|
||||
USERPROFILE: env.home
|
||||
})
|
||||
|
||||
expect(registry.received.publish).toBeNull()
|
||||
})
|
||||
|
||||
test('--dry-run respects --namespace', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({ token: 'sk_ok' })
|
||||
await login(env, registry.url)
|
||||
|
||||
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
|
||||
await runCli(['publish', dir, '--dry-run', '--namespace', 'myteam', '--registry', registry.url], {
|
||||
HOME: env.home,
|
||||
USERPROFILE: env.home
|
||||
})
|
||||
|
||||
expect(registry.received.validate!.namespace).toBe('myteam')
|
||||
})
|
||||
|
||||
test('--dry-run forwards --visibility to server', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({ token: 'sk_ok' })
|
||||
await login(env, registry.url)
|
||||
|
||||
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
|
||||
await runCli(['publish', dir, '--dry-run', '--visibility', 'private', '--registry', registry.url], {
|
||||
HOME: env.home,
|
||||
USERPROFILE: env.home
|
||||
})
|
||||
|
||||
expect(registry.received.validate!.visibility).toBe('PRIVATE')
|
||||
})
|
||||
|
||||
test('--dry-run requires authentication', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({ token: 'sk_ok' })
|
||||
|
||||
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
|
||||
const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
|
||||
HOME: env.home,
|
||||
USERPROFILE: env.home
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(2)
|
||||
expect(result.stderr).toContain('authentication')
|
||||
})
|
||||
|
||||
test('--dry-run reports scope error on 403', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({ token: 'sk_ok', failures: { validate: 'forbidden' } })
|
||||
await login(env, registry.url)
|
||||
|
||||
const dir = await makeTempDir(['SKILL.md', '---\nname: test\ndescription: test\n---\n'])
|
||||
const result = await runCli(['publish', dir, '--dry-run', '--registry', registry.url], {
|
||||
HOME: env.home,
|
||||
USERPROFILE: env.home
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(2)
|
||||
expect(result.stderr).toContain('scope')
|
||||
})
|
||||
})
|
||||
|
|
@ -320,4 +320,111 @@ describe('remove command — local remove (P1)', () => {
|
|||
const agents = parsed.removed.map((r: { agent: string }) => r.agent).sort()
|
||||
expect(agents).toEqual(['claude-code', 'cursor'])
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1: --remote --hard against a slug that doesn't exist on the server
|
||||
// -------------------------------------------------------------------------
|
||||
test('--remote --hard for a nonexistent slug surfaces server 404 as non-zero exit', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' }
|
||||
// No skills configured → DELETE returns 404.
|
||||
})
|
||||
|
||||
const result = await runCli(
|
||||
[
|
||||
'remove', 'never-published',
|
||||
'--remote', '--hard',
|
||||
'--namespace', 'global',
|
||||
'--registry', registry.url,
|
||||
'--token', 'sk_ok'
|
||||
],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr.toLowerCase()).toMatch(/404|not found|registry returned 4/)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1: --agent on a multi-target inventory leaves OTHER agents' targets
|
||||
// intact in the inventory file (not just in the JSON envelope).
|
||||
// -------------------------------------------------------------------------
|
||||
test('--agent removes one target while leaving others in inventory.json', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({ token: 'sk_ok' })
|
||||
|
||||
const rootDir = `${env.home}/agents`
|
||||
const codexDir = `${rootDir}/codex/skills/keep-others`
|
||||
const claudeDir = `${rootDir}/claude-code/skills/keep-others`
|
||||
await createInstallDir(codexDir)
|
||||
await createInstallDir(claudeDir)
|
||||
|
||||
await seedInventory(env.home, [
|
||||
{
|
||||
registry: registry.url,
|
||||
namespace: 'global',
|
||||
slug: 'keep-others',
|
||||
version: '1.0.0',
|
||||
targets: [
|
||||
{ agent: 'codex', rootDir: `${rootDir}/codex`, installDir: codexDir, installedAt: '2026-04-20T00:00:00Z' },
|
||||
{ agent: 'claude-code', rootDir: `${rootDir}/claude-code`, installDir: claudeDir, installedAt: '2026-04-20T00:00:00Z' }
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runCli(
|
||||
['remove', 'keep-others', '--agent', 'codex', '--registry', registry.url, '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
const inv = JSON.parse(await Bun.file(`${env.home}/.skillhub/inventory.json`).text()) as {
|
||||
items: Array<{ slug: string; targets: Array<{ agent: string }> }>
|
||||
}
|
||||
const survived = inv.items.find(i => i.slug === 'keep-others')
|
||||
expect(survived).toBeDefined()
|
||||
expect(survived!.targets.map(t => t.agent)).toEqual(['claude-code'])
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1: --agent + --namespace together filter precisely so a same-slug skill
|
||||
// in a different namespace is not collateral damage.
|
||||
// -------------------------------------------------------------------------
|
||||
test('--agent + --namespace filters precisely; same slug under different namespace is untouched', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({ token: 'sk_ok' })
|
||||
|
||||
const rootDir = `${env.home}/agents`
|
||||
const aDir = `${rootDir}/codex/skills/dup-slug-A`
|
||||
const bDir = `${rootDir}/codex/skills/dup-slug-B`
|
||||
await createInstallDir(aDir)
|
||||
await createInstallDir(bDir)
|
||||
|
||||
await seedInventory(env.home, [
|
||||
{
|
||||
registry: registry.url, namespace: 'team-a', slug: 'dup-slug-A', version: '1.0.0',
|
||||
targets: [{ agent: 'codex', rootDir: `${rootDir}/codex`, installDir: aDir, installedAt: '2026-04-20T00:00:00Z' }]
|
||||
},
|
||||
{
|
||||
registry: registry.url, namespace: 'team-b', slug: 'dup-slug-B', version: '1.0.0',
|
||||
targets: [{ agent: 'codex', rootDir: `${rootDir}/codex`, installDir: bDir, installedAt: '2026-04-20T00:00:00Z' }]
|
||||
}
|
||||
])
|
||||
|
||||
// Remove dup-slug-A only — dup-slug-B should survive even though both
|
||||
// share the codex agent.
|
||||
const result = await runCli(
|
||||
['remove', 'dup-slug-A', '--agent', 'codex', '--registry', registry.url],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
||||
const inv = JSON.parse(await Bun.file(`${env.home}/.skillhub/inventory.json`).text()) as {
|
||||
items: Array<{ slug: string }>
|
||||
}
|
||||
const slugs = inv.items.map(i => i.slug).sort()
|
||||
expect(slugs).toEqual(['dup-slug-B'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -103,4 +103,235 @@ describe('search command', () => {
|
|||
expect(result.exitCode).toBe(3) // EXIT.network
|
||||
expect(result.stderr).toMatch(/registry unreachable|registry returned 5\d\d/)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P2: query containing non-ASCII characters must be URL-encoded in the
|
||||
// outgoing request. We capture the raw URL via a custom Bun.serve and
|
||||
// assert the q parameter is the percent-encoded UTF-8 form of "中文测试".
|
||||
// -------------------------------------------------------------------------
|
||||
test('non-ASCII query is URL-encoded as UTF-8 percent escapes', async () => {
|
||||
let capturedUrl = ''
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/skills/search') {
|
||||
capturedUrl = req.url
|
||||
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
const registryUrl = `http://localhost:${server.port}`
|
||||
try {
|
||||
const result = await runCli(['search', '中文测试', '--registry', registryUrl])
|
||||
expect(result.exitCode).toBe(0)
|
||||
// UTF-8 of 中文测试 = E4 B8 AD E6 96 87 E6 B5 8B E8 AF 95
|
||||
expect(capturedUrl).toContain('q=%E4%B8%AD%E6%96%87%E6%B5%8B%E8%AF%95')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P2: queries containing special characters (script tags, ampersands,
|
||||
// equals signs) are percent-encoded so they don't break the query string.
|
||||
// -------------------------------------------------------------------------
|
||||
test('special-character query is encoded so the URL stays parseable', async () => {
|
||||
let capturedUrl = ''
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/skills/search') {
|
||||
capturedUrl = req.url
|
||||
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
const registryUrl = `http://localhost:${server.port}`
|
||||
try {
|
||||
const result = await runCli(['search', '<script>&q=evil', '--registry', registryUrl])
|
||||
expect(result.exitCode).toBe(0)
|
||||
// Re-parse the captured URL and read q via URLSearchParams to confirm
|
||||
// the original payload survives a round-trip without splitting.
|
||||
const captured = new URL(capturedUrl)
|
||||
expect(captured.searchParams.get('q')).toBe('<script>&q=evil')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P2: --limit 0 still forwards limit=0 to the registry. The CLI does not
|
||||
// validate boundary values; the server contract decides how to respond.
|
||||
// We assert the CLI forwards faithfully and exits cleanly when the server
|
||||
// returns an empty list.
|
||||
// -------------------------------------------------------------------------
|
||||
test('--limit 0 forwards limit=0 and renders no skills', async () => {
|
||||
let capturedUrl = ''
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/skills/search') {
|
||||
capturedUrl = req.url
|
||||
return Response.json({ code: 0, data: { items: [], total: 0, limit: 0 } })
|
||||
}
|
||||
return Response.json({ code: 404, message: 'not found' }, { status: 404 })
|
||||
}
|
||||
})
|
||||
const registryUrl = `http://localhost:${server.port}`
|
||||
try {
|
||||
const result = await runCli(['search', 'pdf', '--limit', '0', '--registry', registryUrl])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(capturedUrl).toContain('limit=0')
|
||||
expect(result.stdout).toBe('No skills found.')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1: 5xx server response. Per commit a14d89d8 (refactor: unify
|
||||
// download/handleJsonResponse error mapping) non-2xx responses surface
|
||||
// as EXIT.generic (1), distinct from EXIT.network (3) which is reserved
|
||||
// for "couldn't even reach the registry". stderr still carries the HTTP
|
||||
// status so the user can debug.
|
||||
// -------------------------------------------------------------------------
|
||||
test('5xx server error returns EXIT.generic with status in stderr', async () => {
|
||||
registry = await startFakeRegistry({ failures: { search: 'server_error' } })
|
||||
|
||||
const result = await runCli(['search', 'pdf', '--registry', registry.url])
|
||||
|
||||
expect(result.exitCode).toBe(1) // EXIT.generic
|
||||
expect(result.stderr).toMatch(/registry returned 500/)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P2: extra --limit values, including high integers and negative inputs.
|
||||
// CLI does not validate; server contract decides. We only assert the
|
||||
// outgoing URL faithfully reflects the user's input.
|
||||
// -------------------------------------------------------------------------
|
||||
test('--limit 100 forwards limit=100 in the search URL', async () => {
|
||||
let capturedUrl = ''
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/skills/search') {
|
||||
capturedUrl = req.url
|
||||
return Response.json({ code: 0, data: { items: [], total: 0, limit: 100 } })
|
||||
}
|
||||
return Response.json({ code: 404 }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const result = await runCli(['search', 'pdf', '--limit', '100', '--registry', `http://localhost:${server.port}`])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(capturedUrl).toContain('limit=100')
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('query with + and = characters round-trips faithfully through URL encoding', async () => {
|
||||
let capturedUrl = ''
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/skills/search') {
|
||||
capturedUrl = req.url
|
||||
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
|
||||
}
|
||||
return Response.json({ code: 404 }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const tricky = 'a+b=c&d e'
|
||||
const result = await runCli(['search', tricky, '--registry', `http://localhost:${server.port}`])
|
||||
expect(result.exitCode).toBe(0)
|
||||
const captured = new URL(capturedUrl)
|
||||
expect(captured.searchParams.get('q')).toBe(tricky)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('1KB long query is forwarded without truncation', async () => {
|
||||
let capturedUrl = ''
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/skills/search') {
|
||||
capturedUrl = req.url
|
||||
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
|
||||
}
|
||||
return Response.json({ code: 404 }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const longQuery = 'q'.repeat(1024)
|
||||
const result = await runCli(['search', longQuery, '--registry', `http://localhost:${server.port}`])
|
||||
expect(result.exitCode).toBe(0)
|
||||
const captured = new URL(capturedUrl)
|
||||
expect(captured.searchParams.get('q')).toBe(longQuery)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('literal % in query is encoded so it survives a round-trip without being mistaken for an escape', async () => {
|
||||
let capturedUrl = ''
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/skills/search') {
|
||||
capturedUrl = req.url
|
||||
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
|
||||
}
|
||||
return Response.json({ code: 404 }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
// A literal '%' must be escaped as %25 so the server doesn't read it
|
||||
// as the start of an existing escape sequence.
|
||||
const tricky = '50% off'
|
||||
const result = await runCli(['search', tricky, '--registry', `http://localhost:${server.port}`])
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(capturedUrl).toContain('%25')
|
||||
const captured = new URL(capturedUrl)
|
||||
expect(captured.searchParams.get('q')).toBe(tricky)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('query with multiple shell metacharacters survives both shell quoting and URL encoding', async () => {
|
||||
let capturedUrl = ''
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(req) {
|
||||
const url = new URL(req.url)
|
||||
if (url.pathname === '/api/cli/v1/skills/search') {
|
||||
capturedUrl = req.url
|
||||
return Response.json({ code: 0, data: { items: [], total: 0, limit: 20 } })
|
||||
}
|
||||
return Response.json({ code: 404 }, { status: 404 })
|
||||
}
|
||||
})
|
||||
try {
|
||||
const tricky = "$VAR `cmd` 'quote' \"dq\""
|
||||
const result = await runCli(['search', tricky, '--registry', `http://localhost:${server.port}`])
|
||||
expect(result.exitCode).toBe(0)
|
||||
const captured = new URL(capturedUrl)
|
||||
expect(captured.searchParams.get('q')).toBe(tricky)
|
||||
} finally {
|
||||
server.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
162
cli/test/integration/version-upgrade-flow.test.ts
Normal file
162
cli/test/integration/version-upgrade-flow.test.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
/**
|
||||
* CLI skill version-upgrade flow.
|
||||
*
|
||||
* These tests cover scenarios that span multiple `install` invocations
|
||||
* against a registry whose state changes between runs — i.e. the user-facing
|
||||
* "upgrade an installed skill" workflow. They complement the per-command
|
||||
* tests in install-command.test.ts which use a single static registry.
|
||||
*
|
||||
* Coverage focus (per test-case-design-skill methodology):
|
||||
* - VU1 (state-transition): full v1 → v2 upgrade lifecycle. Asserts that
|
||||
* metadata.json, inventory.json AND the on-disk bundle all reflect v2
|
||||
* after `install --force`, with no orphaned v1 entry left behind.
|
||||
* - VU2 (equivalence-class on `--version`): pinning to a specific version
|
||||
* forwards `?version=` to /resolve so the registry can serve the
|
||||
* intended bundle.
|
||||
*/
|
||||
import { mkdir, readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { zipSync, strToU8 } from 'fflate'
|
||||
import { createTempHome } from '../helpers/temp-env'
|
||||
import { startFakeRegistry } from '../helpers/fake-registry'
|
||||
import { runCli } from '../helpers/run-cli'
|
||||
|
||||
function makeSkillZipWithBody(body: string): Uint8Array {
|
||||
return zipSync({ 'SKILL.md': strToU8(body) })
|
||||
}
|
||||
|
||||
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
||||
|
||||
afterEach(() => {
|
||||
registry?.stop()
|
||||
registry = undefined
|
||||
})
|
||||
|
||||
describe('version upgrade flow', () => {
|
||||
// -------------------------------------------------------------------------
|
||||
// VU1 — full upgrade lifecycle:
|
||||
// 1. Registry serves pdf-parser@1.0.0 → install → metadata=v1, content=v1
|
||||
// 2. Stop registry, start a new one serving pdf-parser@2.0.0
|
||||
// 3. Install --force using the new registry URL
|
||||
// 4. metadata.json, inventory.json AND on-disk SKILL.md all reflect v2
|
||||
// -------------------------------------------------------------------------
|
||||
test('VU1 install v1 then upgrade to v2 with --force replaces metadata, inventory, and content', async () => {
|
||||
const env = await createTempHome()
|
||||
|
||||
// --- Stage 1: install v1 ----------------------------------------------
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
skills: [{
|
||||
namespace: 'global',
|
||||
slug: 'pdf-parser',
|
||||
version: '1.0.0',
|
||||
zipBytes: makeSkillZipWithBody('# pdf-parser v1\n\nVersion one body.')
|
||||
}]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'skills-upgrade')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const r1 = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(r1.exitCode).toBe(0)
|
||||
|
||||
const skillFile = join(installDir, 'pdf-parser', 'SKILL.md')
|
||||
const metaPath = join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')
|
||||
const inventoryPath = join(env.home, '.skillhub', 'inventory.json')
|
||||
|
||||
{
|
||||
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
|
||||
expect(meta.version).toBe('1.0.0')
|
||||
const body = await readFile(skillFile, 'utf-8')
|
||||
expect(body).toContain('Version one body.')
|
||||
}
|
||||
|
||||
// --- Stage 2: swap registry to v2 -------------------------------------
|
||||
registry.stop()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
skills: [{
|
||||
namespace: 'global',
|
||||
slug: 'pdf-parser',
|
||||
version: '2.0.0',
|
||||
zipBytes: makeSkillZipWithBody('# pdf-parser v2\n\nVersion two body.')
|
||||
}]
|
||||
})
|
||||
|
||||
// Re-login against the new registry (URL changed, so credentials are
|
||||
// keyed differently).
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const r2 = await runCli(
|
||||
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
expect(r2.exitCode).toBe(0)
|
||||
|
||||
// --- Stage 3: assert end state ----------------------------------------
|
||||
const finalMeta = JSON.parse(await readFile(metaPath, 'utf-8'))
|
||||
expect(finalMeta.version).toBe('2.0.0')
|
||||
expect(finalMeta.registry).toBe(registry.url)
|
||||
|
||||
const finalBody = await readFile(skillFile, 'utf-8')
|
||||
expect(finalBody).toContain('Version two body.')
|
||||
expect(finalBody).not.toContain('Version one body.')
|
||||
|
||||
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) as {
|
||||
items: Array<{ namespace: string; slug: string; version: string; targets: Array<{ installDir: string }> }>
|
||||
}
|
||||
const matching = inventory.items.filter(i => i.namespace === 'global' && i.slug === 'pdf-parser')
|
||||
expect(matching).toHaveLength(1) // no duplicate v1 entry
|
||||
expect(matching[0]?.version).toBe('2.0.0')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// VU2 — version pinning:
|
||||
// `install --version=X` must forward ?version=X to /resolve so the
|
||||
// registry can serve the requested bundle. The fake registry captures
|
||||
// the resolve query for assertion.
|
||||
// -------------------------------------------------------------------------
|
||||
test('VU2 --version pins resolve to the requested version string', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
skills: [{
|
||||
namespace: 'global',
|
||||
slug: 'pdf-parser',
|
||||
version: '1.5.0',
|
||||
zipBytes: makeSkillZipWithBody('# pinned')
|
||||
}]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const installDir = join(env.cwd, 'skills-pin')
|
||||
await mkdir(installDir, { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
[
|
||||
'install', 'pdf-parser',
|
||||
'--version', '1.5.0',
|
||||
'--dir', installDir,
|
||||
'--registry', registry.url,
|
||||
'--token', 'sk_ok'
|
||||
],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(registry.received.resolve?.namespace).toBe('global')
|
||||
expect(registry.received.resolve?.slug).toBe('pdf-parser')
|
||||
expect(registry.received.resolve?.version).toBe('1.5.0')
|
||||
|
||||
const meta = JSON.parse(await readFile(join(installDir, 'pdf-parser', '.skillhub', 'metadata.json'), 'utf-8'))
|
||||
expect(meta.version).toBe('1.5.0')
|
||||
})
|
||||
})
|
||||
|
|
@ -98,4 +98,54 @@ describe('whoami command', () => {
|
|||
expect(result.exitCode).toBe(2)
|
||||
expect(result.stderr.toLowerCase()).toContain('authentication failed')
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P1 — Stored-token revocation: token persists in credentials.json but
|
||||
// server now rejects it. whoami must surface the auth failure on first
|
||||
// call, not silently use a stale principal.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('stored token that the server now rejects surfaces EXIT.auth on whoami', async () => {
|
||||
// Configure a registry that requires sk_new but seed sk_old in credentials
|
||||
// — simulates a token that was valid at login time but has since been
|
||||
// revoked or rotated server-side.
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_new',
|
||||
user: { handle: 'should-not-see', displayName: 'X' }
|
||||
})
|
||||
const { home } = await createTempHome()
|
||||
const env = { HOME: home, USERPROFILE: home }
|
||||
|
||||
const { mkdir, writeFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
await mkdir(join(home, '.skillhub'), { recursive: true })
|
||||
await writeFile(
|
||||
join(home, '.skillhub', 'credentials.json'),
|
||||
JSON.stringify({ tokens: { [registry.url]: 'sk_old_revoked' } })
|
||||
)
|
||||
|
||||
const result = await runCli(['whoami', '--registry', registry.url], env)
|
||||
expect(result.exitCode).toBe(2)
|
||||
expect(result.stderr.toLowerCase()).toMatch(/auth|401|unauthorized/)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P1 — Token priority --token > SKILLHUB_TOKEN > stored, end-to-end via
|
||||
// whoami. Cross-checks the auth-resolution suite by verifying the wired
|
||||
// contract on this specific command.
|
||||
// ---------------------------------------------------------------------------
|
||||
test('--token wins over SKILLHUB_TOKEN env on whoami', async () => {
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_winner',
|
||||
user: { handle: 'winner', displayName: 'W' }
|
||||
})
|
||||
const { home } = await createTempHome()
|
||||
const env = { HOME: home, USERPROFILE: home, SKILLHUB_TOKEN: 'sk_loser_env' }
|
||||
|
||||
const result = await runCli(
|
||||
['whoami', '--token', 'sk_winner', '--registry', registry.url],
|
||||
env
|
||||
)
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('winner')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -91,4 +91,173 @@ describe('updateCommand branches', () => {
|
|||
expect(caught).toBeInstanceOf(CliError)
|
||||
expect((caught as CliError).message).toContain('install command failed')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// bun-global success branch — symmetric to npm-global success but routes
|
||||
// through `bun add -g` rather than `npm install -g`. Command-level output
|
||||
// still uses the same "Updated skillhub X -> Y" copy, but the run dep
|
||||
// receives a different argv. We assert both: (a) the command captured the
|
||||
// bun argv on the dep and (b) the output formatting matches.
|
||||
// -------------------------------------------------------------------------
|
||||
test('updated success branch — bun-global with run() success captures bun argv (human + json)', async () => {
|
||||
const calls: string[][] = []
|
||||
const deps: Required<UpdateCommandDeps> = {
|
||||
latestVersion: async () => '99.0.0',
|
||||
detectInstallMode: () => 'bun-global',
|
||||
run: async (cmd) => {
|
||||
calls.push([...cmd])
|
||||
return { success: true, output: '' }
|
||||
}
|
||||
}
|
||||
|
||||
const human = await updateCommand({}, deps)
|
||||
expect(human).toContain(`Updated skillhub ${CLI_VERSION} -> 99.0.0`)
|
||||
|
||||
const json = await updateCommand({ json: true }, deps)
|
||||
expect(JSON.parse(json)).toEqual({ ok: true, updated: true, from: CLI_VERSION, to: '99.0.0' })
|
||||
|
||||
// Both invocations must have routed through `bun add -g` — never `npm`.
|
||||
expect(calls).toHaveLength(2)
|
||||
for (const cmd of calls) {
|
||||
expect(cmd[0]).toBe('bun')
|
||||
expect(cmd[1]).toBe('add')
|
||||
expect(cmd[2]).toBe('-g')
|
||||
expect(cmd[3]).toMatch(/@latest$/)
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// unknown install mode — falls through to the manual-upgrade hint. The
|
||||
// command must NOT invoke run() (we'd be guessing the package manager),
|
||||
// and the human output must spell out both npm and bun fallbacks so the
|
||||
// user can pick whichever is on their system.
|
||||
// -------------------------------------------------------------------------
|
||||
test('available-not-updated branch — unknown mode emits manual upgrade hint and never calls run()', async () => {
|
||||
let runCalls = 0
|
||||
const deps: Required<UpdateCommandDeps> = {
|
||||
latestVersion: async () => '99.0.0',
|
||||
detectInstallMode: () => 'unknown',
|
||||
run: async () => {
|
||||
runCalls += 1
|
||||
return { success: true, output: '' }
|
||||
}
|
||||
}
|
||||
|
||||
const human = await updateCommand({}, deps)
|
||||
expect(human).toContain(`Update available: ${CLI_VERSION} -> 99.0.0`)
|
||||
expect(human).toContain('npm install -g')
|
||||
expect(human).toContain('bun add -g')
|
||||
|
||||
const json = await updateCommand({ json: true }, deps)
|
||||
const parsed = JSON.parse(json)
|
||||
expect(parsed.ok).toBe(true)
|
||||
expect(parsed.available).toBe(true)
|
||||
expect(typeof parsed.next).toBe('string')
|
||||
expect(parsed.next).toMatch(/npm install -g.*bun add -g|bun add -g.*npm install -g/)
|
||||
|
||||
// Neither invocation should have attempted to spawn an installer.
|
||||
expect(runCalls).toBe(0)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// checkOnly short-circuit — even in install modes that WOULD execute an
|
||||
// upgrade (npm-global, bun-global), passing { check: true } must short
|
||||
// circuit the service before it reaches `run()`. Output is the bare
|
||||
// "Update available" line with no `next` hint (next is only populated
|
||||
// for npx / unknown branches, not on checkOnly's early return).
|
||||
// -------------------------------------------------------------------------
|
||||
test('checkOnly with npm-global short-circuits and never calls run()', async () => {
|
||||
let runCalls = 0
|
||||
const deps: Required<UpdateCommandDeps> = {
|
||||
latestVersion: async () => '99.0.0',
|
||||
detectInstallMode: () => 'npm-global',
|
||||
run: async () => {
|
||||
runCalls += 1
|
||||
return { success: true, output: '' }
|
||||
}
|
||||
}
|
||||
|
||||
const human = await updateCommand({ check: true }, deps)
|
||||
expect(human.trim()).toBe(`Update available: ${CLI_VERSION} -> 99.0.0`)
|
||||
|
||||
const json = await updateCommand({ check: true, json: true }, deps)
|
||||
const parsed = JSON.parse(json)
|
||||
expect(parsed.ok).toBe(true)
|
||||
expect(parsed.available).toBe(true)
|
||||
// No `next` hint on the checkOnly path.
|
||||
expect(parsed.next).toBeUndefined()
|
||||
|
||||
expect(runCalls).toBe(0)
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// P1 — Version comparison edge cases. semver.gt drives the available
|
||||
// gate; these tests pin the boundary where "no upgrade" must hold.
|
||||
// -------------------------------------------------------------------------
|
||||
test('latest version equal to current is reported as up-to-date', async () => {
|
||||
const deps = buildDeps({
|
||||
latest: CLI_VERSION,
|
||||
mode: 'npm-global',
|
||||
runResult: { success: true, output: '' }
|
||||
})
|
||||
const human = await updateCommand({}, deps)
|
||||
expect(human).toContain('Already up to date')
|
||||
})
|
||||
|
||||
test('latest version older than current is treated as up-to-date (no downgrade)', async () => {
|
||||
const deps = buildDeps({
|
||||
latest: '0.0.1',
|
||||
mode: 'npm-global',
|
||||
runResult: { success: true, output: '' }
|
||||
})
|
||||
const human = await updateCommand({}, deps)
|
||||
expect(human).toContain('Already up to date')
|
||||
// Make sure we didn't accidentally invoke npm with an older version.
|
||||
const jsonOut = await updateCommand({ json: true }, deps)
|
||||
expect(JSON.parse(jsonOut).updated).toBeUndefined()
|
||||
})
|
||||
|
||||
test('checkOnly + already up-to-date emits a non-available envelope', async () => {
|
||||
const deps = buildDeps({
|
||||
latest: CLI_VERSION,
|
||||
mode: 'unknown',
|
||||
runResult: { success: true, output: '' }
|
||||
})
|
||||
const json = await updateCommand({ check: true, json: true }, deps)
|
||||
const parsed = JSON.parse(json)
|
||||
expect(parsed).toMatchObject({ ok: true, upToDate: true, version: CLI_VERSION })
|
||||
})
|
||||
|
||||
test('bun-global run() failure surfaces the failure output as a CliError message', async () => {
|
||||
const deps = buildDeps({
|
||||
latest: '99.0.0',
|
||||
mode: 'bun-global',
|
||||
runResult: { success: false, output: 'bun add: permission denied' }
|
||||
})
|
||||
let caught: unknown
|
||||
try {
|
||||
await updateCommand({}, deps)
|
||||
} catch (err) {
|
||||
caught = err
|
||||
}
|
||||
expect(caught).toBeInstanceOf(CliError)
|
||||
expect((caught as CliError).message).toContain('bun add')
|
||||
})
|
||||
|
||||
test('checkOnly with npx emits Update-available envelope WITHOUT a next hint (next is mode-specific)', async () => {
|
||||
// Per service code, npx's `next` is added only when checkOnly is false.
|
||||
// checkOnly returns earlier and never enters the mode switch.
|
||||
const deps = buildDeps({
|
||||
latest: '99.0.0',
|
||||
mode: 'npx',
|
||||
runResult: { success: true, output: '' }
|
||||
})
|
||||
const json = await updateCommand({ check: true, json: true }, deps)
|
||||
const parsed = JSON.parse(json)
|
||||
expect(parsed.ok).toBe(true)
|
||||
expect(parsed.available).toBe(true)
|
||||
expect(parsed.next).toBeUndefined()
|
||||
expect(parsed.from).toBe(CLI_VERSION)
|
||||
expect(parsed.to).toBe('99.0.0')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -95,6 +95,30 @@ public class CliSkillController extends BaseApiController {
|
|||
namespace, slug, principal.userId(), AuditRequestContext.from(request)));
|
||||
}
|
||||
|
||||
@PostMapping(value = "/{namespace}/publish/validate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@RateLimit(category = "publish", authenticated = 10, anonymous = 0)
|
||||
public ApiResponse<CliDryRunResponse> validatePublish(
|
||||
@PathVariable String namespace,
|
||||
@RequestPart("file") MultipartFile file,
|
||||
@RequestPart(value = "visibility", required = false) String visibility,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) throws IOException {
|
||||
List<PackageEntry> entries;
|
||||
try {
|
||||
entries = archiveExtractor.extract(file);
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new DomainBadRequestException("error.skill.publish.package.invalid", e.getMessage());
|
||||
}
|
||||
SkillVisibility resolvedVisibility;
|
||||
try {
|
||||
resolvedVisibility = SkillVisibility.valueOf((visibility != null ? visibility : "PUBLIC").toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new DomainBadRequestException("error.skill.publish.visibility.invalid", visibility);
|
||||
}
|
||||
var result = cliSkillAppService.validatePublish(
|
||||
namespace, entries, principal.userId(), resolvedVisibility, principal.platformRoles());
|
||||
return ok("response.success.read", result);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/{namespace}/publish", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@RateLimit(category = "publish", authenticated = 10, anonymous = 0)
|
||||
public ApiResponse<CliPublishResponse> publish(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
package com.iflytek.skillhub.dto.cli;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record CliDryRunResponse(
|
||||
boolean valid,
|
||||
List<String> errors,
|
||||
List<String> warnings,
|
||||
String resolvedSlug,
|
||||
String resolvedVersion
|
||||
) {}
|
||||
|
|
@ -8,6 +8,7 @@ import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
|
|||
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
|
||||
import com.iflytek.skillhub.dto.SkillSummaryResponse;
|
||||
import com.iflytek.skillhub.dto.cli.CliDeleteResponse;
|
||||
import com.iflytek.skillhub.dto.cli.CliDryRunResponse;
|
||||
import com.iflytek.skillhub.dto.cli.CliPublishResponse;
|
||||
import com.iflytek.skillhub.dto.cli.CliResolveResponse;
|
||||
import com.iflytek.skillhub.service.AuditRequestContext;
|
||||
|
|
@ -119,6 +120,18 @@ public class CliSkillAppService {
|
|||
);
|
||||
}
|
||||
|
||||
public CliDryRunResponse validatePublish(String namespace, List<PackageEntry> entries, String publisherId, SkillVisibility visibility, Set<String> platformRoles) {
|
||||
SkillPublishService.DryRunResult result = skillPublishService.validateOnly(
|
||||
namespace, entries, publisherId, visibility, platformRoles);
|
||||
return new CliDryRunResponse(
|
||||
result.valid(),
|
||||
result.errors(),
|
||||
result.warnings(),
|
||||
result.resolvedSlug(),
|
||||
result.resolvedVersion()
|
||||
);
|
||||
}
|
||||
|
||||
public CliPublishResponse publish(String namespace, List<PackageEntry> entries, String publisherId, SkillVisibility visibility, Set<String> platformRoles) {
|
||||
SkillPublishService.PublishResult result = skillPublishService.publishFromEntries(
|
||||
namespace, entries, publisherId, visibility, platformRoles, false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
package com.iflytek.skillhub.controller.cli;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.dto.cli.CliDryRunResponse;
|
||||
import com.iflytek.skillhub.service.cli.CliSkillAppService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("test")
|
||||
class CliDryRunValidateTest {
|
||||
@Autowired MockMvc mockMvc;
|
||||
@MockBean CliSkillAppService cliSkillAppService;
|
||||
|
||||
private UsernamePasswordAuthenticationToken auth() {
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-1", "tester", "t@example.com", "", "api_token", Set.of("USER"));
|
||||
return new UsernamePasswordAuthenticationToken(
|
||||
principal, null, List.of(
|
||||
new SimpleGrantedAuthority("ROLE_USER"),
|
||||
new SimpleGrantedAuthority("SCOPE_skill:publish")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatePublish_returnsValidResult() throws Exception {
|
||||
given(cliSkillAppService.validatePublish(
|
||||
eq("global"), any(), eq("user-1"), eq(SkillVisibility.PUBLIC), eq(Set.of("USER"))))
|
||||
.willReturn(new CliDryRunResponse(
|
||||
true, List.of(), List.of(),
|
||||
"my-skill", "1.0.0"));
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile("file", "skill.zip",
|
||||
"application/zip", new byte[]{0x50, 0x4B, 0x03, 0x04});
|
||||
|
||||
mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate")
|
||||
.file(file)
|
||||
.with(authentication(auth())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.valid").value(true))
|
||||
.andExpect(jsonPath("$.data.resolvedSlug").value("my-skill"))
|
||||
.andExpect(jsonPath("$.data.resolvedVersion").value("1.0.0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatePublish_returnsInvalidResult() throws Exception {
|
||||
given(cliSkillAppService.validatePublish(
|
||||
eq("global"), any(), eq("user-1"), eq(SkillVisibility.PUBLIC), eq(Set.of("USER"))))
|
||||
.willReturn(new CliDryRunResponse(
|
||||
false, List.of("Missing required file: SKILL.md at root"), List.of(),
|
||||
null, null));
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile("file", "skill.zip",
|
||||
"application/zip", new byte[]{0x50, 0x4B, 0x03, 0x04});
|
||||
|
||||
mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate")
|
||||
.file(file)
|
||||
.with(authentication(auth())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.valid").value(false))
|
||||
.andExpect(jsonPath("$.data.errors[0]").value("Missing required file: SKILL.md at root"))
|
||||
.andExpect(jsonPath("$.data.resolvedSlug").doesNotExist());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatePublish_acceptsCustomVisibility() throws Exception {
|
||||
given(cliSkillAppService.validatePublish(
|
||||
eq("global"), any(), eq("user-1"), eq(SkillVisibility.PRIVATE), eq(Set.of("USER"))))
|
||||
.willReturn(new CliDryRunResponse(
|
||||
true, List.of(), List.of(), "my-skill", "1.0.0"));
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile("file", "skill.zip",
|
||||
"application/zip", new byte[]{0x50, 0x4B, 0x03, 0x04});
|
||||
|
||||
mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate")
|
||||
.file(file)
|
||||
.file(new MockMultipartFile("visibility", "", "text/plain", "PRIVATE".getBytes()))
|
||||
.with(authentication(auth())))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.valid").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatePublish_rejectsInvalidVisibility() throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "skill.zip",
|
||||
"application/zip", new byte[]{0x50, 0x4B, 0x03, 0x04});
|
||||
|
||||
mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate")
|
||||
.file(file)
|
||||
.file(new MockMultipartFile("visibility", "", "text/plain", "BOGUS".getBytes()))
|
||||
.with(authentication(auth())))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatePublish_requiresAuthentication() throws Exception {
|
||||
MockMultipartFile file = new MockMultipartFile("file", "skill.zip",
|
||||
"application/zip", new byte[]{0x50, 0x4B, 0x03, 0x04});
|
||||
|
||||
mockMvc.perform(multipart("/api/cli/v1/skills/global/publish/validate")
|
||||
.file(file))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
}
|
||||
|
|
@ -103,7 +103,9 @@ class CliSkillControllerTest {
|
|||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-1", "tester", "t@example.com", "", "api_token", Set.of("USER"));
|
||||
var auth = new UsernamePasswordAuthenticationToken(
|
||||
principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")));
|
||||
principal, null, List.of(
|
||||
new SimpleGrantedAuthority("ROLE_USER"),
|
||||
new SimpleGrantedAuthority("SCOPE_skill:delete")));
|
||||
|
||||
given(cliSkillAppService.deleteRemote(
|
||||
org.mockito.ArgumentMatchers.eq("global"),
|
||||
|
|
|
|||
|
|
@ -82,7 +82,8 @@ public class RouteSecurityPolicyRegistry {
|
|||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/cli/v1/skills/*/*/download"),
|
||||
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/cli/v1/skills/*/*/versions/*/download"),
|
||||
RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/cli/v1/skills/*/*"),
|
||||
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/cli/v1/skills/*/publish")
|
||||
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/cli/v1/skills/*/publish"),
|
||||
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/cli/v1/skills/*/publish/validate")
|
||||
);
|
||||
|
||||
private static final List<ApiTokenPolicy> API_TOKEN_POLICIES = List.of(
|
||||
|
|
@ -121,7 +122,8 @@ public class RouteSecurityPolicyRegistry {
|
|||
ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/skills/*/*/download"),
|
||||
ApiTokenPolicy.allow(HttpMethod.GET, "/api/cli/v1/skills/*/*/versions/*/download"),
|
||||
ApiTokenPolicy.require(HttpMethod.DELETE, "/api/cli/v1/skills/*/*", "skill:delete"),
|
||||
ApiTokenPolicy.require(HttpMethod.POST, "/api/cli/v1/skills/*/publish", "skill:publish")
|
||||
ApiTokenPolicy.require(HttpMethod.POST, "/api/cli/v1/skills/*/publish", "skill:publish"),
|
||||
ApiTokenPolicy.require(HttpMethod.POST, "/api/cli/v1/skills/*/publish/validate", "skill:publish")
|
||||
);
|
||||
|
||||
private final AntPathMatcher pathMatcher = new AntPathMatcher();
|
||||
|
|
|
|||
|
|
@ -70,7 +70,8 @@ public class ApiTokenScopeFilter extends OncePerRequestFilter {
|
|||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = request.getRequestURI();
|
||||
return path == null || (!path.startsWith("/api/v1/")
|
||||
&& !path.startsWith("/api/web/"));
|
||||
&& !path.startsWith("/api/web/")
|
||||
&& !path.startsWith("/api/cli/"));
|
||||
}
|
||||
|
||||
private boolean isApiTokenAuthentication(Authentication authentication) {
|
||||
|
|
|
|||
|
|
@ -80,6 +80,8 @@ class RouteSecurityPolicyRegistryTest {
|
|||
assertTrue(registry.authorizeApiToken("GET", "/api/cli/v1/skills/global/demo/resolve", Set.of()).allowed());
|
||||
assertFalse(registry.authorizeApiToken("POST", "/api/cli/v1/skills/global/publish", Set.of()).allowed());
|
||||
assertTrue(registry.authorizeApiToken("POST", "/api/cli/v1/skills/global/publish", Set.of("skill:publish")).allowed());
|
||||
assertFalse(registry.authorizeApiToken("POST", "/api/cli/v1/skills/global/publish/validate", Set.of()).allowed());
|
||||
assertTrue(registry.authorizeApiToken("POST", "/api/cli/v1/skills/global/publish/validate", Set.of("skill:publish")).allowed());
|
||||
assertTrue(registry.authorizeApiToken("DELETE", "/api/cli/v1/skills/global/demo", Set.of("skill:delete")).allowed());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -134,4 +134,40 @@ class ApiTokenScopeFilterTest {
|
|||
assertTrue(response.getErrorMessage().contains("Missing API token scope: skill:publish"));
|
||||
verify(chain, never()).doFilter(request, response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDenyApiCliRequestsWithoutRequiredScope() throws Exception {
|
||||
AccessDeniedHandler handler = (request, response, accessDeniedException) -> {
|
||||
response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage());
|
||||
};
|
||||
ApiTokenScopeFilter filter = new ApiTokenScopeFilter(scopeService, handler);
|
||||
|
||||
PlatformPrincipal principal = new PlatformPrincipal(
|
||||
"user-4",
|
||||
"Dave",
|
||||
"dave@example.com",
|
||||
"",
|
||||
"api_token",
|
||||
Set.of("USER")
|
||||
);
|
||||
var authentication = new UsernamePasswordAuthenticationToken(
|
||||
principal,
|
||||
null,
|
||||
List.of(
|
||||
new SimpleGrantedAuthority("ROLE_USER"),
|
||||
new SimpleGrantedAuthority("SCOPE_skill:read")
|
||||
)
|
||||
);
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/cli/v1/skills/global/publish/validate");
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
FilterChain chain = mock(FilterChain.class);
|
||||
|
||||
filter.doFilter(request, response, chain);
|
||||
|
||||
assertEquals(HttpServletResponse.SC_FORBIDDEN, response.getStatus());
|
||||
assertTrue(response.getErrorMessage().contains("Missing API token scope: skill:publish"));
|
||||
verify(chain, never()).doFilter(request, response);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,6 +119,137 @@ public class SkillPublishService {
|
|||
this.clock = clock;
|
||||
}
|
||||
|
||||
public record DryRunResult(
|
||||
boolean valid,
|
||||
List<String> errors,
|
||||
List<String> warnings,
|
||||
String resolvedSlug,
|
||||
String resolvedVersion
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Validates a package without persisting anything. Used by the --dry-run CLI flow.
|
||||
*
|
||||
* <p>Warnings make the result invalid because the CLI publish flow uses
|
||||
* {@code confirmWarnings=false}, which causes real publish to reject any
|
||||
* package with warnings. Treating warnings as making the dry-run invalid
|
||||
* keeps the two flows in lockstep.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public DryRunResult validateOnly(
|
||||
String namespaceSlug,
|
||||
List<PackageEntry> entries,
|
||||
String publisherId,
|
||||
SkillVisibility visibility,
|
||||
Set<String> platformRoles) {
|
||||
|
||||
List<String> errors = new ArrayList<>();
|
||||
List<String> warnings = new ArrayList<>();
|
||||
String resolvedSlug = null;
|
||||
String resolvedVersion = null;
|
||||
|
||||
// 1. Find namespace
|
||||
var namespaceOpt = namespaceRepository.findBySlug(namespaceSlug);
|
||||
if (namespaceOpt.isEmpty()) {
|
||||
errors.add("Namespace not found: " + namespaceSlug);
|
||||
return new DryRunResult(false, errors, warnings, null, null);
|
||||
}
|
||||
Namespace namespace = namespaceOpt.get();
|
||||
if (namespace.getStatus() == NamespaceStatus.FROZEN) {
|
||||
errors.add("Namespace is frozen: " + namespaceSlug);
|
||||
}
|
||||
if (namespace.getStatus() == NamespaceStatus.ARCHIVED) {
|
||||
errors.add("Namespace is archived: " + namespaceSlug);
|
||||
}
|
||||
|
||||
// 2. Check membership
|
||||
boolean isSuperAdmin = platformRoles.contains("SUPER_ADMIN");
|
||||
if (!isSuperAdmin) {
|
||||
var member = namespaceMemberRepository.findByNamespaceIdAndUserId(namespace.getId(), publisherId);
|
||||
if (member.isEmpty()) {
|
||||
errors.add("Publisher is not a member of namespace: " + namespaceSlug);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Package validation
|
||||
ValidationResult packageValidation = skillPackageValidator.validate(entries);
|
||||
errors.addAll(packageValidation.errors());
|
||||
warnings.addAll(packageValidation.warnings());
|
||||
|
||||
if (!packageValidation.passed()) {
|
||||
return new DryRunResult(false, errors, warnings, null, null);
|
||||
}
|
||||
|
||||
// 4. Parse SKILL.md
|
||||
PackageEntry skillMd = entries.stream()
|
||||
.filter(e -> e.path().equals("SKILL.md"))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (skillMd == null) {
|
||||
errors.add("Missing required file: SKILL.md at root");
|
||||
return new DryRunResult(false, errors, warnings, null, null);
|
||||
}
|
||||
|
||||
SkillMetadata metadata;
|
||||
try {
|
||||
metadata = skillMetadataParser.parse(new String(skillMd.content(), java.nio.charset.StandardCharsets.UTF_8));
|
||||
} catch (Exception e) {
|
||||
errors.add("Invalid SKILL.md: " + e.getMessage());
|
||||
return new DryRunResult(false, errors, warnings, null, null);
|
||||
}
|
||||
|
||||
if (metadata.version() == null || metadata.version().isBlank()) {
|
||||
resolvedVersion = AUTO_VERSION_FORMATTER.format(currentTime());
|
||||
} else {
|
||||
resolvedVersion = metadata.version();
|
||||
}
|
||||
|
||||
try {
|
||||
resolvedSlug = SlugValidator.slugify(metadata.name());
|
||||
} catch (Exception e) {
|
||||
errors.add("Invalid skill name for slug generation: " + e.getMessage());
|
||||
return new DryRunResult(false, errors, warnings, resolvedSlug, resolvedVersion);
|
||||
}
|
||||
|
||||
// 5. Pre-publish validation (credential scan)
|
||||
PrePublishValidator.SkillPackageContext context = new PrePublishValidator.SkillPackageContext(
|
||||
entries, metadata, publisherId, namespace.getId());
|
||||
ValidationResult prePublishValidation = prePublishValidator.validate(context);
|
||||
errors.addAll(prePublishValidation.errors());
|
||||
warnings.addAll(prePublishValidation.warnings());
|
||||
|
||||
// 6. Slug conflict, archived skill, and version-exists checks
|
||||
if (resolvedSlug != null && errors.isEmpty()) {
|
||||
List<Skill> existingSkills = skillRepository.findByNamespaceIdAndSlug(namespace.getId(), resolvedSlug);
|
||||
for (Skill existing : existingSkills) {
|
||||
if (existing.getOwnerId().equals(publisherId)) {
|
||||
if (existing.getStatus() == SkillStatus.ARCHIVED) {
|
||||
errors.add("Cannot publish to archived skill: " + resolvedSlug);
|
||||
}
|
||||
if (resolvedVersion != null) {
|
||||
var existingVersion = skillVersionRepository.findBySkillIdAndVersion(existing.getId(), resolvedVersion);
|
||||
if (existingVersion.isPresent() && existingVersion.get().getStatus() == SkillVersionStatus.PUBLISHED) {
|
||||
errors.add("Version already published: " + resolvedVersion);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
boolean hasPublished = !skillVersionRepository
|
||||
.findBySkillIdAndStatus(existing.getId(), SkillVersionStatus.PUBLISHED)
|
||||
.isEmpty();
|
||||
if (hasPublished) {
|
||||
errors.add("Name conflict: slug \"" + resolvedSlug + "\" is already published by another user");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Warnings make valid=false: real publish rejects them when confirmWarnings=false,
|
||||
// which is the only mode the CLI uses today.
|
||||
boolean valid = errors.isEmpty() && warnings.isEmpty();
|
||||
return new DryRunResult(valid, errors, warnings, resolvedSlug, resolvedVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes an extracted package into the target namespace.
|
||||
*
|
||||
|
|
|
|||
62
web/src/api/generated/schema.d.ts
vendored
62
web/src/api/generated/schema.d.ts
vendored
|
|
@ -1652,6 +1652,22 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/cli/v1/skills/{namespace}/publish/validate": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["validatePublish"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/user/profile": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -3948,6 +3964,22 @@ export interface components {
|
|||
version?: string;
|
||||
visibility?: string;
|
||||
};
|
||||
ApiResponseCliDryRunResponse: {
|
||||
/** Format: int32 */
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: components["schemas"]["CliDryRunResponse"];
|
||||
/** Format: date-time */
|
||||
timestamp?: string;
|
||||
requestId?: string;
|
||||
};
|
||||
CliDryRunResponse: {
|
||||
valid?: boolean;
|
||||
errors?: string[];
|
||||
warnings?: string[];
|
||||
resolvedSlug?: string | null;
|
||||
resolvedVersion?: string | null;
|
||||
};
|
||||
UpdateProfileRequest: {
|
||||
displayName?: string;
|
||||
};
|
||||
|
|
@ -8294,6 +8326,36 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
validatePublish: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
namespace: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: {
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
/** Format: binary */
|
||||
file: string;
|
||||
visibility?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ApiResponseCliDryRunResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getProfile: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue