import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, test } from 'bun:test' import { zipSync } from 'fflate' import { createTempHome } from '../helpers/temp-env' import { startFakeRegistry } from '../helpers/fake-registry' import { runCli } from '../helpers/run-cli' let registry: Awaited> | undefined afterEach(() => { registry?.stop() registry = undefined }) // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- async function login(env: { home: string }, registryUrl: string) { const result = await runCli(['login', '--registry', registryUrl, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home }) if (result.exitCode !== 0) { throw new Error(`login failed: ${result.stderr}`) } } async function makeTempDir(...files: Array<[string, string]>) { const dir = await mkdtemp(join(tmpdir(), 'skillhub-publish-dir-')) for (const [name, content] of files) { await writeFile(join(dir, name), content) } return dir } async function makeTempZip(name: string) { const dir = await mkdtemp(join(tmpdir(), 'skillhub-publish-zip-')) const zipPath = join(dir, name) const bytes = zipSync({ 'SKILL.md': new TextEncoder().encode('# Demo') }) await writeFile(zipPath, bytes) return zipPath } async function makeTempTxt(name: string) { const dir = await mkdtemp(join(tmpdir(), 'skillhub-publish-txt-')) const txtPath = join(dir, name) await writeFile(txtPath, 'not a zip') return txtPath } // --------------------------------------------------------------------------- // P0 — must-have // --------------------------------------------------------------------------- describe('publish command — P0', () => { test('unauthenticated publish is rejected with EXIT.auth', async () => { const env = await createTempHome() registry = await startFakeRegistry({ token: 'sk_ok' }) const dir = await makeTempDir(['SKILL.md', '# Demo']) const result = await runCli(['publish', dir, '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home }) expect(result.exitCode).toBe(2) expect(result.stderr).toContain('authentication') }) test('path not found returns filesystem error', async () => { const env = await createTempHome() registry = await startFakeRegistry({ token: 'sk_ok' }) await login(env, registry.url) const result = await runCli(['publish', '/does/not/exist', '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home }) expect(result.exitCode).not.toBe(0) expect(result.stderr).toContain('not found') }) test('non-zip file is rejected', async () => { const env = await createTempHome() registry = await startFakeRegistry({ token: 'sk_ok' }) await login(env, registry.url) const txtPath = await makeTempTxt('skill.txt') const result = await runCli(['publish', txtPath, '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home }) expect(result.exitCode).not.toBe(0) expect(result.stderr).toContain('zip') }) test('directory happy path: exit 0, default namespace=global, fileName ends in .zip, visibility=PUBLIC', async () => { const env = await createTempHome() registry = await startFakeRegistry({ token: 'sk_ok' }) await login(env, registry.url) const dir = await makeTempDir(['SKILL.md', '# Demo'], ['index.js', 'console.log("hi")']) const result = await runCli(['publish', dir, '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home }) expect(result.exitCode).toBe(0) expect(registry.received.publish).not.toBeNull() expect(registry.received.publish!.namespace).toBe('global') expect(registry.received.publish!.fileName).toMatch(/\.zip$/) expect(registry.received.publish!.visibility).toBe('PUBLIC') }) test('directory publish excludes local SkillHub installation metadata', async () => { const env = await createTempHome() registry = await startFakeRegistry({ token: 'sk_ok' }) await login(env, registry.url) const dir = await makeTempDir(['SKILL.md', '# Demo']) await mkdir(join(dir, '.skillhub'), { recursive: true }) await writeFile(join(dir, '.skillhub', 'metadata.json'), '{"registry":"private"}') const result = await runCli(['publish', dir, '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home }) expect(result.exitCode).toBe(0) expect(registry.received.publish!.archiveEntries).toContain('SKILL.md') expect(registry.received.publish!.archiveEntries.some(path => path.startsWith('.skillhub'))).toBe(false) }) test('zip file happy path: exit 0, fileName matches passed file', async () => { const env = await createTempHome() registry = await startFakeRegistry({ token: 'sk_ok' }) await login(env, registry.url) const zipPath = await makeTempZip('my-skill.zip') const result = await runCli(['publish', zipPath, '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home }) expect(result.exitCode).toBe(0) expect(registry.received.publish).not.toBeNull() expect(registry.received.publish!.fileName).toBe('my-skill.zip') }) describe('visibility mapping', () => { test('--visibility public → PUBLIC', async () => { const env = await createTempHome() registry = await startFakeRegistry({ token: 'sk_ok' }) await login(env, registry.url) const dir = await makeTempDir(['SKILL.md', '# Demo']) await runCli(['publish', dir, '--registry', registry.url, '--visibility', 'public'], { HOME: env.home, USERPROFILE: env.home }) expect(registry.received.publish!.visibility).toBe('PUBLIC') }) test('--visibility namespace-only → NAMESPACE_ONLY', async () => { const env = await createTempHome() registry = await startFakeRegistry({ token: 'sk_ok' }) await login(env, registry.url) const dir = await makeTempDir(['SKILL.md', '# Demo']) await runCli(['publish', dir, '--registry', registry.url, '--visibility', 'namespace-only'], { HOME: env.home, USERPROFILE: env.home }) expect(registry.received.publish!.visibility).toBe('NAMESPACE_ONLY') }) test('--visibility private → PRIVATE', async () => { const env = await createTempHome() registry = await startFakeRegistry({ token: 'sk_ok' }) await login(env, registry.url) const dir = await makeTempDir(['SKILL.md', '# Demo']) await runCli(['publish', dir, '--registry', registry.url, '--visibility', 'private'], { HOME: env.home, USERPROFILE: env.home }) expect(registry.received.publish!.visibility).toBe('PRIVATE') }) }) }) // --------------------------------------------------------------------------- // P1 — should-have // --------------------------------------------------------------------------- describe('publish command — P1', () => { test('--namespace override is forwarded to server', async () => { const env = await createTempHome() registry = await startFakeRegistry({ token: 'sk_ok' }) await login(env, registry.url) const dir = await makeTempDir(['SKILL.md', '# Demo']) const result = await runCli(['publish', dir, '--registry', registry.url, '--namespace', 'myteam'], { HOME: env.home, USERPROFILE: env.home }) expect(result.exitCode).toBe(0) expect(registry.received.publish!.namespace).toBe('myteam') }) test('--json output has correct shape including detailUrl', async () => { const env = await createTempHome() registry = await startFakeRegistry({ token: 'sk_ok' }) await login(env, registry.url) const dir = await makeTempDir(['SKILL.md', '# Demo']) const result = await runCli(['publish', dir, '--registry', registry.url, '--json'], { HOME: env.home, USERPROFILE: env.home }) expect(result.exitCode).toBe(0) const json = JSON.parse(result.stdout) expect(json.ok).toBe(true) expect(json.action).toBe('submitted') expect(json.namespace).toBe('global') expect(typeof json.slug).toBe('string') expect(typeof json.version).toBe('string') expect(typeof json.visibility).toBe('string') expect(json.status).toBe('PENDING_REVIEW') expect(json.detailUrl).toContain(registry.url) expect(json.detailUrl).toContain('global') expect(json.detailUrl).toContain(encodeURIComponent(json.slug)) }) test.each(['PUBLISHED', 'PENDING_REVIEW', 'SCANNING', 'UPLOADED'])( 'treats server status %s as a successful submission and preserves it in JSON', async status => { const env = await createTempHome() registry = await startFakeRegistry({ token: 'sk_ok', publishStatus: status }) await login(env, registry.url) const dir = await makeTempDir(['SKILL.md', '# Demo']) const result = await runCli(['publish', dir, '--registry', registry.url, '--json'], { HOME: env.home, USERPROFILE: env.home }) expect(result.exitCode).toBe(0) expect(result.stderr).toBe('') expect(JSON.parse(result.stdout)).toMatchObject({ ok: true, action: 'submitted', status }) } ) test.each(['PUBLISHED', 'PENDING_REVIEW', 'SCANNING', 'UPLOADED'])( 'uses submitted semantics for server status %s in human output', async status => { const env = await createTempHome() registry = await startFakeRegistry({ token: 'sk_ok', publishStatus: status }) await login(env, registry.url) const dir = await makeTempDir(['SKILL.md', '# Demo']) const result = await runCli(['publish', dir, '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home }) expect(result.exitCode).toBe(0) expect(result.stdout).toContain('Submitted successfully:') expect(result.stdout).toContain(`Status: ${status}`) expect(result.stdout).not.toContain('Published successfully') expect(result.stdout).toContain('Check the Web page') } ) test('server error during publish returns EXIT.generic', async () => { const env = await createTempHome() // 'server_error' returns HTTP 500; request reached registry but failed, so EXIT.generic. registry = await startFakeRegistry({ token: 'sk_ok', failures: { publish: 'server_error' } }) await login(env, registry.url) const dir = await makeTempDir(['SKILL.md', '# Demo']) const result = await runCli(['publish', dir, '--registry', registry.url], { HOME: env.home, USERPROFILE: env.home }) expect(result.exitCode).toBe(1) expect(result.stderr).toContain('registry') }) test.each([ { failure: 'forbidden' as const, exitCode: 2, message: 'API token is missing required scope', requestId: 'req-test-forbidden' }, { failure: 'rate_limited' as const, exitCode: 1, message: 'rate limit exceeded', requestId: 'req-test-rate-limit' } ])('structured publish failure $failure preserves msg and requestId', async scenario => { const env = await createTempHome() registry = await startFakeRegistry({ token: 'sk_ok', failures: { publish: scenario.failure } }) await login(env, registry.url) const dir = await makeTempDir(['SKILL.md', '# Demo']) const result = await runCli(['publish', dir, '--registry', registry.url, '--json'], { HOME: env.home, USERPROFILE: env.home }) expect(result.exitCode).toBe(scenario.exitCode) expect(result.stdout).toBe('') expect(JSON.parse(result.stderr)).toMatchObject({ ok: false, message: expect.stringContaining(scenario.message), details: { requestId: scenario.requestId } }) }) }) // --------------------------------------------------------------------------- // P1 — content shape: directory layout and edge files // --------------------------------------------------------------------------- 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() } }) })