diff --git a/convex/httpApiV1.ts b/convex/httpApiV1.ts index 993ea28..80d8316 100644 --- a/convex/httpApiV1.ts +++ b/convex/httpApiV1.ts @@ -461,8 +461,18 @@ async function skillsPostRouterV1Handler(ctx: ActionCtx, request: Request) { deleted: false, }) return json({ ok: true }, 200, rate.headers) - } catch { - return text('Unauthorized', 401, rate.headers) + } catch (error) { + const message = error instanceof Error ? error.message : 'Undelete failed' + if (message === 'Unauthorized') { + return text('Unauthorized', 401, rate.headers) + } + if (message === 'Forbidden') { + return text('Forbidden', 403, rate.headers) + } + if (message === 'Skill not found' || message === 'User not found') { + return text('Not found', 404, rate.headers) + } + return text(message, 400, rate.headers) } } @@ -483,8 +493,18 @@ async function skillsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) { deleted: true, }) return json({ ok: true }, 200, rate.headers) - } catch { - return text('Unauthorized', 401, rate.headers) + } catch (error) { + const message = error instanceof Error ? error.message : 'Delete failed' + if (message === 'Unauthorized') { + return text('Unauthorized', 401, rate.headers) + } + if (message === 'Forbidden') { + return text('Forbidden', 403, rate.headers) + } + if (message === 'Skill not found' || message === 'User not found') { + return text('Not found', 404, rate.headers) + } + return text(message, 400, rate.headers) } } @@ -1052,8 +1072,18 @@ async function soulsPostRouterV1Handler(ctx: ActionCtx, request: Request) { deleted: false, }) return json({ ok: true }, 200, rate.headers) - } catch { - return text('Unauthorized', 401, rate.headers) + } catch (error) { + const message = error instanceof Error ? error.message : 'Undelete failed' + if (message === 'Unauthorized') { + return text('Unauthorized', 401, rate.headers) + } + if (message === 'Forbidden') { + return text('Forbidden', 403, rate.headers) + } + if (message === 'Soul not found' || message === 'User not found') { + return text('Not found', 404, rate.headers) + } + return text(message, 400, rate.headers) } } @@ -1074,8 +1104,18 @@ async function soulsDeleteRouterV1Handler(ctx: ActionCtx, request: Request) { deleted: true, }) return json({ ok: true }, 200, rate.headers) - } catch { - return text('Unauthorized', 401, rate.headers) + } catch (error) { + const message = error instanceof Error ? error.message : 'Delete failed' + if (message === 'Unauthorized') { + return text('Unauthorized', 401, rate.headers) + } + if (message === 'Forbidden') { + return text('Forbidden', 403, rate.headers) + } + if (message === 'Soul not found' || message === 'User not found') { + return text('Not found', 404, rate.headers) + } + return text(message, 400, rate.headers) } } diff --git a/e2e/clawdhub.e2e.test.ts b/e2e/clawdhub.e2e.test.ts index 436510d..6f4cff5 100644 --- a/e2e/clawdhub.e2e.test.ts +++ b/e2e/clawdhub.e2e.test.ts @@ -47,7 +47,7 @@ async function makeTempConfig(registry: string, token: string | null) { async function fetchWithTimeout(input: RequestInfo | URL, init?: RequestInit) { const controller = new AbortController() - const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS) + const timeout = setTimeout(() => controller.abort(new Error('Timeout')), REQUEST_TIMEOUT_MS) try { return await fetch(input, { ...init, signal: controller.signal }) } finally { @@ -491,4 +491,49 @@ describe('clawdhub e2e', () => { 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://clawdhub.com' + const site = process.env.CLAWDHUB_SITE?.trim() || 'https://clawdhub.com' + const token = mustGetToken() ?? (await readGlobalConfig())?.token ?? null + if (!token) { + throw new Error('Missing token. Set CLAWDHUB_E2E_TOKEN or run: bun clawdhub auth login') + } + + const cfg = await makeTempConfig(registry, token) + const workdir = await mkdtemp(join(tmpdir(), 'clawdhub-e2e-delete-')) + const nonExistentSlug = `non-existent-skill-${Date.now()}` + + try { + const del = spawnSync( + 'bun', + [ + 'clawdhub', + '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) }) diff --git a/packages/clawdhub/src/http.ts b/packages/clawdhub/src/http.ts index 9c72a59..647b8f5 100644 --- a/packages/clawdhub/src/http.ts +++ b/packages/clawdhub/src/http.ts @@ -54,7 +54,7 @@ export async function apiRequest( body = JSON.stringify(args.body ?? {}) } const controller = new AbortController() - const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS) + const timeout = setTimeout(() => controller.abort(new Error('Timeout')), REQUEST_TIMEOUT_MS) const response = await fetch(url, { method: args.method, headers, @@ -103,7 +103,7 @@ export async function apiRequestForm( const headers: Record = { Accept: 'application/json' } if (args.token) headers.Authorization = `Bearer ${args.token}` const controller = new AbortController() - const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS) + const timeout = setTimeout(() => controller.abort(new Error('Timeout')), REQUEST_TIMEOUT_MS) const response = await fetch(url, { method: args.method, headers, @@ -138,7 +138,7 @@ export async function downloadZip(registry: string, args: { slug: string; versio } const controller = new AbortController() - const timeout = setTimeout(() => controller.abort('Timeout'), REQUEST_TIMEOUT_MS) + const timeout = setTimeout(() => controller.abort(new Error('Timeout')), REQUEST_TIMEOUT_MS) const response = await fetch(url.toString(), { method: 'GET', signal: controller.signal }) clearTimeout(timeout) if (!response.ok) {