diff --git a/CHANGELOG.md b/CHANGELOG.md index 93e95efbe..7bd01944d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ - Skills/Web: centralize public visibility checks and keep `globalStats` skill counts in sync incrementally; remove duplicate `/skills` default-sort fallback and share browse test mocks (thanks @rknoche6, #76). - Moderation: clear stale `flagged.suspicious` flags when VirusTotal rescans improve to clean verdicts (#418) (thanks @Phineas1500). - CLI: respect `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` env vars for outbound registry requests, with troubleshooting docs (#363) (thanks @kerrypotter). +- CLI: preserve registry base paths when composing API URLs for search/inspect/moderation commands (#486) (thanks @Liknox). ## 0.6.1 - 2026-02-13 diff --git a/packages/clawdhub/src/cli/commands/inspect.test.ts b/packages/clawdhub/src/cli/commands/inspect.test.ts index 9ebeb697e..04a73f931 100644 --- a/packages/clawdhub/src/cli/commands/inspect.test.ts +++ b/packages/clawdhub/src/cli/commands/inspect.test.ts @@ -6,9 +6,15 @@ import type { GlobalOpts } from '../types' const mockApiRequest = vi.fn() const mockFetchText = vi.fn() +const mockRegistryUrl = vi.fn((path: string, registry: string) => { + const base = registry.endsWith('/') ? registry : `${registry}/` + const relative = path.startsWith('/') ? path.slice(1) : path + return new URL(relative, base) +}) vi.mock('../../http.js', () => ({ apiRequest: (...args: unknown[]) => mockApiRequest(...args), fetchText: (...args: unknown[]) => mockFetchText(...args), + registryUrl: (...args: [string, string]) => mockRegistryUrl(...args), })) const mockGetRegistry = vi.fn(async () => 'https://clawhub.ai') diff --git a/packages/clawdhub/src/cli/commands/inspect.ts b/packages/clawdhub/src/cli/commands/inspect.ts index bbbe58e62..5adf0968d 100644 --- a/packages/clawdhub/src/cli/commands/inspect.ts +++ b/packages/clawdhub/src/cli/commands/inspect.ts @@ -1,4 +1,4 @@ -import { apiRequest, fetchText } from '../../http.js' +import { apiRequest, fetchText, registryUrl } from '../../http.js' import { ApiRoutes, ApiV1SkillResponseSchema, @@ -78,7 +78,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec let versionsList: { items?: unknown[]; nextCursor?: string | null } | null = null if (options.versions) { const limit = clampLimit(options.limit ?? 25, 25) - const url = new URL(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions`, registry) + const url = registryUrl(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/versions`, registry) url.searchParams.set('limit', String(limit)) spinner.text = `Fetching versions (${limit})` versionsList = await apiRequest( @@ -90,7 +90,7 @@ export async function cmdInspect(opts: GlobalOpts, slug: string, options: Inspec let fileContent: string | null = null if (options.file) { - const url = new URL(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/file`, registry) + const url = registryUrl(`${ApiRoutes.skills}/${encodeURIComponent(trimmed)}/file`, registry) url.searchParams.set('path', options.file) if (options.version) { url.searchParams.set('version', options.version) diff --git a/packages/clawdhub/src/cli/commands/moderation.test.ts b/packages/clawdhub/src/cli/commands/moderation.test.ts index 9d7e29699..fd74eb3e6 100644 --- a/packages/clawdhub/src/cli/commands/moderation.test.ts +++ b/packages/clawdhub/src/cli/commands/moderation.test.ts @@ -12,9 +12,15 @@ vi.mock('../registry.js', () => ({ })) const mockApiRequest = vi.fn() +const mockRegistryUrl = vi.fn((path: string, registry: string) => { + const base = registry.endsWith('/') ? registry : `${registry}/` + const relative = path.startsWith('/') ? path.slice(1) : path + return new URL(relative, base) +}) vi.mock('../../http.js', () => ({ apiRequest: (registry: unknown, args: unknown, schema?: unknown) => mockApiRequest(registry, args, schema), + registryUrl: (...args: [string, string]) => mockRegistryUrl(...args), })) vi.mock('../ui.js', () => ({ @@ -116,7 +122,7 @@ describe('cmdBanUser', () => { expect.anything(), expect.objectContaining({ method: 'GET', - path: expect.stringContaining('/api/v1/users?'), + url: expect.stringContaining('/api/v1/users?'), }), expect.anything(), ) diff --git a/packages/clawdhub/src/cli/commands/moderation.ts b/packages/clawdhub/src/cli/commands/moderation.ts index 637efb665..179a24f12 100644 --- a/packages/clawdhub/src/cli/commands/moderation.ts +++ b/packages/clawdhub/src/cli/commands/moderation.ts @@ -1,5 +1,5 @@ import { isCancel, select } from '@clack/prompts' -import { apiRequest } from '../../http.js' +import { apiRequest, registryUrl } from '../../http.js' import { ApiRoutes, ApiV1BanUserResponseSchema, @@ -192,12 +192,12 @@ async function resolveUserIdentifier( } async function searchUsers(registry: string, token: string, query: string) { - const url = new URL(ApiRoutes.users, registry) + const url = registryUrl(ApiRoutes.users, registry) url.searchParams.set('q', query.trim()) url.searchParams.set('limit', '10') const result = await apiRequest( registry, - { method: 'GET', path: `${url.pathname}?${url.searchParams.toString()}`, token }, + { method: 'GET', url: url.toString(), token }, ApiV1UserSearchResponseSchema, ) return parseArk(ApiV1UserSearchResponseSchema, result, 'User search response') diff --git a/packages/clawdhub/src/cli/commands/skills.test.ts b/packages/clawdhub/src/cli/commands/skills.test.ts index b3e701734..ad327cafc 100644 --- a/packages/clawdhub/src/cli/commands/skills.test.ts +++ b/packages/clawdhub/src/cli/commands/skills.test.ts @@ -6,9 +6,15 @@ import type { GlobalOpts } from '../types' const mockApiRequest = vi.fn() const mockDownloadZip = vi.fn() +const mockRegistryUrl = vi.fn((path: string, registry: string) => { + const base = registry.endsWith('/') ? registry : `${registry}/` + const relative = path.startsWith('/') ? path.slice(1) : path + return new URL(relative, base) +}) vi.mock('../../http.js', () => ({ apiRequest: (...args: unknown[]) => mockApiRequest(...args), downloadZip: (...args: unknown[]) => mockDownloadZip(...args), + registryUrl: (...args: [string, string]) => mockRegistryUrl(...args), })) const mockGetRegistry = vi.fn(async () => 'https://clawhub.ai') diff --git a/packages/clawdhub/src/cli/commands/skills.ts b/packages/clawdhub/src/cli/commands/skills.ts index bd8d6617a..b3e89be63 100644 --- a/packages/clawdhub/src/cli/commands/skills.ts +++ b/packages/clawdhub/src/cli/commands/skills.ts @@ -1,7 +1,7 @@ import { mkdir, rm, stat } from 'node:fs/promises' import { join } from 'node:path' import semver from 'semver' -import { apiRequest, downloadZip } from '../../http.js' +import { apiRequest, downloadZip, registryUrl } from '../../http.js' import { ApiRoutes, ApiV1SearchResponseSchema, @@ -43,7 +43,7 @@ export async function cmdSearch(opts: GlobalOpts, query: string, limit?: number) const registry = await getRegistry(opts, { cache: true }) const spinner = createSpinner('Searching') try { - const url = new URL(ApiRoutes.search, registry) + const url = registryUrl(ApiRoutes.search, registry) url.searchParams.set('q', query) if (typeof limit === 'number' && Number.isFinite(limit)) { url.searchParams.set('limit', String(limit)) @@ -363,7 +363,7 @@ export async function cmdExplore( const registry = await getRegistry(opts, { cache: true }) const spinner = createSpinner('Fetching latest skills') try { - const url = new URL(ApiRoutes.skills, registry) + const url = registryUrl(ApiRoutes.skills, registry) const boundedLimit = clampLimit(options.limit ?? 25) const { apiSort } = resolveExploreSort(options.sort) url.searchParams.set('limit', String(boundedLimit)) @@ -465,7 +465,7 @@ function resolveExploreSort(raw?: string): { sort: ExploreSort; apiSort: ApiExpl } async function resolveSkillVersion(registry: string, slug: string, hash: string, token?: string) { - const url = new URL(ApiRoutes.resolve, registry) + const url = registryUrl(ApiRoutes.resolve, registry) url.searchParams.set('slug', slug) url.searchParams.set('hash', hash) return apiRequest( diff --git a/packages/clawdhub/src/http.test.ts b/packages/clawdhub/src/http.test.ts index eea3ff959..310c6a5d3 100644 --- a/packages/clawdhub/src/http.test.ts +++ b/packages/clawdhub/src/http.test.ts @@ -1,7 +1,14 @@ /* @vitest-environment node */ import { describe, expect, it, vi } from 'vitest' -import { apiRequest, apiRequestForm, downloadZip, fetchText, shouldUseProxyFromEnv } from './http' +import { + apiRequest, + apiRequestForm, + downloadZip, + fetchText, + registryUrl, + shouldUseProxyFromEnv, +} from './http' import { ApiV1WhoamiResponseSchema } from './schema/index.js' function mockImmediateTimeouts() { @@ -65,6 +72,42 @@ describe('shouldUseProxyFromEnv', () => { }) }) +describe('registryUrl', () => { + it('works with a plain-origin registry (no base path)', () => { + expect(registryUrl('/api/v1/skills', 'https://clawhub.ai').toString()).toBe( + 'https://clawhub.ai/api/v1/skills', + ) + }) + + it('preserves the registry base path', () => { + const base = 'http://localhost:8081/custom/registry/path' + expect(registryUrl('/api/v1/skills', base).toString()).toBe( + 'http://localhost:8081/custom/registry/path/api/v1/skills', + ) + }) + + it('handles a trailing slash on the registry', () => { + const base = 'http://localhost:8081/custom/registry/path/' + expect(registryUrl('/api/v1/skills', base).toString()).toBe( + 'http://localhost:8081/custom/registry/path/api/v1/skills', + ) + }) + + it('handles paths without a leading slash', () => { + expect(registryUrl('api/v1/skills', 'https://clawhub.ai').toString()).toBe( + 'https://clawhub.ai/api/v1/skills', + ) + }) + + it('handles compound paths with encoded segments', () => { + const base = 'http://localhost:8081/base' + const path = `/api/v1/skills/${encodeURIComponent('my-skill')}/versions` + expect(registryUrl(path, base).toString()).toBe( + 'http://localhost:8081/base/api/v1/skills/my-skill/versions', + ) + }) +}) + describe('apiRequest', () => { it('adds bearer token and parses json', async () => { const fetchMock = vi.fn().mockResolvedValue({ diff --git a/packages/clawdhub/src/http.ts b/packages/clawdhub/src/http.ts index 82581a1fd..bdd007c98 100644 --- a/packages/clawdhub/src/http.ts +++ b/packages/clawdhub/src/http.ts @@ -48,6 +48,12 @@ if (typeof process !== 'undefined' && process.versions?.node) { } } +export function registryUrl(path: string, registry: string): URL { + const base = registry.endsWith('/') ? registry : `${registry}/` + const relative = path.startsWith('/') ? path.slice(1) : path + return new URL(relative, base) +} + type RequestArgs = | { method: 'GET' | 'POST' | 'DELETE'; path: string; token?: string; body?: unknown } | { method: 'GET' | 'POST' | 'DELETE'; url: string; token?: string; body?: unknown } @@ -84,7 +90,7 @@ export async function apiRequest( args: RequestArgs, schema?: ArkValidator, ): Promise { - const url = 'url' in args ? args.url : new URL(args.path, registry).toString() + const url = 'url' in args ? args.url : registryUrl(args.path, registry).toString() const json = await runWithRetries( async () => { if (isBun) { @@ -128,7 +134,7 @@ export async function apiRequestForm( args: FormRequestArgs, schema?: ArkValidator, ): Promise { - const url = 'url' in args ? args.url : new URL(args.path, registry).toString() + const url = 'url' in args ? args.url : registryUrl(args.path, registry).toString() const json = await runWithRetries( async () => { if (isBun) { @@ -155,7 +161,7 @@ export async function apiRequestForm( type TextRequestArgs = { path: string; token?: string } | { url: string; token?: string } export async function fetchText(registry: string, args: TextRequestArgs): Promise { - const url = 'url' in args ? args.url : new URL(args.path, registry).toString() + const url = 'url' in args ? args.url : registryUrl(args.path, registry).toString() return runWithRetries( async () => { if (isBun) { @@ -178,7 +184,7 @@ export async function downloadZip( registry: string, args: { slug: string; version?: string; token?: string }, ) { - const url = new URL(ApiRoutes.download, registry) + const url = registryUrl(ApiRoutes.download, registry) url.searchParams.set('slug', args.slug) if (args.version) url.searchParams.set('version', args.version) return runWithRetries(