mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
🐛 fix: lh command issue (#12949)
* fix command issue * add run command UI * fix API key * add apikey page * add apikey * 🐛 fix: update apiKey model tests to use new sk-lh- prefix format Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -198,6 +198,7 @@
|
||||
"builtins.lobe-skills.apiName.importFromMarket": "Import from Market",
|
||||
"builtins.lobe-skills.apiName.importSkill": "Import Skill",
|
||||
"builtins.lobe-skills.apiName.readReference": "Read Reference",
|
||||
"builtins.lobe-skills.apiName.runCommand": "Run Command",
|
||||
"builtins.lobe-skills.apiName.searchSkill": "Search Skills",
|
||||
"builtins.lobe-skills.title": "Skills",
|
||||
"builtins.lobe-tools.apiName.activateTools": "Activate Tools",
|
||||
|
||||
@@ -194,6 +194,7 @@
|
||||
"builtins.lobe-skills.apiName.importFromMarket": "从市场导入",
|
||||
"builtins.lobe-skills.apiName.importSkill": "导入技能",
|
||||
"builtins.lobe-skills.apiName.readReference": "读取参考资料",
|
||||
"builtins.lobe-skills.apiName.runCommand": "执行命令",
|
||||
"builtins.lobe-skills.apiName.searchSkill": "搜索技能",
|
||||
"builtins.lobe-skills.title": "技能",
|
||||
"builtins.lobe-tools.apiName.activateTools": "激活工具",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { type BuiltinInspectorProps } from '@lobechat/types';
|
||||
import { createStaticStyles, cssVar, cx } from 'antd-style';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles';
|
||||
|
||||
import type { CommandResult, RunCommandParams } from '../../../types';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
statusIcon: css`
|
||||
margin-block-end: -2px;
|
||||
margin-inline-start: 4px;
|
||||
`,
|
||||
}));
|
||||
|
||||
export const RunCommandInspector = memo<BuiltinInspectorProps<RunCommandParams, CommandResult>>(
|
||||
({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
|
||||
const description = args?.description || partialArgs?.description;
|
||||
|
||||
if (isArgumentsStreaming) {
|
||||
if (!description)
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-skills.apiName.runCommand')}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
|
||||
<span>{t('builtins.lobe-skills.apiName.runCommand')}: </span>
|
||||
<span className={highlightTextStyles.primary}>{description}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
|
||||
<span style={{ marginInlineStart: 2 }}>
|
||||
<span>{t('builtins.lobe-skills.apiName.runCommand')}: </span>
|
||||
{description && <span className={highlightTextStyles.primary}>{description}</span>}
|
||||
{isLoading ? null : pluginState?.success !== undefined ? (
|
||||
pluginState.success ? (
|
||||
<Check className={styles.statusIcon} color={cssVar.colorSuccess} size={14} />
|
||||
) : (
|
||||
<X className={styles.statusIcon} color={cssVar.colorError} size={14} />
|
||||
)
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RunCommandInspector.displayName = 'RunCommandInspector';
|
||||
@@ -1,11 +1,13 @@
|
||||
import { SkillsApiName } from '../../types';
|
||||
import { ExecScriptInspector } from './ExecScript';
|
||||
import { ReadReferenceInspector } from './ReadReference';
|
||||
import { RunCommandInspector } from './RunCommand';
|
||||
import { RunSkillInspector } from './RunSkill';
|
||||
|
||||
export const SkillsInspectors = {
|
||||
[SkillsApiName.execScript]: ExecScriptInspector,
|
||||
[SkillsApiName.readReference]: ReadReferenceInspector,
|
||||
[SkillsApiName.runCommand]: RunCommandInspector,
|
||||
[SkillsApiName.activateSkill]: RunSkillInspector,
|
||||
// @deprecated skill id
|
||||
runSkill: RunSkillInspector,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import type { BuiltinRenderProps } from '@lobechat/types';
|
||||
import { Block, Flexbox, Highlighter } from '@lobehub/ui';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { memo } from 'react';
|
||||
|
||||
import type { CommandResult, RunCommandParams } from '../../../types';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
container: css`
|
||||
overflow: hidden;
|
||||
padding-inline: 8px 0;
|
||||
`,
|
||||
}));
|
||||
|
||||
const RunCommand = memo<BuiltinRenderProps<RunCommandParams, CommandResult>>(
|
||||
({ args, content, pluginState }) => {
|
||||
return (
|
||||
<Flexbox className={styles.container} gap={8}>
|
||||
<Block gap={8} padding={8} variant={'outlined'}>
|
||||
<Highlighter
|
||||
wrap
|
||||
language={'sh'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 200, overflow: 'auto', paddingInline: 8 }}
|
||||
variant={'borderless'}
|
||||
>
|
||||
{args?.command || ''}
|
||||
</Highlighter>
|
||||
{(pluginState?.output || content) && (
|
||||
<Highlighter
|
||||
wrap
|
||||
language={'text'}
|
||||
showLanguage={false}
|
||||
style={{ maxHeight: 200, overflow: 'auto', paddingInline: 8 }}
|
||||
variant={'filled'}
|
||||
>
|
||||
{pluginState?.output || content}
|
||||
</Highlighter>
|
||||
)}
|
||||
{pluginState?.stderr && (
|
||||
<Highlighter wrap language={'text'} showLanguage={false} variant={'filled'}>
|
||||
{pluginState.stderr}
|
||||
</Highlighter>
|
||||
)}
|
||||
</Block>
|
||||
</Flexbox>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
RunCommand.displayName = 'RunCommand';
|
||||
|
||||
export default RunCommand;
|
||||
@@ -1,10 +1,12 @@
|
||||
import { SkillsApiName } from '../../types';
|
||||
import ExecScript from './ExecScript';
|
||||
import ReadReference from './ReadReference';
|
||||
import RunCommand from './RunCommand';
|
||||
import RunSkill from './RunSkill';
|
||||
|
||||
export const SkillsRenders = {
|
||||
[SkillsApiName.execScript]: ExecScript,
|
||||
[SkillsApiName.readReference]: ReadReference,
|
||||
[SkillsApiName.runCommand]: RunCommand,
|
||||
[SkillsApiName.activateSkill]: RunSkill,
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('ApiKeyModel', () => {
|
||||
expect(result.name).toBe(params.name);
|
||||
expect(result.enabled).toBe(params.enabled);
|
||||
expect(result.key).toBeDefined();
|
||||
expect(result.key).not.toMatch(/^lb-[\da-z]{16}$/);
|
||||
expect(result.key).not.toMatch(/^sk-lh-[\da-z]{16}$/);
|
||||
expect(result.userId).toBe(userId);
|
||||
|
||||
const apiKey = await serverDB.query.apiKeys.findFirst({
|
||||
@@ -145,7 +145,7 @@ describe('ApiKeyModel', () => {
|
||||
|
||||
const keys = await apiKeyModel.query();
|
||||
expect(keys).toHaveLength(2);
|
||||
expect(keys[0].key).toMatch(/^lb-[\da-z]{16}$/);
|
||||
expect(keys[0].key).toMatch(/^sk-lh-[\da-z]{16}$/);
|
||||
});
|
||||
|
||||
it('should query API keys ordered by updatedAt desc', async () => {
|
||||
@@ -186,7 +186,7 @@ describe('ApiKeyModel', () => {
|
||||
describe('findByKey', () => {
|
||||
it('should find API key by key value without custom hasher', async () => {
|
||||
// Use a valid hex format key since validateApiKeyFormat checks for hex pattern
|
||||
const validKey = 'lb-abcdef0123456789';
|
||||
const validKey = 'sk-lh-abcdef0123456789';
|
||||
await serverDB.insert(apiKeys).values({
|
||||
enabled: true,
|
||||
key: validKey,
|
||||
@@ -209,7 +209,7 @@ describe('ApiKeyModel', () => {
|
||||
});
|
||||
|
||||
it('should return undefined for non-existent key', async () => {
|
||||
const found = await apiKeyModel.findByKey('lb-0123456789abcdef');
|
||||
const found = await apiKeyModel.findByKey('sk-lh-0123456789abcdef');
|
||||
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
@@ -221,7 +221,7 @@ describe('ApiKeyModel', () => {
|
||||
futureDate.setFullYear(futureDate.getFullYear() + 1);
|
||||
|
||||
// Use a valid hex format key
|
||||
const validKey = 'lb-0123456789abcdef';
|
||||
const validKey = 'sk-lh-0123456789abcdef';
|
||||
await serverDB.insert(apiKeys).values({
|
||||
enabled: true,
|
||||
expiresAt: futureDate,
|
||||
@@ -238,7 +238,7 @@ describe('ApiKeyModel', () => {
|
||||
|
||||
it('should validate enabled key without expiration with valid hex format', async () => {
|
||||
// Use a valid hex format key
|
||||
const validKey = 'lb-fedcba9876543210';
|
||||
const validKey = 'sk-lh-fedcba9876543210';
|
||||
await serverDB.insert(apiKeys).values({
|
||||
enabled: true,
|
||||
key: validKey,
|
||||
@@ -253,13 +253,13 @@ describe('ApiKeyModel', () => {
|
||||
});
|
||||
|
||||
it('should reject non-existent key', async () => {
|
||||
const isValid = await apiKeyModel.validateKey('lb-0123456789abcdef');
|
||||
const isValid = await apiKeyModel.validateKey('sk-lh-0123456789abcdef');
|
||||
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject disabled key', async () => {
|
||||
const validKey = 'lb-1111111111111111';
|
||||
const validKey = 'sk-lh-1111111111111111';
|
||||
await serverDB.insert(apiKeys).values({
|
||||
enabled: false,
|
||||
key: validKey,
|
||||
@@ -277,7 +277,7 @@ describe('ApiKeyModel', () => {
|
||||
const pastDate = new Date();
|
||||
pastDate.setFullYear(pastDate.getFullYear() - 1);
|
||||
|
||||
const validKey = 'lb-2222222222222222';
|
||||
const validKey = 'sk-lh-2222222222222222';
|
||||
await serverDB.insert(apiKeys).values({
|
||||
enabled: true,
|
||||
expiresAt: pastDate,
|
||||
|
||||
@@ -70,7 +70,7 @@ export const userAuthMiddleware = async (c: Context, next: Next) => {
|
||||
if (bearerToken) {
|
||||
log('Bearer token received: %s...', bearerToken.slice(0, 10));
|
||||
|
||||
// Check if bearerToken matches API Key format (lb-{16 alphanumeric chars})
|
||||
// Check if bearerToken matches API Key format (sk-lh-{16 alphanumeric chars})
|
||||
const isApiKeyFormat = validateApiKeyFormat(bearerToken);
|
||||
log('API Key format validation result: %s', isApiKeyFormat);
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@ describe('apiKey', () => {
|
||||
describe('generateApiKey', () => {
|
||||
it('should generate API key with correct format', () => {
|
||||
const apiKey = generateApiKey();
|
||||
expect(apiKey).toMatch(/^lb-[\da-z]{16}$/);
|
||||
expect(apiKey).toMatch(/^sk-lh-[\da-z]{16}$/);
|
||||
});
|
||||
|
||||
it('should generate API key with correct length', () => {
|
||||
const apiKey = generateApiKey();
|
||||
expect(apiKey).toHaveLength(19); // 'lb-' (3) + 16 characters
|
||||
expect(apiKey).toHaveLength(22); // 'sk-lh-' (6) + 16 characters
|
||||
});
|
||||
|
||||
it('should generate unique API keys', () => {
|
||||
@@ -25,12 +25,12 @@ describe('apiKey', () => {
|
||||
|
||||
it('should start with lb- prefix', () => {
|
||||
const apiKey = generateApiKey();
|
||||
expect(apiKey.startsWith('lb-')).toBe(true);
|
||||
expect(apiKey.startsWith('sk-lh-')).toBe(true);
|
||||
});
|
||||
|
||||
it('should only contain lowercase alphanumeric characters after prefix', () => {
|
||||
const apiKey = generateApiKey();
|
||||
const randomPart = apiKey.slice(3); // Remove 'lb-' prefix
|
||||
const randomPart = apiKey.slice(6); // Remove 'sk-lh-' prefix
|
||||
expect(randomPart).toMatch(/^[\da-z]+$/);
|
||||
});
|
||||
});
|
||||
@@ -67,52 +67,52 @@ describe('apiKey', () => {
|
||||
|
||||
describe('validateApiKeyFormat', () => {
|
||||
it('should validate correct API key format', () => {
|
||||
const validKey = 'lb-1234567890abcdef';
|
||||
const validKey = 'sk-lh-1234567890abcdef';
|
||||
expect(validateApiKeyFormat(validKey)).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept keys with only numbers', () => {
|
||||
const validKey = 'lb-1234567890123456';
|
||||
const validKey = 'sk-lh-1234567890123456';
|
||||
expect(validateApiKeyFormat(validKey)).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept keys with only lowercase letters', () => {
|
||||
const validKey = 'lb-abcdefabcdefabcd';
|
||||
const validKey = 'sk-lh-abcdefabcdefabcd';
|
||||
expect(validateApiKeyFormat(validKey)).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept keys with mixed alphanumeric characters', () => {
|
||||
const validKey = 'lb-abc123def456789a';
|
||||
const validKey = 'sk-lh-abc123def456789a';
|
||||
expect(validateApiKeyFormat(validKey)).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject keys without lb- prefix', () => {
|
||||
it('should reject keys without sk-lh- prefix', () => {
|
||||
const invalidKey = '1234567890abcdef';
|
||||
expect(validateApiKeyFormat(invalidKey)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject keys with wrong prefix', () => {
|
||||
const invalidKey = 'lc-1234567890abcdef';
|
||||
const invalidKey = 'lb-1234567890abcdef';
|
||||
expect(validateApiKeyFormat(invalidKey)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject keys that are too short', () => {
|
||||
const invalidKey = 'lb-123456789abcde';
|
||||
const invalidKey = 'sk-lh-123456789abcde';
|
||||
expect(validateApiKeyFormat(invalidKey)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject keys that are too long', () => {
|
||||
const invalidKey = 'lb-1234567890abcdef0';
|
||||
const invalidKey = 'sk-lh-1234567890abcdef0';
|
||||
expect(validateApiKeyFormat(invalidKey)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject keys with uppercase letters', () => {
|
||||
const invalidKey = 'lb-1234567890ABCDEF';
|
||||
const invalidKey = 'sk-lh-1234567890ABCDEF';
|
||||
expect(validateApiKeyFormat(invalidKey)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject keys with special characters', () => {
|
||||
const invalidKey = 'lb-1234567890abcd-f';
|
||||
const invalidKey = 'sk-lh-1234567890abcd-f';
|
||||
expect(validateApiKeyFormat(invalidKey)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -121,19 +121,13 @@ describe('apiKey', () => {
|
||||
});
|
||||
|
||||
it('should reject keys with spaces', () => {
|
||||
const invalidKey = 'lb-1234567890abcd f';
|
||||
const invalidKey = 'sk-lh-1234567890abcd f';
|
||||
expect(validateApiKeyFormat(invalidKey)).toBe(false);
|
||||
});
|
||||
|
||||
it('should validate generated keys', () => {
|
||||
// Generate a key and validate it
|
||||
const generatedKey = generateApiKey();
|
||||
// Note: The validation pattern expects hex digits (0-9a-f), but generated keys use base36 (0-9a-z)
|
||||
// This test will fail if there are non-hex characters (g-z) in the generated key
|
||||
const hasOnlyHexChars = /^lb-[\da-f]{16}$/.test(generatedKey);
|
||||
if (hasOnlyHexChars) {
|
||||
expect(validateApiKeyFormat(generatedKey)).toBe(true);
|
||||
}
|
||||
expect(validateApiKeyFormat(generatedKey)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ let apiKeyCounter = 0;
|
||||
|
||||
/**
|
||||
* Generate API Key
|
||||
* Format: lb-{random}
|
||||
* Format: sk-lh-{random}
|
||||
* @returns Generated API Key
|
||||
*/
|
||||
export function generateApiKey(): string {
|
||||
@@ -35,7 +35,7 @@ export function generateApiKey(): string {
|
||||
randomPart = randomPart.slice(0, 16);
|
||||
|
||||
// Combine to form the final API Key
|
||||
return `lb-${randomPart}`;
|
||||
return `sk-lh-${randomPart}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,7 +54,7 @@ export function isApiKeyExpired(expiresAt: Date | null): boolean {
|
||||
* @returns Whether the key has a valid format
|
||||
*/
|
||||
export function validateApiKeyFormat(key: string): boolean {
|
||||
// Check format: lb-{random}
|
||||
const pattern = /^lb-[\da-z]{16}$/;
|
||||
// Check format: sk-lh-{random}
|
||||
const pattern = /^sk-lh-[\da-z]{16}$/;
|
||||
return pattern.test(key);
|
||||
}
|
||||
|
||||
@@ -199,6 +199,7 @@ export default {
|
||||
'builtins.lobe-skills.apiName.importFromMarket': 'Import from Market',
|
||||
'builtins.lobe-skills.apiName.importSkill': 'Import Skill',
|
||||
'builtins.lobe-skills.apiName.readReference': 'Read Reference',
|
||||
'builtins.lobe-skills.apiName.runCommand': 'Run Command',
|
||||
'builtins.lobe-skills.apiName.activateSkill': 'Activate Skill',
|
||||
'builtins.lobe-skills.apiName.searchSkill': 'Search Skills',
|
||||
'builtins.lobe-skills.title': 'Skills',
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from '@/store/serverConfig';
|
||||
import { useUserStore } from '@/store/user';
|
||||
import { userProfileSelectors } from '@/store/user/slices/auth/selectors';
|
||||
import { userGeneralSettingsSelectors } from '@/store/user/slices/settings/selectors';
|
||||
|
||||
export enum SettingsGroupKey {
|
||||
Account = 'account',
|
||||
@@ -71,6 +72,7 @@ export const useCategory = () => {
|
||||
userProfileSelectors.nickName(s),
|
||||
]);
|
||||
const remoteServerUrl = useElectronStore(electronSyncSelectors.remoteServerUrl);
|
||||
const isDevMode = useUserStore((s) => userGeneralSettingsSelectors.config(s).isDevMode);
|
||||
|
||||
// Process avatar URL for desktop environment
|
||||
const avatarUrl = useMemo(() => {
|
||||
@@ -96,11 +98,6 @@ export const useCategory = () => {
|
||||
key: SettingsTabs.Stats,
|
||||
label: tAuth('tab.stats'),
|
||||
},
|
||||
showApiKeyManage && {
|
||||
icon: KeyIcon,
|
||||
key: SettingsTabs.APIKey,
|
||||
label: tAuth('tab.apikey'),
|
||||
},
|
||||
].filter(Boolean) as CategoryItem[];
|
||||
|
||||
groups.push({
|
||||
@@ -232,6 +229,11 @@ export const useCategory = () => {
|
||||
key: SettingsTabs.Storage,
|
||||
label: t('tab.storage'),
|
||||
},
|
||||
isDevMode && {
|
||||
icon: KeyIcon,
|
||||
key: SettingsTabs.APIKey,
|
||||
label: tAuth('tab.apikey'),
|
||||
},
|
||||
{
|
||||
icon: EllipsisIcon,
|
||||
key: SettingsTabs.Advanced,
|
||||
@@ -260,6 +262,7 @@ export const useCategory = () => {
|
||||
mobile,
|
||||
showAiImage,
|
||||
showApiKeyManage,
|
||||
isDevMode,
|
||||
avatarUrl,
|
||||
username,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { preprocessLhCommand } from '../preprocessLhCommand';
|
||||
|
||||
const mockSignUserJWT = vi.hoisted(() => vi.fn().mockResolvedValue('mock-jwt-token'));
|
||||
|
||||
vi.mock('@/libs/trpc/utils/internalJwt', () => ({
|
||||
signUserJWT: mockSignUserJWT,
|
||||
}));
|
||||
|
||||
vi.mock('@/envs/app', () => ({
|
||||
appEnv: { APP_URL: 'https://app.lobehub.com' },
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/env', () => ({
|
||||
isDev: false,
|
||||
}));
|
||||
|
||||
const ENV_PREFIX = 'LOBEHUB_JWT=mock-jwt-token LOBEHUB_SERVER=https://app.lobehub.com';
|
||||
|
||||
describe('preprocessLhCommand', () => {
|
||||
it('should return unchanged command for non-lh commands', async () => {
|
||||
const result = await preprocessLhCommand('echo hello', 'user-1');
|
||||
|
||||
expect(result.isLhCommand).toBe(false);
|
||||
expect(result.skipSkillLookup).toBe(false);
|
||||
expect(result.command).toBe('echo hello');
|
||||
});
|
||||
|
||||
it('should rewrite a single lh command', async () => {
|
||||
const result = await preprocessLhCommand('lh topic list --json', 'user-1');
|
||||
|
||||
expect(result.isLhCommand).toBe(true);
|
||||
expect(result.skipSkillLookup).toBe(true);
|
||||
expect(result.command).toBe(`${ENV_PREFIX} npx -y @lobehub/cli topic list --json`);
|
||||
});
|
||||
|
||||
it('should rewrite all lh commands chained with &&', async () => {
|
||||
const cmd = 'lh topic list --page 1 && lh topic list --page 2 && echo "done"';
|
||||
const result = await preprocessLhCommand(cmd, 'user-1');
|
||||
|
||||
expect(result.command).toBe(
|
||||
`${ENV_PREFIX} npx -y @lobehub/cli topic list --page 1 && ${ENV_PREFIX} npx -y @lobehub/cli topic list --page 2 && echo "done"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should rewrite lh commands chained with ||', async () => {
|
||||
const cmd = 'lh foo || lh bar';
|
||||
const result = await preprocessLhCommand(cmd, 'user-1');
|
||||
|
||||
expect(result.command).toBe(
|
||||
`${ENV_PREFIX} npx -y @lobehub/cli foo || ${ENV_PREFIX} npx -y @lobehub/cli bar`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should rewrite lh commands chained with ;', async () => {
|
||||
const cmd = 'lh foo; lh bar';
|
||||
const result = await preprocessLhCommand(cmd, 'user-1');
|
||||
|
||||
expect(result.command).toBe(
|
||||
`${ENV_PREFIX} npx -y @lobehub/cli foo; ${ENV_PREFIX} npx -y @lobehub/cli bar`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not replace lh inside other words', async () => {
|
||||
const result = await preprocessLhCommand('echoalhough', 'user-1');
|
||||
|
||||
expect(result.isLhCommand).toBe(false);
|
||||
expect(result.command).toBe('echoalhough');
|
||||
});
|
||||
|
||||
it('should handle bare lh command', async () => {
|
||||
const result = await preprocessLhCommand('lh', 'user-1');
|
||||
|
||||
expect(result.isLhCommand).toBe(true);
|
||||
expect(result.command).toBe(`${ENV_PREFIX} npx -y @lobehub/cli`);
|
||||
});
|
||||
|
||||
it('should return error when JWT signing fails', async () => {
|
||||
mockSignUserJWT.mockRejectedValueOnce(new Error('sign failed'));
|
||||
|
||||
const result = await preprocessLhCommand('lh topic list', 'user-1');
|
||||
|
||||
expect(result.isLhCommand).toBe(true);
|
||||
expect(result.error).toBe('Failed to authenticate for CLI execution');
|
||||
expect(result.command).toBe('lh topic list');
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,9 @@ export const preprocessLhCommand = async (
|
||||
command: string,
|
||||
userId: string,
|
||||
): Promise<PreprocessResult> => {
|
||||
const isLhCommand = /^lh\s/.test(command) || command === 'lh';
|
||||
// Match `lh` at the start of the command or after shell operators (&&, ||, ;)
|
||||
const lhPattern = /(?:^|&&|\|\||;)\s*lh(?:\s|$)/;
|
||||
const isLhCommand = lhPattern.test(command);
|
||||
|
||||
if (!isLhCommand) {
|
||||
return { command, isLhCommand: false, skipSkillLookup: false };
|
||||
@@ -34,8 +36,14 @@ export const preprocessLhCommand = async (
|
||||
|
||||
const serverUrl = isDev ? 'https://app.lobehub.com' : appEnv.APP_URL;
|
||||
|
||||
const rewritten = command.replace(/^lh/, 'npx -y @lobehub/cli');
|
||||
const finalCommand = `LOBEHUB_JWT=${jwt} LOBEHUB_SERVER=${serverUrl} ${rewritten}`;
|
||||
const envPrefix = `LOBEHUB_JWT=${jwt} LOBEHUB_SERVER=${serverUrl}`;
|
||||
|
||||
// Replace `lh` in all sub-commands separated by &&, ||, or ;
|
||||
const rewritten = command.replaceAll(
|
||||
/(^|&&|\|\||;)(\s*)lh(\s|$)/g,
|
||||
`$1$2${envPrefix} npx -y @lobehub/cli$3`,
|
||||
);
|
||||
const finalCommand = rewritten;
|
||||
|
||||
log(
|
||||
'Intercepted lh command for user %s, rewritten to: %s',
|
||||
|
||||
Reference in New Issue
Block a user