diff --git a/convex/lib/skillPublish.ts b/convex/lib/skillPublish.ts index aa9743b3..987433a4 100644 --- a/convex/lib/skillPublish.ts +++ b/convex/lib/skillPublish.ts @@ -180,11 +180,11 @@ export async function publishVersionForUser( ) let similarRecentCount = 0 for (const entry of recentCandidates) { - const version = (await ctx.runQuery(internal.skills.getVersionByIdInternal, { + const recentVersion = (await ctx.runQuery(internal.skills.getVersionByIdInternal, { versionId: entry.latestVersionId as Id<'skillVersions'>, })) as Doc<'skillVersions'> | null - if (!version) continue - const candidateReadmeFile = version.files.find((file) => { + if (!recentVersion) continue + const candidateReadmeFile = recentVersion.files.find((file) => { const lower = file.path.toLowerCase() return lower === 'skill.md' || lower === 'skills.md' }) diff --git a/convex/lib/skills.ts b/convex/lib/skills.ts index 6774fe96..a8fd5e43 100644 --- a/convex/lib/skills.ts +++ b/convex/lib/skills.ts @@ -98,14 +98,14 @@ export function parseClawdisMetadata(frontmatter: ParsedSkillFrontmatter) { .filter((entry): entry is SkillInstallSpec => Boolean(entry)) const osRaw = normalizeStringList(clawdisObj.os) - const metadata: ClawdisSkillMetadata = {} - if (typeof clawdisObj.always === 'boolean') metadata.always = clawdisObj.always - if (typeof clawdisObj.emoji === 'string') metadata.emoji = clawdisObj.emoji - if (typeof clawdisObj.homepage === 'string') metadata.homepage = clawdisObj.homepage - if (typeof clawdisObj.skillKey === 'string') metadata.skillKey = clawdisObj.skillKey - if (typeof clawdisObj.primaryEnv === 'string') metadata.primaryEnv = clawdisObj.primaryEnv - if (typeof clawdisObj.cliHelp === 'string') metadata.cliHelp = clawdisObj.cliHelp - if (osRaw.length > 0) metadata.os = osRaw + const parsedMetadata: ClawdisSkillMetadata = {} + if (typeof clawdisObj.always === 'boolean') parsedMetadata.always = clawdisObj.always + if (typeof clawdisObj.emoji === 'string') parsedMetadata.emoji = clawdisObj.emoji + if (typeof clawdisObj.homepage === 'string') parsedMetadata.homepage = clawdisObj.homepage + if (typeof clawdisObj.skillKey === 'string') parsedMetadata.skillKey = clawdisObj.skillKey + if (typeof clawdisObj.primaryEnv === 'string') parsedMetadata.primaryEnv = clawdisObj.primaryEnv + if (typeof clawdisObj.cliHelp === 'string') parsedMetadata.cliHelp = clawdisObj.cliHelp + if (osRaw.length > 0) parsedMetadata.os = osRaw if (requiresRaw) { const bins = normalizeStringList(requiresRaw.bins) @@ -113,34 +113,34 @@ export function parseClawdisMetadata(frontmatter: ParsedSkillFrontmatter) { const env = normalizeStringList(requiresRaw.env) const config = normalizeStringList(requiresRaw.config) if (bins.length || anyBins.length || env.length || config.length) { - metadata.requires = {} - if (bins.length) metadata.requires.bins = bins - if (anyBins.length) metadata.requires.anyBins = anyBins - if (env.length) metadata.requires.env = env - if (config.length) metadata.requires.config = config + parsedMetadata.requires = {} + if (bins.length) parsedMetadata.requires.bins = bins + if (anyBins.length) parsedMetadata.requires.anyBins = anyBins + if (env.length) parsedMetadata.requires.env = env + if (config.length) parsedMetadata.requires.config = config } } - if (install.length > 0) metadata.install = install + if (install.length > 0) parsedMetadata.install = install const nix = parseNixPluginSpec(clawdisObj.nix) - if (nix) metadata.nix = nix + if (nix) parsedMetadata.nix = nix const config = parseClawdbotConfigSpec(clawdisObj.config) - if (config) metadata.config = config + if (config) parsedMetadata.config = config // Parse env var declarations (detailed env with descriptions) const envVars = parseEnvVarDeclarations(clawdisObj.envVars ?? clawdisObj.env) - if (envVars.length > 0) metadata.envVars = envVars + if (envVars.length > 0) parsedMetadata.envVars = envVars // Parse dependency declarations const dependencies = parseDependencyDeclarations(clawdisObj.dependencies) - if (dependencies.length > 0) metadata.dependencies = dependencies + if (dependencies.length > 0) parsedMetadata.dependencies = dependencies // Parse author and links - if (typeof clawdisObj.author === 'string') metadata.author = clawdisObj.author + if (typeof clawdisObj.author === 'string') parsedMetadata.author = clawdisObj.author const links = parseSkillLinks(clawdisObj.links) - if (links) metadata.links = links + if (links) parsedMetadata.links = links - return parseArk(ClawdisSkillMetadataSchema, metadata, 'Clawdis metadata') + return parseArk(ClawdisSkillMetadataSchema, parsedMetadata, 'Clawdis metadata') } catch { return undefined } diff --git a/convex/skills.ts b/convex/skills.ts index 29c0f717..9eb5b655 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -2526,7 +2526,7 @@ export const getVersionsByIdsInternal = internalQuery({ const versions = await Promise.all( args.versionIds.map((id) => ctx.db.get(id)), ) - return versions.filter((v): v is NonNullable => v !== null) + return versions.filter((versionDoc): versionDoc is NonNullable => versionDoc !== null) }, }) diff --git a/convex/souls.ts b/convex/souls.ts index 481fcbf3..d3dbf123 100644 --- a/convex/souls.ts +++ b/convex/souls.ts @@ -150,7 +150,7 @@ export const getVersionsByIdsInternal = internalQuery({ args: { versionIds: v.array(v.id('soulVersions')) }, handler: async (ctx, args) => { const versions = await Promise.all(args.versionIds.map((id) => ctx.db.get(id))) - return versions.filter((v): v is NonNullable => v !== null) + return versions.filter((versionDoc): versionDoc is NonNullable => versionDoc !== null) }, }) diff --git a/packages/clawdhub/src/http.bun.test.ts b/packages/clawdhub/src/http.bun.test.ts index 14a94f5e..c45c3c42 100644 --- a/packages/clawdhub/src/http.bun.test.ts +++ b/packages/clawdhub/src/http.bun.test.ts @@ -1,23 +1,43 @@ /* @vitest-environment node */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const originalBunVersion = (process.versions as Record).bun - -function enableBunRuntime() { +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const bunRuntimeMocks = vi.hoisted(() => { + const originalBunVersion = (process.versions as Record).bun Object.defineProperty(process.versions, 'bun', { value: '1.2.3', configurable: true, }) -} + + return { + originalBunVersion, + spawnSync: vi.fn(), + mkdtemp: vi.fn(async () => '/tmp/clawhub-test'), + rm: vi.fn(async () => undefined), + writeFile: vi.fn(async () => undefined), + readFile: vi.fn(async () => Buffer.from([1, 2, 3]) as Buffer), + } +}) + +vi.mock('node:child_process', () => ({ + spawnSync: bunRuntimeMocks.spawnSync, +})) + +vi.mock('node:fs/promises', () => ({ + mkdtemp: bunRuntimeMocks.mkdtemp, + rm: bunRuntimeMocks.rm, + writeFile: bunRuntimeMocks.writeFile, + readFile: bunRuntimeMocks.readFile, +})) + +import * as http from './http' function restoreBunRuntime() { - if (originalBunVersion === undefined) { + if (bunRuntimeMocks.originalBunVersion === undefined) { Reflect.deleteProperty(process.versions, 'bun') return } Object.defineProperty(process.versions, 'bun', { - value: originalBunVersion, + value: bunRuntimeMocks.originalBunVersion, configurable: true, }) } @@ -33,50 +53,61 @@ function mockImmediateTimeouts() { return { setTimeoutMock, clearTimeoutMock } } +type SpawnImpl = (...args: unknown[]) => unknown + async function loadHttpModuleWithBunMocks(opts?: { - spawnImpl?: ReturnType + spawnImpl?: SpawnImpl mkdtempValue?: string readFileValue?: Buffer | null }) { - const spawnSync = opts?.spawnImpl ?? vi.fn() - const mkdtemp = vi.fn(async () => opts?.mkdtempValue ?? '/tmp/clawhub-test') - const rm = vi.fn(async () => undefined) - const writeFile = vi.fn(async () => undefined) - const readFile = vi.fn(async () => opts?.readFileValue ?? Buffer.from([1, 2, 3])) + const spawnSync: SpawnImpl = opts?.spawnImpl ?? vi.fn() + bunRuntimeMocks.spawnSync.mockImplementation((...args: unknown[]) => spawnSync(...args)) + bunRuntimeMocks.mkdtemp.mockImplementation(async () => opts?.mkdtempValue ?? '/tmp/clawhub-test') + bunRuntimeMocks.rm.mockImplementation(async () => undefined) + bunRuntimeMocks.writeFile.mockImplementation(async () => undefined) + bunRuntimeMocks.readFile.mockImplementation( + async () => (opts?.readFileValue ?? Buffer.from([1, 2, 3])) as Buffer, + ) - vi.doMock('node:child_process', () => ({ spawnSync })) - vi.doMock('node:fs/promises', () => ({ mkdtemp, rm, writeFile, readFile })) - - const http = await import('./http') - return { http, spawnSync, mkdtemp, rm, writeFile, readFile } + return { + http, + spawnSync: bunRuntimeMocks.spawnSync, + mkdtemp: bunRuntimeMocks.mkdtemp, + rm: bunRuntimeMocks.rm, + writeFile: bunRuntimeMocks.writeFile, + readFile: bunRuntimeMocks.readFile, + } } describe('http bun runtime', () => { beforeEach(() => { - vi.resetModules() vi.clearAllMocks() vi.unstubAllGlobals() - enableBunRuntime() + Object.defineProperty(process.versions, 'bun', { + value: '1.2.3', + configurable: true, + }) }) afterEach(() => { - restoreBunRuntime() - vi.doUnmock('node:child_process') - vi.doUnmock('node:fs/promises') vi.unstubAllGlobals() }) + afterAll(() => { + restoreBunRuntime() + }) + it('uses curl for apiRequest GET and parses JSON', async () => { const spawnSync = vi.fn().mockReturnValue({ status: 0, stdout: '{"ok":true}\n200', stderr: '', }) - const { http } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync }) + const { http: httpClient } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync }) const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) - const result = await http.apiRequest<{ ok: boolean }>('https://registry.example', { + const result = await httpClient.apiRequest<{ ok: boolean }>('https://registry.example', { method: 'GET', path: '/v1/ping', token: 'clh_token', @@ -98,9 +129,9 @@ describe('http bun runtime', () => { stdout: '{"ok":true}\n200', stderr: '', }) - const { http } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync }) + const { http: httpClient } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync }) - await http.apiRequest('https://registry.example', { + await httpClient.apiRequest('https://registry.example', { method: 'POST', path: '/v1/ping', body: { a: 1 }, @@ -118,10 +149,10 @@ describe('http bun runtime', () => { stdout: 'rate limited\n429', stderr: '', }) - const { http } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync }) + const { http: httpClient } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync }) await expect( - http.apiRequest('https://registry.example', { + httpClient.apiRequest('https://registry.example', { method: 'GET', path: '/v1/ping', }), @@ -138,10 +169,10 @@ describe('http bun runtime', () => { 'rate limited\n__CLAWHUB_CURL_META__\n429\n20\n0\n1771404540\n20\n0\n34\n34\n', stderr: '', }) - const { http } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync }) + const { http: httpClient } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync }) await expect( - http.apiRequest('https://registry.example', { + httpClient.apiRequest('https://registry.example', { method: 'GET', path: '/v1/ping', }), @@ -156,10 +187,10 @@ describe('http bun runtime', () => { stdout: 'missing\n404', stderr: '', }) - const { http } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync }) + const { http: httpClient } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync }) await expect( - http.apiRequest('https://registry.example', { + httpClient.apiRequest('https://registry.example', { method: 'GET', path: '/v1/ping', }), @@ -181,13 +212,13 @@ describe('http bun runtime', () => { stdout: '\n400', stderr: '', }) - const { http } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync }) + const { http: httpClient } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync }) - const text = await http.fetchText('https://registry.example', { path: '/v1/readme' }) + const text = await httpClient.fetchText('https://registry.example', { path: '/v1/readme' }) expect(text).toBe('hello world') await expect( - http.fetchText('https://registry.example', { path: '/v1/readme' }), + httpClient.fetchText('https://registry.example', { path: '/v1/readme' }), ).rejects.toThrow('HTTP 400') }) @@ -204,17 +235,17 @@ describe('http bun runtime', () => { stdout: '404', stderr: '', }) - const { http, rm, readFile } = await loadHttpModuleWithBunMocks({ + const { http: httpClient, rm, readFile } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync, mkdtempValue: '/tmp/clawhub-download-abc', readFileValue: Buffer.from('not found'), }) - const bytes = await http.downloadZip('https://registry.example', { slug: 'demo', token: 't' }) + const bytes = await httpClient.downloadZip('https://registry.example', { slug: 'demo', token: 't' }) expect(Array.from(bytes)).toEqual(Array.from(Buffer.from('not found'))) await expect( - http.downloadZip('https://registry.example', { slug: 'demo', token: 't' }), + httpClient.downloadZip('https://registry.example', { slug: 'demo', token: 't' }), ).rejects.toThrow('not found') expect(readFile).toHaveBeenCalled() @@ -230,7 +261,7 @@ describe('http bun runtime', () => { stdout: '{"ok":true}\n200', stderr: '', }) - const { http, writeFile, rm } = await loadHttpModuleWithBunMocks({ + const { http: httpClient, writeFile, rm } = await loadHttpModuleWithBunMocks({ spawnImpl: spawnSync, mkdtempValue: '/tmp/clawhub-upload-abc', }) @@ -239,7 +270,7 @@ describe('http bun runtime', () => { form.append('name', 'demo') form.append('file', new Blob(['abc'], { type: 'text/plain' }), 'demo.txt') - const result = await http.apiRequestForm<{ ok: boolean }>('https://registry.example', { + const result = await httpClient.apiRequestForm<{ ok: boolean }>('https://registry.example', { method: 'POST', path: '/upload', form, diff --git a/packages/clawdhub/src/http.ts b/packages/clawdhub/src/http.ts index e7e98488..fbd3e29d 100644 --- a/packages/clawdhub/src/http.ts +++ b/packages/clawdhub/src/http.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import pRetry, { AbortError } from 'p-retry' @@ -587,7 +587,6 @@ function setHeaderIfPresent(headers: Record, key: string, value: async function readFileSafe(path: string) { try { - const { readFile } = await import('node:fs/promises') return await readFile(path) } catch { return null diff --git a/src/routes/souls/index.tsx b/src/routes/souls/index.tsx index 7d638ad7..0e67c3fd 100644 --- a/src/routes/souls/index.tsx +++ b/src/routes/souls/index.tsx @@ -140,12 +140,12 @@ function SoulsIndex() { className="skills-sort" value={sort} onChange={(event) => { - const sort = parseSort(event.target.value) + const nextSort = parseSort(event.target.value) void navigate({ search: (prev) => ({ ...prev, - sort, - dir: parseDir(prev.dir, sort), + sort: nextSort, + dir: parseDir(prev.dir, nextSort), }), replace: true, }) diff --git a/src/routes/upload.tsx b/src/routes/upload.tsx index 8a4ac187..4d1a4ddd 100644 --- a/src/routes/upload.tsx +++ b/src/routes/upload.tsx @@ -383,9 +383,9 @@ export function Upload() { params: isSoulMode ? { slug: trimmedSlug } : { owner: ownerParam, slug: trimmedSlug }, }) } - } catch (error) { + } catch (publishError) { setStatus(null) - setError(formatPublishError(error)) + setError(formatPublishError(publishError)) } } diff --git a/vitest.config.ts b/vitest.config.ts index 8436ead8..faf8a82e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,8 @@ export default defineConfig({ environment: 'jsdom', globals: true, setupFiles: ['./vitest.setup.ts'], + testTimeout: 15_000, + hookTimeout: 15_000, exclude: [ '**/node_modules/**', '**/dist/**',