Files
bot-hub/e2e/bothub.e2e.test.ts
zeekay cacc1f4d92 rebrand clawhub → bothub + add self-hosted API backend
Migration from Convex/Vercel to self-hosted infrastructure:

API Backend (api/):
- Hono server with PostgreSQL (Drizzle ORM) + pgvector
- Skills CRUD, search (hybrid vector + lexical), auth (hanzo.id OAuth)
- Upload (MinIO S3), stars, comments, API tokens
- Rate limiting middleware, session management

Frontend Adapter (src/lib/):
- api.ts: REST API client replacing Convex SDK
- hooks.ts: React hooks replacing convex/react useQuery/useMutation
- AuthContext.tsx: Auth provider replacing ConvexAuthProvider

Deployment:
- Dockerfile (multi-stage: API build + web build + production)
- K8s manifests for hub.hanzo.bot on hanzo-k8s
- GitHub Actions CI/CD (build → GHCR → deploy)

Rebrand:
- clawhub/clawdhub/ClawHub → bothub/Bot Hub
- Domain: clawhub.ai → hub.hanzo.bot
- CLI: clawhub → bothub
- Package: @hanzo/bot-hub-api
2026-02-16 19:36:19 -08:00

553 lines
16 KiB
TypeScript

/* @vitest-environment node */
import { spawnSync } from 'node:child_process'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
ApiRoutes,
ApiV1SearchResponseSchema,
ApiV1WhoamiResponseSchema,
parseArk,
} from 'bothub-schema'
import { unzipSync } from 'fflate'
import { Agent, setGlobalDispatcher } from 'undici'
import { describe, expect, it } from 'vitest'
import { readGlobalConfig } from '../packages/bothub/src/config'
const REQUEST_TIMEOUT_MS = 15_000
try {
setGlobalDispatcher(
new Agent({
connect: { timeout: REQUEST_TIMEOUT_MS },
}),
)
} catch {
// ignore dispatcher setup failures
}
function mustGetToken() {
const fromEnv = process.env.BOTHUB_E2E_TOKEN?.trim() || process.env.CLAWDHUB_E2E_TOKEN?.trim()
if (fromEnv) return fromEnv
return null
}
function getRegistry() {
return (
process.env.BOTHUB_REGISTRY?.trim() ||
process.env.CLAWDHUB_REGISTRY?.trim() ||
'https://hub.hanzo.bot'
)
}
function getSite() {
return (
process.env.BOTHUB_SITE?.trim() || process.env.CLAWDHUB_SITE?.trim() || 'https://hub.hanzo.bot'
)
}
async function makeTempConfig(registry: string, token: string | null) {
const dir = await mkdtemp(join(tmpdir(), 'bothub-e2e-'))
const path = join(dir, 'config.json')
await writeFile(
path,
`${JSON.stringify({ registry, token: token || undefined }, null, 2)}\n`,
'utf8',
)
return { dir, path }
}
async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(new Error('Timeout')), REQUEST_TIMEOUT_MS)
try {
return await fetch(input, { ...init, signal: controller.signal })
} finally {
clearTimeout(timeout)
}
}
describe('bothub e2e', () => {
it('prints CLI version via --cli-version', async () => {
const result = spawnSync('bun', ['bothub', '--cli-version'], {
cwd: process.cwd(),
encoding: 'utf8',
})
expect(result.status).toBe(0)
expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+/)
})
it('search endpoint returns a results array (schema parse)', async () => {
const registry = getRegistry()
const url = new URL(ApiRoutes.search, registry)
url.searchParams.set('q', 'gif')
url.searchParams.set('limit', '5')
const response = await fetchWithTimeout(url.toString(), {
headers: { Accept: 'application/json' },
})
expect(response.ok).toBe(true)
const json = (await response.json()) as unknown
const parsed = parseArk(ApiV1SearchResponseSchema, json, 'API response')
expect(Array.isArray(parsed.results)).toBe(true)
})
it('cli search does not error on multi-result responses', async () => {
const registry = getRegistry()
const site = getSite()
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null
const cfg = await makeTempConfig(registry, token)
try {
const workdir = await mkdtemp(join(tmpdir(), 'bothub-e2e-workdir-'))
const result = spawnSync(
'bun',
[
'bothub',
'search',
'gif',
'--limit',
'5',
'--site',
site,
'--registry',
registry,
'--workdir',
workdir,
],
{
cwd: process.cwd(),
env: { ...process.env, BOTHUB_CONFIG_PATH: cfg.path, BOTHUB_DISABLE_TELEMETRY: '1' },
encoding: 'utf8',
},
)
await rm(workdir, { recursive: true, force: true })
expect(result.status).toBe(0)
expect(result.stderr).not.toMatch(/API response:/)
} finally {
await rm(cfg.dir, { recursive: true, force: true })
}
})
it('assumes a logged-in user (whoami succeeds)', async () => {
const registry = getRegistry()
const site = getSite()
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null
if (!token) {
throw new Error('Missing token. Set BOTHUB_E2E_TOKEN or run: bun bothub auth login')
}
const cfg = await makeTempConfig(registry, token)
try {
const whoamiUrl = new URL(ApiRoutes.whoami, registry)
const whoamiRes = await fetchWithTimeout(whoamiUrl.toString(), {
headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
})
expect(whoamiRes.ok).toBe(true)
const whoami = parseArk(
ApiV1WhoamiResponseSchema,
(await whoamiRes.json()) as unknown,
'Whoami',
)
expect(whoami.user).toBeTruthy()
const result = spawnSync(
'bun',
['bothub', 'whoami', '--site', site, '--registry', registry],
{
cwd: process.cwd(),
env: { ...process.env, BOTHUB_CONFIG_PATH: cfg.path, BOTHUB_DISABLE_TELEMETRY: '1' },
encoding: 'utf8',
},
)
expect(result.status).toBe(0)
expect(result.stderr).not.toMatch(/not logged in|unauthorized|error:/i)
} finally {
await rm(cfg.dir, { recursive: true, force: true })
}
})
it('sync dry-run finds skills from an explicit root', async () => {
const registry = getRegistry()
const site = getSite()
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null
if (!token) {
throw new Error('Missing token. Set BOTHUB_E2E_TOKEN or run: bun bothub auth login')
}
const cfg = await makeTempConfig(registry, token)
const root = await mkdtemp(join(tmpdir(), 'bothub-e2e-sync-'))
try {
const skillDir = join(root, 'cool-skill')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '# Skill\n', 'utf8')
const result = spawnSync(
'bun',
[
'bothub',
'sync',
'--dry-run',
'--all',
'--root',
root,
'--site',
site,
'--registry',
registry,
],
{
cwd: process.cwd(),
env: { ...process.env, BOTHUB_CONFIG_PATH: cfg.path, BOTHUB_DISABLE_TELEMETRY: '1' },
encoding: 'utf8',
},
)
expect(result.status).toBe(0)
expect(result.stderr).not.toMatch(/error:/i)
expect(result.stdout).toMatch(/Dry run/i)
} finally {
await rm(root, { recursive: true, force: true })
await rm(cfg.dir, { recursive: true, force: true })
}
})
it('sync dry-run finds skills from clawdbot.json roots', async () => {
const registry = getRegistry()
const site = getSite()
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null
if (!token) {
throw new Error('Missing token. Set BOTHUB_E2E_TOKEN or run: bun bothub auth login')
}
const cfg = await makeTempConfig(registry, token)
const root = await mkdtemp(join(tmpdir(), 'bothub-e2e-clawdbot-'))
const stateDir = join(root, 'state')
const configPath = join(root, 'clawdbot.json')
const workspace = join(root, 'clawd-work')
const skillsRoot = join(workspace, 'skills')
const skillDir = join(skillsRoot, 'auto-skill')
try {
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '# Skill\n', 'utf8')
const config = `{
// JSON5-style comments + trailing commas
routing: {
agents: {
work: { name: 'Work', workspace: '${workspace}', },
},
},
}`
await writeFile(configPath, config, 'utf8')
const result = spawnSync(
'bun',
['bothub', 'sync', '--dry-run', '--all', '--site', site, '--registry', registry],
{
cwd: process.cwd(),
env: {
...process.env,
BOTHUB_CONFIG_PATH: cfg.path,
BOTHUB_DISABLE_TELEMETRY: '1',
CLAWDBOT_CONFIG_PATH: configPath,
CLAWDBOT_STATE_DIR: stateDir,
},
encoding: 'utf8',
},
)
expect(result.status).toBe(0)
expect(result.stderr).not.toMatch(/error:/i)
expect(result.stdout).toMatch(/Dry run/i)
expect(result.stdout).toMatch(/auto-skill/i)
} finally {
await rm(root, { recursive: true, force: true })
await rm(cfg.dir, { recursive: true, force: true })
}
})
it('publishes, deletes, and undeletes a skill (logged-in)', async () => {
const registry = getRegistry()
const site = getSite()
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null
if (!token) {
throw new Error('Missing token. Set BOTHUB_E2E_TOKEN or run: bun bothub auth login')
}
const cfg = await makeTempConfig(registry, token)
const workdir = await mkdtemp(join(tmpdir(), 'bothub-e2e-publish-'))
const installWorkdir = await mkdtemp(join(tmpdir(), 'bothub-e2e-install-'))
const slug = `e2e-${Date.now()}`
const skillDir = join(workdir, slug)
try {
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), `# ${slug}\n\nHello.\n`, 'utf8')
const publish1 = spawnSync(
'bun',
[
'bothub',
'publish',
skillDir,
'--slug',
slug,
'--name',
`E2E ${slug}`,
'--version',
'1.0.0',
'--tags',
'latest',
'--site',
site,
'--registry',
registry,
'--workdir',
workdir,
],
{
cwd: process.cwd(),
env: { ...process.env, BOTHUB_CONFIG_PATH: cfg.path, BOTHUB_DISABLE_TELEMETRY: '1' },
encoding: 'utf8',
},
)
expect(publish1.status).toBe(0)
expect(publish1.stderr).not.toMatch(/changelog required/i)
const publish2 = spawnSync(
'bun',
[
'bothub',
'publish',
skillDir,
'--slug',
slug,
'--name',
`E2E ${slug}`,
'--version',
'1.0.1',
'--tags',
'latest',
'--site',
site,
'--registry',
registry,
'--workdir',
workdir,
],
{
cwd: process.cwd(),
env: { ...process.env, BOTHUB_CONFIG_PATH: cfg.path, BOTHUB_DISABLE_TELEMETRY: '1' },
encoding: 'utf8',
},
)
expect(publish2.status).toBe(0)
expect(publish2.stderr).not.toMatch(/changelog required/i)
const downloadUrl = new URL(ApiRoutes.download, registry)
downloadUrl.searchParams.set('slug', slug)
downloadUrl.searchParams.set('version', '1.0.1')
const zipRes = await fetchWithTimeout(downloadUrl.toString())
expect(zipRes.ok).toBe(true)
const zipBytes = new Uint8Array(await zipRes.arrayBuffer())
const unzipped = unzipSync(zipBytes)
expect(Object.keys(unzipped)).toContain('SKILL.md')
const install = spawnSync(
'bun',
[
'bothub',
'install',
slug,
'--version',
'1.0.0',
'--force',
'--site',
site,
'--registry',
registry,
'--workdir',
installWorkdir,
],
{
cwd: process.cwd(),
env: { ...process.env, BOTHUB_CONFIG_PATH: cfg.path, BOTHUB_DISABLE_TELEMETRY: '1' },
encoding: 'utf8',
},
)
expect(install.status).toBe(0)
const list = spawnSync(
'bun',
['bothub', 'list', '--site', site, '--registry', registry, '--workdir', installWorkdir],
{
cwd: process.cwd(),
env: { ...process.env, BOTHUB_CONFIG_PATH: cfg.path, BOTHUB_DISABLE_TELEMETRY: '1' },
encoding: 'utf8',
},
)
expect(list.status).toBe(0)
expect(list.stdout).toMatch(new RegExp(`${slug}\\s+1\\.0\\.0`))
const update = spawnSync(
'bun',
[
'bothub',
'update',
slug,
'--force',
'--site',
site,
'--registry',
registry,
'--workdir',
installWorkdir,
],
{
cwd: process.cwd(),
env: { ...process.env, BOTHUB_CONFIG_PATH: cfg.path, BOTHUB_DISABLE_TELEMETRY: '1' },
encoding: 'utf8',
},
)
expect(update.status).toBe(0)
const metaUrl = new URL(`${ApiRoutes.skills}/${slug}`, registry)
const metaRes = await fetchWithTimeout(metaUrl.toString(), {
headers: { Accept: 'application/json' },
})
expect(metaRes.status).toBe(200)
const del = spawnSync(
'bun',
[
'bothub',
'delete',
slug,
'--yes',
'--site',
site,
'--registry',
registry,
'--workdir',
workdir,
],
{
cwd: process.cwd(),
env: { ...process.env, BOTHUB_CONFIG_PATH: cfg.path, BOTHUB_DISABLE_TELEMETRY: '1' },
encoding: 'utf8',
},
)
expect(del.status).toBe(0)
const metaAfterDelete = await fetchWithTimeout(metaUrl.toString(), {
headers: { Accept: 'application/json' },
})
expect(metaAfterDelete.status).toBe(404)
const downloadAfterDelete = await fetchWithTimeout(downloadUrl.toString())
expect(downloadAfterDelete.status).toBe(404)
const undelete = spawnSync(
'bun',
[
'bothub',
'undelete',
slug,
'--yes',
'--site',
site,
'--registry',
registry,
'--workdir',
workdir,
],
{
cwd: process.cwd(),
env: { ...process.env, BOTHUB_CONFIG_PATH: cfg.path, BOTHUB_DISABLE_TELEMETRY: '1' },
encoding: 'utf8',
},
)
expect(undelete.status).toBe(0)
const metaAfterUndelete = await fetchWithTimeout(metaUrl.toString(), {
headers: { Accept: 'application/json' },
})
expect(metaAfterUndelete.status).toBe(200)
} finally {
const cleanup = spawnSync(
'bun',
[
'bothub',
'delete',
slug,
'--yes',
'--site',
site,
'--registry',
registry,
'--workdir',
workdir,
],
{
cwd: process.cwd(),
env: { ...process.env, BOTHUB_CONFIG_PATH: cfg.path, BOTHUB_DISABLE_TELEMETRY: '1' },
encoding: 'utf8',
},
)
if (cleanup.status !== 0) {
// best-effort cleanup
}
await rm(workdir, { recursive: true, force: true })
await rm(installWorkdir, { recursive: true, force: true })
await rm(cfg.dir, { recursive: true, force: true })
}
}, 180_000)
it('delete returns proper error for non-existent skill', async () => {
const registry = process.env.CLAWDHUB_REGISTRY?.trim() || 'https://hub.hanzo.bot'
const site = process.env.CLAWDHUB_SITE?.trim() || 'https://hub.hanzo.bot'
const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null
if (!token) {
throw new Error('Missing token. Set CLAWDHUB_E2E_TOKEN or run: bun bothub auth login')
}
const cfg = await makeTempConfig(registry, token)
const workdir = await mkdtemp(join(tmpdir(), 'bothub-e2e-delete-'))
const nonExistentSlug = `non-existent-skill-${Date.now()}`
try {
const del = spawnSync(
'bun',
[
'bothub',
'delete',
nonExistentSlug,
'--yes',
'--site',
site,
'--registry',
registry,
'--workdir',
workdir,
],
{
cwd: process.cwd(),
env: { ...process.env, CLAWDHUB_CONFIG_PATH: cfg.path, CLAWDHUB_DISABLE_TELEMETRY: '1' },
encoding: 'utf8',
},
)
// Should fail with non-zero exit code
expect(del.status).not.toBe(0)
// Error should mention "not found" - not generic "Unauthorized"
const output = (del.stdout + del.stderr).toLowerCase()
expect(output).toMatch(/not found|404|does not exist/i)
expect(output).not.toMatch(/unauthorized/i)
} finally {
await rm(workdir, { recursive: true, force: true })
await rm(cfg.dir, { recursive: true, force: true })
}
}, 30_000)
})