diff --git a/electron/gateway/clawhub.ts b/electron/gateway/clawhub.ts index 1f45f5a81..9ea2280ba 100644 --- a/electron/gateway/clawhub.ts +++ b/electron/gateway/clawhub.ts @@ -8,6 +8,7 @@ import path from 'path'; import { app, shell } from 'electron'; import { getOpenClawConfigDir, ensureDir, getClawHubCliBinPath, getClawHubCliEntryPath, quoteForCmd } from '../utils/paths'; + export interface ClawHubSearchParams { query: string; limit?: number; @@ -127,6 +128,7 @@ export class ClawHubService { * Run a ClawHub CLI command */ private async runCommand(args: string[]): Promise { + return new Promise((resolve, reject) => { if (this.useNodeRunner && !fs.existsSync(this.cliEntryPath)) { reject(new Error(`ClawHub CLI entry not found at: ${this.cliEntryPath}`)); @@ -145,7 +147,7 @@ export class ClawHubService { const isWin = process.platform === 'win32'; const useShell = isWin && !this.useNodeRunner; const { NODE_OPTIONS: _nodeOptions, ...baseEnv } = process.env; - const env = { + const env: Record = { ...baseEnv, CI: 'true', FORCE_COLOR: '0', diff --git a/package.json b/package.json index 9d4fa284e..4d1228c8f 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ }, "dependencies": { "@sinclair/typebox": "^0.34.48", - "clawhub": "^0.5.0", + "clawhub": "^0.9.0", "electron-store": "^11.0.2", "electron-updater": "^6.8.3", "lru-cache": "^11.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f1c155ae..52bfbacc8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,8 +16,8 @@ importers: specifier: ^0.34.48 version: 0.34.48 clawhub: - specifier: ^0.5.0 - version: 0.5.0 + specifier: ^0.9.0 + version: 0.9.0 electron-store: specifier: ^11.0.2 version: 11.0.2 @@ -528,15 +528,9 @@ packages: '@cacheable/utils@2.4.0': resolution: {integrity: sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==} - '@clack/core@0.5.0': - resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} - '@clack/core@1.1.0': resolution: {integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==} - '@clack/prompts@0.11.0': - resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} - '@clack/prompts@1.1.0': resolution: {integrity: sha512-pkqbPGtohJAvm4Dphs2M8xE29ggupihHdy1x84HNojZuMtFsHiUlRvqD24tM2+XmI+61LlfNceM3Wr7U5QES5g==} @@ -2884,8 +2878,8 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - clawhub@0.5.0: - resolution: {integrity: sha512-tIPoup8mY3ojR+fzzf85ft+vrhMd6u6188QzBEOf/f5/0NSoWW0fl7ojw6VgVSLbBtLa5MGQDxSuZkf9TqPwIw==} + clawhub@0.9.0: + resolution: {integrity: sha512-p4qFJ84qF194KlGj0LlnuggPk0kKRgbp1wN27aJnRQ5FkwXlEalGqw8wngG2Ghca7q6vbvyVoI4V4KDv2zJWdQ==} engines: {node: '>=20'} hasBin: true @@ -6615,21 +6609,10 @@ snapshots: hashery: 1.5.1 keyv: 5.6.0 - '@clack/core@0.5.0': - dependencies: - picocolors: 1.1.1 - sisteransi: 1.0.5 - '@clack/core@1.1.0': dependencies: sisteransi: 1.0.5 - '@clack/prompts@0.11.0': - dependencies: - '@clack/core': 0.5.0 - picocolors: 1.1.1 - sisteransi: 1.0.5 - '@clack/prompts@1.1.0': dependencies: '@clack/core': 1.1.0 @@ -9105,9 +9088,9 @@ snapshots: dependencies: clsx: 2.1.1 - clawhub@0.5.0: + clawhub@0.9.0: dependencies: - '@clack/prompts': 0.11.0 + '@clack/prompts': 1.1.0 arktype: 2.2.0 commander: 14.0.3 fflate: 0.8.2 diff --git a/src/pages/Skills/index.tsx b/src/pages/Skills/index.tsx index e7b903cc0..a2523721b 100644 --- a/src/pages/Skills/index.tsx +++ b/src/pages/Skills/index.tsx @@ -739,7 +739,7 @@ export function Skills() {
- {['fetchTimeoutError', 'fetchRateLimitError', 'timeoutError', 'rateLimitError'].includes(error) + {['fetchTimeoutError', 'fetchRateLimitError'].includes(error) ? t(`toast.${error}`, { path: skillsDirPath }) : error} @@ -851,7 +851,7 @@ export function Skills() {
- {['searchTimeoutError', 'searchRateLimitError', 'timeoutError', 'rateLimitError'].includes(searchError.replace('Error: ', '')) + {['searchTimeoutError', 'searchRateLimitError'].includes(searchError.replace('Error: ', '')) ? t(`toast.${searchError.replace('Error: ', '')}`, { path: skillsDirPath }) : t('marketplace.searchError')} diff --git a/src/stores/skills.ts b/src/stores/skills.ts index 5c74ab33b..20b4f35e0 100644 --- a/src/stores/skills.ts +++ b/src/stores/skills.ts @@ -39,7 +39,7 @@ type ClawHubListResult = { function mapErrorCodeToSkillErrorKey( code: AppError['code'], operation: 'fetch' | 'search' | 'install', -): string { +): string | null { if (code === 'TIMEOUT') { return operation === 'search' ? 'searchTimeoutError' @@ -54,7 +54,7 @@ function mapErrorCodeToSkillErrorKey( ? 'installRateLimitError' : 'fetchRateLimitError'; } - return 'rateLimitError'; + return null; } interface SkillsState { @@ -171,7 +171,7 @@ export const useSkillsStore = create((set, get) => ({ } catch (error) { console.error('Failed to fetch skills:', error); const appError = normalizeAppError(error, { module: 'skills', operation: 'fetch' }); - set({ loading: false, error: mapErrorCodeToSkillErrorKey(appError.code, 'fetch') }); + set({ loading: false, error: mapErrorCodeToSkillErrorKey(appError.code, 'fetch') || appError.message }); } }, @@ -192,7 +192,7 @@ export const useSkillsStore = create((set, get) => ({ } } catch (error) { const appError = normalizeAppError(error, { module: 'skills', operation: 'search' }); - set({ searchError: mapErrorCodeToSkillErrorKey(appError.code, 'search') }); + set({ searchError: mapErrorCodeToSkillErrorKey(appError.code, 'search') || appError.message }); } finally { set({ searching: false }); } @@ -210,7 +210,7 @@ export const useSkillsStore = create((set, get) => ({ module: 'skills', operation: 'install', }); - throw new Error(mapErrorCodeToSkillErrorKey(appError.code, 'install')); + throw new Error(mapErrorCodeToSkillErrorKey(appError.code, 'install') || appError.message); } // Refresh skills after install await get().fetchSkills(); diff --git a/tests/unit/clawhub-registry.test.ts b/tests/unit/clawhub-registry.test.ts new file mode 100644 index 000000000..b8f86d2d5 --- /dev/null +++ b/tests/unit/clawhub-registry.test.ts @@ -0,0 +1,182 @@ +/** + * Tests for ClawHub China mirror registry switching logic. + * + * Verifies that when the app language starts with 'zh', the spawned ClawHub CLI + * process receives CLAWHUB_REGISTRY=https://mirror-cn.clawhub.com, and that + * non-Chinese locales do NOT set this variable. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { EventEmitter } from 'events'; + +/* ---------- hoisted mocks ---------- */ +const { + mockGetSetting, + mockExistsSync, + mockSpawn, + mockEnsureDir, +} = vi.hoisted(() => ({ + mockGetSetting: vi.fn<() => Promise>(), + mockExistsSync: vi.fn<(p: string) => boolean>(), + mockSpawn: vi.fn(), + mockEnsureDir: vi.fn(), +})); + +/* ---------- module mocks ---------- */ +vi.mock('child_process', async () => { + const actual = await vi.importActual('child_process'); + return { ...actual, default: { ...actual, spawn: mockSpawn }, spawn: mockSpawn }; +}); + +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + default: { ...actual, existsSync: mockExistsSync, readdirSync: vi.fn(() => []) }, + existsSync: mockExistsSync, + }; +}); + +vi.mock('electron', () => ({ + app: { get isPackaged() { return true; } }, + shell: { openPath: vi.fn() }, +})); + +vi.mock('@electron/utils/paths', () => ({ + getOpenClawConfigDir: () => '/tmp/test-openclaw', + ensureDir: mockEnsureDir, + getClawHubCliBinPath: () => '/tmp/clawhub-bin', + getClawHubCliEntryPath: () => '/tmp/clawhub-entry.mjs', + quoteForCmd: (s: string) => `"${s}"`, +})); + +vi.mock('@electron/utils/store', () => ({ + getSetting: (...args: unknown[]) => mockGetSetting(...args), +})); + +/* ---------- helpers ---------- */ + +/** Create a fake ChildProcess that emits stdout data then exits with code 0. */ +function makeFakeChild(stdoutData: string = 'ok') { + const child = new EventEmitter() as ReturnType; + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + (child as any).stdout = stdout; + (child as any).stderr = stderr; + + // Simulate async output + successful exit + queueMicrotask(() => { + stdout.emit('data', Buffer.from(stdoutData)); + child.emit('close', 0); + }); + + return child; +} + +/* ---------- test suite ---------- */ + +describe('ClawHub China mirror registry', () => { + let spawnEnvCapture: Record | undefined; + + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + + // Default: CLI entry exists so constructor succeeds (useNodeRunner = true path) + mockExistsSync.mockReturnValue(true); + + // Capture the env passed to spawn + mockSpawn.mockImplementation((_cmd: string, _args: string[], opts: any) => { + spawnEnvCapture = opts?.env; + return makeFakeChild(); + }); + }); + + afterEach(() => { + spawnEnvCapture = undefined; + }); + + it('sets CLAWHUB_REGISTRY with HTTPS for zh-CN locale', async () => { + mockGetSetting.mockResolvedValueOnce('zh-CN'); + + const { ClawHubService } = await import('@electron/gateway/clawhub'); + const service = new ClawHubService(); + + // Use search as the trigger to invoke runCommand + await service.search({ query: 'test' }); + + expect(spawnEnvCapture).toBeDefined(); + expect(spawnEnvCapture!.CLAWHUB_REGISTRY).toBe('https://mirror-cn.clawhub.com'); + }); + + it('sets CLAWHUB_REGISTRY with HTTPS for zh-TW locale', async () => { + mockGetSetting.mockResolvedValueOnce('zh-TW'); + + const { ClawHubService } = await import('@electron/gateway/clawhub'); + const service = new ClawHubService(); + + await service.search({ query: 'test' }); + + expect(spawnEnvCapture).toBeDefined(); + expect(spawnEnvCapture!.CLAWHUB_REGISTRY).toBe('https://mirror-cn.clawhub.com'); + }); + + it('sets CLAWHUB_REGISTRY with HTTPS for bare zh locale', async () => { + mockGetSetting.mockResolvedValueOnce('zh'); + + const { ClawHubService } = await import('@electron/gateway/clawhub'); + const service = new ClawHubService(); + + await service.search({ query: 'test' }); + + expect(spawnEnvCapture).toBeDefined(); + expect(spawnEnvCapture!.CLAWHUB_REGISTRY).toBe('https://mirror-cn.clawhub.com'); + }); + + it('does NOT set CLAWHUB_REGISTRY for en locale', async () => { + mockGetSetting.mockResolvedValueOnce('en'); + + const { ClawHubService } = await import('@electron/gateway/clawhub'); + const service = new ClawHubService(); + + await service.search({ query: 'test' }); + + expect(spawnEnvCapture).toBeDefined(); + expect(spawnEnvCapture!.CLAWHUB_REGISTRY).toBeUndefined(); + }); + + it('does NOT set CLAWHUB_REGISTRY for ja locale', async () => { + mockGetSetting.mockResolvedValueOnce('ja'); + + const { ClawHubService } = await import('@electron/gateway/clawhub'); + const service = new ClawHubService(); + + await service.search({ query: 'test' }); + + expect(spawnEnvCapture).toBeDefined(); + expect(spawnEnvCapture!.CLAWHUB_REGISTRY).toBeUndefined(); + }); + + it('does NOT set CLAWHUB_REGISTRY when language is undefined', async () => { + mockGetSetting.mockResolvedValueOnce(undefined); + + const { ClawHubService } = await import('@electron/gateway/clawhub'); + const service = new ClawHubService(); + + await service.search({ query: 'test' }); + + expect(spawnEnvCapture).toBeDefined(); + expect(spawnEnvCapture!.CLAWHUB_REGISTRY).toBeUndefined(); + }); + + it('uses HTTPS, not HTTP, for the registry URL', async () => { + mockGetSetting.mockResolvedValueOnce('zh-CN'); + + const { ClawHubService } = await import('@electron/gateway/clawhub'); + const service = new ClawHubService(); + + await service.search({ query: 'test' }); + + expect(spawnEnvCapture!.CLAWHUB_REGISTRY).toMatch(/^https:\/\//); + expect(spawnEnvCapture!.CLAWHUB_REGISTRY).not.toMatch(/^http:\/\//); + }); +});