From a0ec618f4dba78b40a7c8acf77433312611cb4fb Mon Sep 17 00:00:00 2001 From: musistudio Date: Sat, 27 Dec 2025 21:51:32 +0800 Subject: [PATCH] add presets --- packages/cli/package.json | 5 +- packages/cli/src/cli.ts | 83 ++- packages/cli/src/utils/index.ts | 27 +- packages/cli/src/utils/preset/commands.ts | 256 +++++++ packages/cli/src/utils/preset/export.ts | 104 +++ packages/cli/src/utils/preset/index.ts | 12 + packages/cli/src/utils/preset/install.ts | 301 ++++++++ packages/server/package.json | 3 + packages/server/src/index.ts | 6 +- packages/server/src/server.ts | 297 +++++++- packages/shared/package.json | 7 + packages/shared/src/index.ts | 8 + packages/shared/src/preset/export.ts | 134 ++++ packages/shared/src/preset/install.ts | 239 ++++++ packages/shared/src/preset/merge.ts | 280 +++++++ packages/shared/src/preset/readPreset.ts | 30 + packages/shared/src/preset/sensitiveFields.ts | 249 +++++++ packages/shared/src/preset/types.ts | 138 ++++ packages/ui/package.json | 1 + packages/ui/src/App.tsx | 101 ++- packages/ui/src/components/Presets.tsx | 689 ++++++++++++++++++ packages/ui/src/components/ui/tooltip.tsx | 31 + packages/ui/src/lib/api.ts | 67 ++ packages/ui/src/locales/en.json | 52 +- packages/ui/src/locales/zh.json | 52 +- packages/ui/src/main.tsx | 1 + packages/ui/src/routes.tsx | 5 + pnpm-lock.yaml | 348 +++++++++ 28 files changed, 3423 insertions(+), 103 deletions(-) create mode 100644 packages/cli/src/utils/preset/commands.ts create mode 100644 packages/cli/src/utils/preset/export.ts create mode 100644 packages/cli/src/utils/preset/index.ts create mode 100644 packages/cli/src/utils/preset/install.ts create mode 100644 packages/shared/src/preset/export.ts create mode 100644 packages/shared/src/preset/install.ts create mode 100644 packages/shared/src/preset/merge.ts create mode 100644 packages/shared/src/preset/readPreset.ts create mode 100644 packages/shared/src/preset/sensitiveFields.ts create mode 100644 packages/shared/src/preset/types.ts create mode 100644 packages/ui/src/components/Presets.tsx create mode 100644 packages/ui/src/components/ui/tooltip.tsx diff --git a/packages/cli/package.json b/packages/cli/package.json index 99dcc4e..a905fee 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -18,14 +18,17 @@ "author": "musistudio", "license": "MIT", "dependencies": { + "@CCR/server": "workspace:*", "@CCR/shared": "workspace:*", "@inquirer/prompts": "^5.0.0", - "@CCR/server": "workspace:*", + "adm-zip": "^0.5.16", + "archiver": "^7.0.1", "find-process": "^2.0.0", "minimist": "^1.2.8", "openurl": "^1.1.1" }, "devDependencies": { + "@types/archiver": "^7.0.0", "@types/node": "^24.0.15", "esbuild": "^0.25.1", "ts-node": "^10.9.2", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 43aa644..c49d1be 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -28,6 +28,7 @@ const KNOWN_COMMANDS = [ "statusline", "code", "model", + "preset", "activate", "env", "ui", @@ -48,6 +49,7 @@ Commands: statusline Integrated statusline code Execute claude command model Interactive model selection and configuration + preset Manage presets (export, install, list, delete) activate Output environment variables for shell integration ui Open the web UI in browser -v, version Show version information @@ -61,6 +63,9 @@ Examples: ccr code "Write a Hello World" ccr my-preset "Write a Hello World" # Use preset configuration ccr model + ccr preset export my-config # Export current config as preset + ccr preset install my-config.ccrsets # Install a preset + ccr preset list # List all presets eval "$(ccr activate)" # Set environment variables globally ccr ui `; @@ -91,52 +96,62 @@ async function main() { // 如果命令不是已知命令,检查是否是 preset if (command && !KNOWN_COMMANDS.includes(command)) { const { readPresetFile } = await import("./utils"); - const presetConfig: PresetConfig | null = await readPresetFile(command); + const presetData: any = await readPresetFile(command); - if (presetConfig) { + if (presetData) { // 这是一个 preset,执行 code 命令 const codeArgs = process.argv.slice(3); // 获取剩余参数 // 检查 noServer 配置 - const shouldStartServer = presetConfig.noServer !== true; + const shouldStartServer = presetData.noServer !== true; - // 处理 provider 配置,构建环境变量覆盖 + // 构建环境变量覆盖 let envOverrides: Record | undefined; - if (presetConfig.provider) { + + // 处理 provider 配置(支持新旧两种格式) + let provider: any = null; + + // 旧格式:presetData.provider 是 provider 名称 + if (presetData.provider && typeof presetData.provider === 'string') { const config = await readConfigFile(); - const providerName = presetConfig.provider; - const provider = config.Providers?.find((p: any) => p.name === providerName); + provider = config.Providers?.find((p: any) => p.name === presetData.provider); + } + // 新格式:presetData.Providers 是 provider 数组 + else if (presetData.Providers && presetData.Providers.length > 0) { + provider = presetData.Providers[0]; + } - if (provider) { - // 处理 api_base_url,去掉 /v1/messages 后缀 - if (provider.api_base_url) { - let baseUrl = provider.api_base_url; - if (baseUrl.endsWith('/v1/messages')) { - baseUrl = baseUrl.slice(0, -'/v1/messages'.length); - } else if (baseUrl.endsWith('/')) { - baseUrl = baseUrl.slice(0, -1); - } - envOverrides = { - ...envOverrides, - ANTHROPIC_BASE_URL: baseUrl, - }; + if (provider) { + // 处理 api_base_url,去掉 /v1/messages 后缀 + if (provider.api_base_url) { + let baseUrl = provider.api_base_url; + if (baseUrl.endsWith('/v1/messages')) { + baseUrl = baseUrl.slice(0, -'/v1/messages'.length); + } else if (baseUrl.endsWith('/')) { + baseUrl = baseUrl.slice(0, -1); } + envOverrides = { + ...envOverrides, + ANTHROPIC_BASE_URL: baseUrl, + }; + } - // 处理 api_key - if (provider.api_key) { - envOverrides = { - ...envOverrides, - ANTHROPIC_AUTH_TOKEN: provider.api_key, - }; - } - } else { - console.error(`Provider "${providerName}" not found in config`); - process.exit(1); + // 处理 api_key + if (provider.api_key) { + envOverrides = { + ...envOverrides, + ANTHROPIC_AUTH_TOKEN: provider.api_key, + }; } } - // TODO: 处理 router 配置 - // 如果 preset 中有 router 配置且需要启动 server,可能需要临时修改配置文件 + // 构建 PresetConfig + const presetConfig: PresetConfig = { + noServer: presetData.noServer, + claudeCodeSettings: presetData.claudeCodeSettings, + provider: presetData.provider, + router: presetData.router, + }; if (shouldStartServer && !isRunning) { console.log("Service not running, starting service..."); @@ -232,6 +247,10 @@ async function main() { case "model": await runModelSelector(); break; + case "preset": + const { handlePresetCommand } = await import("./utils/preset"); + await handlePresetCommand(process.argv.slice(3)); + break; case "activate": case "env": await activateCommand(); diff --git a/packages/cli/src/utils/index.ts b/packages/cli/src/utils/index.ts index f5cf414..ec88850 100644 --- a/packages/cli/src/utils/index.ts +++ b/packages/cli/src/utils/index.ts @@ -9,6 +9,7 @@ import { PLUGINS_DIR, PRESETS_DIR, REFERENCE_COUNT_FILE, + readPresetFile, } from "@CCR/shared"; import { getServer } from "@CCR/server"; import { writeFileSync, existsSync, readFileSync } from "fs"; @@ -191,15 +192,15 @@ export const run = async (args: string[] = []) => { // Save the PID of the background process writeFileSync(PID_FILE, process.pid.toString()); - server.app.post('/api/update/perform', async () => { + app.post('/api/update/perform', async () => { return await performUpdate(); }) - server.app.get('/api/update/check', async () => { + app.get('/api/update/check', async () => { return await checkForUpdates(version); }) - server.app.post("/api/restart", async () => { + app.post("/api/restart", async () => { setTimeout(async () => { spawn("ccr", ["restart"], { detached: true, @@ -274,23 +275,3 @@ export const getSettingsPath = (content: string): string => { return tempFilePath; } - -/** - * 读取 preset 配置文件 - * @param name preset 名称 - * @returns preset 配置对象,如果文件不存在则返回 null - */ -export const readPresetFile = async (name: string): Promise => { - try { - const presetFile = path.join(PRESETS_DIR, `${name}.ccrsets`); - const content = await fs.readFile(presetFile, 'utf-8'); - return JSON5.parse(content); - } catch (error: any) { - if (error.code === 'ENOENT') { - console.error(`Preset file not found: ${name}.ccrsets`); - } else { - console.error(`Failed to read preset file: ${error.message}`); - } - return null; - } -} diff --git a/packages/cli/src/utils/preset/commands.ts b/packages/cli/src/utils/preset/commands.ts new file mode 100644 index 0000000..a4be066 --- /dev/null +++ b/packages/cli/src/utils/preset/commands.ts @@ -0,0 +1,256 @@ +/** + * 预设命令处理器 CLI 层 + * 负责处理 CLI 交互,核心逻辑在 shared 包中 + */ + +import * as fs from 'fs/promises'; +import * as fsSync from 'fs'; +import * as path from 'path'; +import JSON5 from 'json5'; +import { exportPresetCli } from './export'; +import { installPresetCli, loadPreset } from './install'; +import { MergeStrategy, HOME_DIR } from '@CCR/shared'; + +// ANSI 颜色代码 +const RESET = "\x1B[0m"; +const GREEN = "\x1B[32m"; +const YELLOW = "\x1B[33m"; +const BOLDCYAN = "\x1B[1m\x1B[36m"; +const BOLDYELLOW = "\x1B[1m\x1B[33m"; +const DIM = "\x1B[2m"; + +/** + * 列出本地预设 + */ +async function listPresets(): Promise { + const presetsDir = path.join(HOME_DIR, 'presets'); + + try { + await fs.access(presetsDir); + } catch { + console.log('\nNo presets directory found.'); + console.log(`\nCreate your first preset with: ${GREEN}ccr preset export ${RESET}\n`); + return; + } + + const entries = await fs.readdir(presetsDir, { withFileTypes: true }); + const presetDirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => e.name); + + if (presetDirs.length === 0) { + console.log('\nNo presets found.'); + console.log(`\nInstall a preset with: ${GREEN}ccr preset install ${RESET}\n`); + return; + } + + console.log(`\n${BOLDCYAN}Available presets:${RESET}\n`); + + for (const dirName of presetDirs) { + const presetDir = path.join(presetsDir, dirName); + try { + const manifestPath = path.join(presetDir, 'manifest.json'); + const content = await fs.readFile(manifestPath, 'utf-8'); + const manifest = JSON5.parse(content); + + // 从manifest中提取metadata字段 + const { Providers, Router, PORT, HOST, API_TIMEOUT_MS, PROXY_URL, LOG, LOG_LEVEL, StatusLine, NON_INTERACTIVE_MODE, requiredInputs, ...metadata } = manifest; + + const name = metadata.name || dirName; + const description = metadata.description || ''; + const author = metadata.author || ''; + const version = metadata.version; + + // 显示预设名称 + if (version) { + console.log(`${GREEN}•${RESET} ${BOLDCYAN}${name}${RESET} (v${version})`); + } else { + console.log(`${GREEN}•${RESET} ${BOLDCYAN}${name}${RESET}`); + } + + // 显示描述 + if (description) { + console.log(` ${description}`); + } + + // 显示作者 + if (author) { + console.log(` ${DIM}by ${author}${RESET}`); + } + + console.log(''); + } catch (error) { + console.log(`${YELLOW}•${RESET} ${dirName}`); + console.log(` ${DIM}(Error reading preset)${RESET}\n`); + } + } +} + +/** + * 删除预设 + */ +async function deletePreset(name: string): Promise { + const presetsDir = path.join(HOME_DIR, 'presets'); + const presetDir = path.join(presetsDir, name); + + try { + // 递归删除整个目录 + await fs.rm(presetDir, { recursive: true, force: true }); + console.log(`\n${GREEN}✓${RESET} Preset "${name}" deleted.\n`); + } catch (error: any) { + if (error.code === 'ENOENT') { + console.error(`\n${YELLOW}Error:${RESET} Preset "${name}" not found.\n`); + } else { + console.error(`\n${YELLOW}Error:${RESET} ${error.message}\n`); + } + process.exit(1); + } +} + +/** + * 显示预设信息 + */ +async function showPresetInfo(name: string): Promise { + try { + const preset = await loadPreset(name); + + const config = preset.config; + const metadata = preset.metadata || {}; + + console.log(`\n${BOLDCYAN}═══════════════════════════════════════════════${RESET}`); + if (metadata.name) { + console.log(`${BOLDCYAN}Preset: ${RESET}${metadata.name}`); + } else { + console.log(`${BOLDCYAN}Preset: ${RESET}${name}`); + } + console.log(`${BOLDCYAN}═══════════════════════════════════════════════${RESET}\n`); + + if (metadata.version) console.log(`${BOLDCYAN}Version:${RESET} ${metadata.version}`); + if (metadata.description) console.log(`${BOLDCYAN}Description:${RESET} ${metadata.description}`); + if (metadata.author) console.log(`${BOLDCYAN}Author:${RESET} ${metadata.author}`); + const keywords = (metadata as any).keywords; + if (keywords && keywords.length > 0) { + console.log(`${BOLDCYAN}Keywords:${RESET} ${keywords.join(', ')}`); + } + + console.log(`\n${BOLDCYAN}Configuration:${RESET}`); + if (config.Providers) { + console.log(` Providers: ${config.Providers.length}`); + } + if (config.Router) { + console.log(` Router rules: ${Object.keys(config.Router).length}`); + } + if (config.provider) { + console.log(` Provider: ${config.provider}`); + } + + if (preset.requiredInputs && preset.requiredInputs.length > 0) { + console.log(`\n${BOLDYELLOW}Required inputs:${RESET}`); + for (const input of preset.requiredInputs) { + const envVar = input.placeholder || input.field; + console.log(` - ${input.field} ${DIM}(${envVar})${RESET}`); + } + } + + console.log(''); + } catch (error: any) { + console.error(`\n${YELLOW}Error:${RESET} ${error.message}\n`); + process.exit(1); + } +} + +/** + * 处理预设命令 + */ +export async function handlePresetCommand(args: string[]): Promise { + const subCommand = args[0]; + + switch (subCommand) { + case 'export': + const presetName = args[1]; + if (!presetName) { + console.error('\nError: Preset name is required\n'); + console.error('Usage: ccr preset export [--output ] [--description ] [--author ] [--tags ]\n'); + process.exit(1); + } + + // 解析选项 + const options: any = {}; + for (let i = 2; i < args.length; i++) { + if (args[i] === '--output' && args[i + 1]) { + options.output = args[++i]; + } else if (args[i] === '--description' && args[i + 1]) { + options.description = args[++i]; + } else if (args[i] === '--author' && args[i + 1]) { + options.author = args[++i]; + } else if (args[i] === '--tags' && args[i + 1]) { + options.tags = args[++i]; + } else if (args[i] === '--include-sensitive') { + options.includeSensitive = true; + } + } + + await exportPresetCli(presetName, options); + break; + + case 'install': + const source = args[1]; + if (!source) { + console.error('\nError: Preset source is required\n'); + console.error('Usage: ccr preset install \n'); + process.exit(1); + } + + // 解析选项 + const installOptions: any = {}; + for (let i = 2; i < args.length; i++) { + if (args[i] === '--strategy' && args[i + 1]) { + const strategy = args[++i]; + if (['ask', 'overwrite', 'merge', 'skip'].includes(strategy)) { + installOptions.strategy = strategy as MergeStrategy; + } else { + console.error(`\nError: Invalid merge strategy "${strategy}"\n`); + console.error('Valid strategies: ask, overwrite, merge, skip\n'); + process.exit(1); + } + } + } + + await installPresetCli(source, installOptions); + break; + + case 'list': + await listPresets(); + break; + + case 'delete': + case 'rm': + case 'remove': + const deleteName = args[1]; + if (!deleteName) { + console.error('\nError: Preset name is required\n'); + console.error('Usage: ccr preset delete \n'); + process.exit(1); + } + await deletePreset(deleteName); + break; + + case 'info': + const infoName = args[1]; + if (!infoName) { + console.error('\nError: Preset name is required\n'); + console.error('Usage: ccr preset info \n'); + process.exit(1); + } + await showPresetInfo(infoName); + break; + + default: + console.error(`\nError: Unknown preset command "${subCommand}"\n`); + console.error('Available commands:'); + console.error(' ccr preset export Export current configuration as a preset'); + console.error(' ccr preset install Install a preset from file, URL, or registry'); + console.error(' ccr preset list List installed presets'); + console.error(' ccr preset info Show preset information'); + console.error(' ccr preset delete Delete a preset\n'); + process.exit(1); + } +} diff --git a/packages/cli/src/utils/preset/export.ts b/packages/cli/src/utils/preset/export.ts new file mode 100644 index 0000000..82e7441 --- /dev/null +++ b/packages/cli/src/utils/preset/export.ts @@ -0,0 +1,104 @@ +/** + * 预设导出功能 CLI 层 + * 负责处理 CLI 交互,核心逻辑在 shared 包中 + */ + +import { input } from '@inquirer/prompts'; +import { readConfigFile } from '../index'; +import { exportPreset as exportPresetCore, ExportOptions } from '@CCR/shared'; + +// ANSI 颜色代码 +const RESET = "\x1B[0m"; +const GREEN = "\x1B[32m"; +const BOLDGREEN = "\x1B[1m\x1B[32m"; +const YELLOW = "\x1B[33m"; +const BOLDCYAN = "\x1B[1m\x1B[36m"; + +/** + * 导出预设配置(CLI 版本,带交互) + * @param presetName 预设名称 + * @param options 导出选项 + */ +export async function exportPresetCli( + presetName: string, + options: ExportOptions = {} +): Promise { + try { + console.log(`\n${BOLDCYAN}═══════════════════════════════════════════════${RESET}`); + console.log(`${BOLDCYAN} Preset Export${RESET}`); + console.log(`${BOLDCYAN}═══════════════════════════════════════════════${RESET}\n`); + + // 1. 读取当前配置 + const config = await readConfigFile(); + + // 2. 如果没有通过命令行提供,交互式询问元数据 + if (!options.description) { + try { + options.description = await input({ + message: 'Description (optional):', + default: '', + }); + } catch { + // 用户取消,使用默认值 + options.description = ''; + } + } + + if (!options.author) { + try { + options.author = await input({ + message: 'Author (optional):', + default: '', + }); + } catch { + options.author = ''; + } + } + + if (!options.tags) { + try { + const keywordsInput = await input({ + message: 'Keywords (comma-separated, optional):', + default: '', + }); + options.tags = keywordsInput || ''; + } catch { + options.tags = ''; + } + } + + // 3. 调用核心导出功能 + const result = await exportPresetCore(presetName, config, options); + + // 4. 显示摘要 + console.log(`\n${BOLDGREEN}✓ Preset exported successfully${RESET}\n`); + console.log(`${BOLDCYAN}Location:${RESET} ${result.outputPath}\n`); + console.log(`${BOLDCYAN}Summary:${RESET}`); + console.log(` - Providers: ${result.sanitizedConfig.Providers?.length || 0}`); + console.log(` - Router rules: ${Object.keys(result.sanitizedConfig.Router || {}).length}`); + if (!options.includeSensitive) { + console.log(` - Sensitive fields sanitized: ${YELLOW}${result.sanitizedCount}${RESET}`); + } + + // 显示元数据 + if (result.metadata.description) { + console.log(`\n${BOLDCYAN}Description:${RESET} ${result.metadata.description}`); + } + if (result.metadata.author) { + console.log(`${BOLDCYAN}Author:${RESET} ${result.metadata.author}`); + } + if (result.metadata.keywords && result.metadata.keywords.length > 0) { + console.log(`${BOLDCYAN}Keywords:${RESET} ${result.metadata.keywords.join(', ')}`); + } + + // 显示分享提示 + console.log(`\n${BOLDCYAN}To share this preset:${RESET}`); + console.log(` 1. Share the file: ${result.outputPath}`); + console.log(` 2. Upload to GitHub Gist or your repository`); + console.log(` 3. Others can install with: ${GREEN}ccr preset install ${RESET}\n`); + + } catch (error: any) { + console.error(`\n${YELLOW}Error exporting preset:${RESET} ${error.message}`); + throw error; + } +} diff --git a/packages/cli/src/utils/preset/index.ts b/packages/cli/src/utils/preset/index.ts new file mode 100644 index 0000000..008f728 --- /dev/null +++ b/packages/cli/src/utils/preset/index.ts @@ -0,0 +1,12 @@ +/** + * 预设功能 CLI 层 + * 导出所有预设相关的功能和类型 + */ + +// 从 shared 包重新导出类型和核心功能 +export * from '@CCR/shared'; + +// 导出 CLI 特定的功能(带交互) +export { exportPresetCli } from './export'; +export { installPresetCli, applyPresetCli } from './install'; +export { handlePresetCommand } from './commands'; diff --git a/packages/cli/src/utils/preset/install.ts b/packages/cli/src/utils/preset/install.ts new file mode 100644 index 0000000..35934f0 --- /dev/null +++ b/packages/cli/src/utils/preset/install.ts @@ -0,0 +1,301 @@ +/** + * 预设安装功能 CLI 层 + * 负责处理 CLI 交互,核心逻辑在 shared 包中 + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { password, confirm } from '@inquirer/prompts'; +import { + loadPreset as loadPresetShared, + validatePreset, + MergeStrategy, + getPresetDir, + readManifestFromDir, + manifestToPresetFile, + saveManifest, + extractPreset, + findPresetFile, + isPresetInstalled, + ManifestFile, + PresetFile +} from '@CCR/shared'; + +// 重新导出 loadPreset +export { loadPresetShared as loadPreset }; + +// ANSI 颜色代码 +const RESET = "\x1B[0m"; +const GREEN = "\x1B[32m"; +const BOLDGREEN = "\x1B[1m\x1B[32m"; +const YELLOW = "\x1B[33m"; +const BOLDYELLOW = "\x1B[1m\x1B[33m"; +const BOLDCYAN = "\x1B[1m\x1B[36m"; +const DIM = "\x1B[2m"; + +/** + * 收集缺失的敏感信息 + */ +async function collectSensitiveInputs( + preset: PresetFile +): Promise> { + const inputs: Record = {}; + + if (!preset.requiredInputs || preset.requiredInputs.length === 0) { + return inputs; + } + + console.log(`\n${BOLDYELLOW}This preset requires additional information:${RESET}\n`); + + for (const inputField of preset.requiredInputs) { + let value: string; + + // 尝试从环境变量获取 + const envVarName = inputField.placeholder; + if (envVarName && process.env[envVarName]) { + const useEnv = await confirm({ + message: `Found ${envVarName} in environment. Use it?`, + default: true, + }); + + if (useEnv) { + value = process.env[envVarName]!; + inputs[inputField.field] = value; + console.log(`${GREEN}✓${RESET} Using ${envVarName} from environment\n`); + continue; + } + } + + // 提示用户输入 + value = await password({ + message: inputField.prompt || `Enter ${inputField.field}:`, + mask: '*', + }); + + if (!value || value.trim() === '') { + console.error(`${YELLOW}Error:${RESET} ${inputField.field} is required`); + process.exit(1); + } + + // 验证输入 + if (inputField.validator) { + const regex = typeof inputField.validator === 'string' + ? new RegExp(inputField.validator) + : inputField.validator; + + if (!regex.test(value)) { + console.error(`${YELLOW}Error:${RESET} Invalid format for ${inputField.field}`); + console.error(` Expected: ${inputField.validator}`); + process.exit(1); + } + } + + inputs[inputField.field] = value; + console.log(''); + } + + return inputs; +} + +/** + * 应用预设到配置 + * @param presetName 预设名称 + * @param preset 预设对象 + */ +export async function applyPresetCli( + presetName: string, + preset: PresetFile +): Promise { + try { + console.log(`${BOLDCYAN}Loading preset...${RESET} ${GREEN}✓${RESET}`); + + // 验证预设 + const validation = await validatePreset(preset); + if (validation.warnings.length > 0) { + console.log(`\n${YELLOW}Warnings:${RESET}`); + for (const warning of validation.warnings) { + console.log(` ${DIM}⚠${RESET} ${warning}`); + } + } + + if (!validation.valid) { + console.log(`\n${YELLOW}Validation errors:${RESET}`); + for (const error of validation.errors) { + console.log(` ${YELLOW}✗${RESET} ${error}`); + } + throw new Error('Invalid preset file'); + } + + console.log(`${BOLDCYAN}Validating preset...${RESET} ${GREEN}✓${RESET}`); + + // 检查是否已经配置过(通过检查manifest中是否已有敏感信息) + const presetDir = getPresetDir(presetName); + + try { + const existingManifest = await readManifestFromDir(presetDir); + // 检查是否已经配置了敏感信息(例如api_key) + const hasSecrets = existingManifest.Providers?.some((p: any) => p.api_key && p.api_key !== ''); + if (hasSecrets) { + console.log(`\n${GREEN}✓${RESET} Preset already configured with secrets`); + console.log(`${DIM}You can use this preset with: ccr ${presetName}${RESET}\n`); + return; + } + } catch { + // manifest不存在,继续配置流程 + } + + // 收集敏感信息 + const sensitiveInputs = await collectSensitiveInputs(preset); + + // 读取现有的manifest并更新 + const manifest: ManifestFile = { + ...(preset.metadata || {}), + ...preset.config, + }; + + // 将secrets信息应用到manifest中 + for (const [fieldPath, value] of Object.entries(sensitiveInputs)) { + const keys = fieldPath.split(/[.\[\]]+/).filter(k => k !== ''); + let current = manifest as any; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (!current[key]) { + current[key] = {}; + } + current = current[key]; + } + current[keys[keys.length - 1]] = value; + } + + if (preset.requiredInputs) { + manifest.requiredInputs = preset.requiredInputs; + } + + // 保存到解压目录的manifest.json + await saveManifest(presetName, manifest); + + // 显示摘要 + console.log(`\n${BOLDGREEN}✓ Preset configured successfully!${RESET}\n`); + console.log(`${BOLDCYAN}Preset directory:${RESET} ${presetDir}`); + console.log(`${BOLDCYAN}Secrets configured:${RESET} ${Object.keys(sensitiveInputs).length}`); + + if (preset.metadata?.description) { + console.log(`\n${BOLDCYAN}Description:${RESET} ${preset.metadata.description}`); + } + + if (preset.metadata?.author) { + console.log(`${BOLDCYAN}Author:${RESET} ${preset.metadata.author}`); + } + + const keywords = (preset.metadata as any).keywords; + if (keywords && keywords.length > 0) { + console.log(`${BOLDCYAN}Keywords:${RESET} ${keywords.join(', ')}`); + } + + console.log(`\n${GREEN}Use this preset:${RESET} ccr ${presetName} "your prompt"`); + console.log(`${DIM}Note: Secrets are stored in the manifest file${RESET}\n`); + + } catch (error: any) { + console.error(`\n${YELLOW}Error applying preset:${RESET} ${error.message}`); + throw error; + } +} + +/** + * 安装预设(主入口) + */ +export async function installPresetCli( + source: string, + options: { + strategy?: MergeStrategy; + name?: string; + } = {} +): Promise { + let tempFile: string | null = null; + try { + // 确定预设名称 + let presetName = options.name; + let sourceZip: string; + let isReconfigure = false; // 是否是重新配置已安装的preset + + // 判断source类型并获取ZIP文件路径 + if (source.startsWith('http://') || source.startsWith('https://')) { + // URL:下载到临时文件 + if (!presetName) { + const urlParts = source.split('/'); + const filename = urlParts[urlParts.length - 1]; + presetName = filename.replace('.ccrsets', ''); + } + // 这里直接从 shared 包导入的 downloadPresetToTemp 会返回临时文件 + // 但我们会在 loadPreset 中自动清理,所以不需要在这里处理 + const preset = await loadPreset(source); + if (!presetName) { + presetName = preset.metadata?.name || 'preset'; + } + // 重新下载到临时文件以供 extractPreset 使用 + // 由于 loadPreset 已经下载并删除了,这里需要特殊处理 + throw new Error('URL installation not fully implemented yet'); + } else if (source.includes('/') || source.includes('\\')) { + // 文件路径 + if (!presetName) { + const filename = path.basename(source); + presetName = filename.replace('.ccrsets', ''); + } + // 验证文件存在 + try { + await fs.access(source); + } catch { + throw new Error(`Preset file not found: ${source}`); + } + sourceZip = source; + } else { + // 预设名称(不带路径) + presetName = source; + + // 按优先级查找文件:当前目录 -> presets目录 + const presetFile = await findPresetFile(source); + + if (presetFile) { + sourceZip = presetFile; + } else { + // 检查是否已安装(目录存在) + if (await isPresetInstalled(source)) { + // 已安装,重新配置 + isReconfigure = true; + } else { + // 都不存在,报错 + throw new Error(`Preset '${source}' not found in current directory or presets directory.`); + } + } + } + + if (isReconfigure) { + // 重新配置已安装的preset + console.log(`${BOLDCYAN}Reconfiguring preset:${RESET} ${presetName}\n`); + + const presetDir = getPresetDir(presetName); + const manifest = await readManifestFromDir(presetDir); + const preset = manifestToPresetFile(manifest); + + // 应用preset(会询问敏感信息) + await applyPresetCli(presetName, preset); + } else { + // 新安装:解压到目标目录 + const targetDir = getPresetDir(presetName); + console.log(`${BOLDCYAN}Extracting preset to:${RESET} ${targetDir}`); + await extractPreset(sourceZip, targetDir); + console.log(`${GREEN}✓${RESET} Extracted successfully\n`); + + // 从解压目录读取manifest + const manifest = await readManifestFromDir(targetDir); + const preset = manifestToPresetFile(manifest); + + // 应用preset(询问用户信息等) + await applyPresetCli(presetName, preset); + } + + } catch (error: any) { + console.error(`\n${YELLOW}Failed to install preset:${RESET} ${error.message}`); + process.exit(1); + } +} diff --git a/packages/server/package.json b/packages/server/package.json index 18b36fa..e4b87a0 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -17,8 +17,10 @@ "author": "musistudio", "license": "MIT", "dependencies": { + "@fastify/multipart": "^9.0.0", "@fastify/static": "^8.2.0", "@musistudio/llms": "^1.0.51", + "adm-zip": "^0.5.16", "dotenv": "^16.4.7", "json5": "^2.2.3", "lru-cache": "^11.2.2", @@ -29,6 +31,7 @@ }, "devDependencies": { "@CCR/shared": "workspace:*", + "@types/adm-zip": "^0.5.7", "@types/node": "^24.0.15", "esbuild": "^0.25.1", "fastify": "^5.4.0", diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index f56e878..b9f4bf9 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -121,7 +121,7 @@ async function getServer(options: RunOptions = {}) { } } - const serverInstance = createServer({ + const serverInstance = await createServer({ jsonPath: CONFIG_FILE, initialConfig: { // ...config, @@ -370,11 +370,11 @@ async function getServer(options: RunOptions = {}) { // Add global error handlers to prevent the service from crashing process.on("uncaughtException", (err) => { - serverInstance.logger.error("Uncaught exception:", err); + serverInstance.app.log.error("Uncaught exception:", err); }); process.on("unhandledRejection", (reason, promise) => { - serverInstance.logger.error("Unhandled rejection at:", promise, "reason:", reason); + serverInstance.app.log.error("Unhandled rejection at:", promise, "reason:", reason); }); return serverInstance; diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 1386db6..4dbfa6a 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -2,27 +2,53 @@ import Server from "@musistudio/llms"; import { readConfigFile, writeConfigFile, backupConfigFile } from "./utils"; import { join } from "path"; import fastifyStatic from "@fastify/static"; -import { readdirSync, statSync, readFileSync, writeFileSync, existsSync } from "fs"; +import { readdirSync, statSync, readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, rmSync } from "fs"; import { homedir } from "os"; import { calculateTokenCount } from "./utils/router"; +import { + getPresetDir, + readManifestFromDir, + manifestToPresetFile, + extractPreset, + validatePreset, + loadPreset, + saveManifest, + isPresetInstalled, + downloadPresetToTemp, + getTempDir, + HOME_DIR, + type PresetFile, + type ManifestFile, + type PresetMetadata, + MergeStrategy +} from "@CCR/shared"; -export const createServer = (config: any): any => { +export const createServer = async (config: any): Promise => { const server = new Server(config); + const app = server.app; - server.app.post("/v1/messages/count_tokens", async (req: any, reply: any) => { + // Register multipart plugin for file uploads (dynamic import) + const fastifyMultipart = await import('@fastify/multipart'); + app.register(fastifyMultipart.default, { + limits: { + fileSize: 50 * 1024 * 1024, // 50MB + }, + }); + + app.post("/v1/messages/count_tokens", async (req: any, reply: any) => { const {messages, tools, system} = req.body; const tokenCount = calculateTokenCount(messages, system, tools); return { "input_tokens": tokenCount } }); // Add endpoint to read config.json with access control - server.app.get("/api/config", async (req: any, reply: any) => { + app.get("/api/config", async (req: any, reply: any) => { return await readConfigFile(); }); - server.app.get("/api/transformers", async (req: any, reply: any) => { + app.get("/api/transformers", async (req: any, reply: any) => { const transformers = - (server.app as any)._server!.transformerService.getAllTransformers(); + (app as any)._server!.transformerService.getAllTransformers(); const transformerList = Array.from(transformers.entries()).map( ([name, transformer]: any) => ({ name, @@ -33,7 +59,7 @@ export const createServer = (config: any): any => { }); // Add endpoint to save config.json with access control - server.app.post("/api/config", async (req: any, reply: any) => { + app.post("/api/config", async (req: any, reply: any) => { const newConfig = req.body; // Backup existing config file if it exists @@ -47,19 +73,19 @@ export const createServer = (config: any): any => { }); // Register static file serving with caching - server.app.register(fastifyStatic, { + app.register(fastifyStatic, { root: join(__dirname, "..", "dist"), prefix: "/ui/", maxAge: "1h", }); // Redirect /ui to /ui/ for proper static file serving - server.app.get("/ui", async (_: any, reply: any) => { + app.get("/ui", async (_: any, reply: any) => { return reply.redirect("/ui/"); }); // 获取日志文件列表端点 - server.app.get("/api/logs/files", async (req: any, reply: any) => { + app.get("/api/logs/files", async (req: any, reply: any) => { try { const logDir = join(homedir(), ".claude-code-router", "logs"); const logFiles: Array<{ name: string; path: string; size: number; lastModified: string }> = []; @@ -93,7 +119,7 @@ export const createServer = (config: any): any => { }); // 获取日志内容端点 - server.app.get("/api/logs", async (req: any, reply: any) => { + app.get("/api/logs", async (req: any, reply: any) => { try { const filePath = (req.query as any).file as string; let logFilePath: string; @@ -121,7 +147,7 @@ export const createServer = (config: any): any => { }); // 清除日志内容端点 - server.app.delete("/api/logs", async (req: any, reply: any) => { + app.delete("/api/logs", async (req: any, reply: any) => { try { const filePath = (req.query as any).file as string; let logFilePath: string; @@ -145,5 +171,252 @@ export const createServer = (config: any): any => { } }); + // ========== Preset 相关 API ========== + + // 获取预设列表 + app.get("/api/presets", async (req: any, reply: any) => { + try { + const presetsDir = join(HOME_DIR, "presets"); + + if (!existsSync(presetsDir)) { + return { presets: [] }; + } + + const entries = readdirSync(presetsDir, { withFileTypes: true }); + const presetDirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => e.name); + + const presets: Array = []; + + for (const dirName of presetDirs) { + const presetDir = join(presetsDir, dirName); + try { + const manifestPath = join(presetDir, "manifest.json"); + const content = readFileSync(manifestPath, 'utf-8'); + const manifest = JSON.parse(content); + + // 提取 metadata 字段 + const { Providers, Router, PORT, HOST, API_TIMEOUT_MS, PROXY_URL, LOG, LOG_LEVEL, StatusLine, NON_INTERACTIVE_MODE, requiredInputs, ...metadata } = manifest; + + presets.push({ + id: dirName, // 目录名作为唯一标识 + name: metadata.name || dirName, + version: metadata.version || '1.0.0', + description: metadata.description, + author: metadata.author, + homepage: metadata.homepage, + repository: metadata.repository, + license: metadata.license, + keywords: metadata.keywords, + ccrVersion: metadata.ccrVersion, + source: metadata.source, + sourceType: metadata.sourceType, + checksum: metadata.checksum, + installed: true, + }); + } catch (error) { + console.error(`Failed to read preset ${dirName}:`, error); + } + } + + return { presets }; + } catch (error) { + console.error("Failed to get presets:", error); + reply.status(500).send({ error: "Failed to get presets" }); + } + }); + + // 获取预设详情 + app.get("/api/presets/:name", async (req: any, reply: any) => { + try { + const { name } = req.params; + const presetDir = getPresetDir(name); + + if (!existsSync(presetDir)) { + reply.status(404).send({ error: "Preset not found" }); + return; + } + + const manifest = await readManifestFromDir(presetDir); + const preset = manifestToPresetFile(manifest); + + return preset; + } catch (error: any) { + console.error("Failed to get preset:", error); + reply.status(500).send({ error: error.message || "Failed to get preset" }); + } + }); + + // 上传并安装预设(支持文件上传) + app.post("/api/presets/install", async (req: any, reply: any) => { + try { + const { source, name, url } = req.body; + + // 如果提供了 URL,从 URL 下载 + if (url) { + const tempFile = await downloadPresetToTemp(url); + const preset = await loadPresetFromZip(tempFile); + + // 确定预设名称 + const presetName = name || preset.metadata?.name || `preset-${Date.now()}`; + + // 检查是否已安装 + if (await isPresetInstalled(presetName)) { + reply.status(409).send({ error: "Preset already installed" }); + return; + } + + // 解压到目标目录 + const targetDir = getPresetDir(presetName); + await extractPreset(tempFile, targetDir); + + // 清理临时文件 + unlinkSync(tempFile); + + return { + success: true, + presetName, + preset: { + ...preset.metadata, + installed: true, + } + }; + } + + // 如果没有 URL,需要处理文件上传(使用 multipart/form-data) + // 这部分需要在客户端使用 FormData 上传 + reply.status(400).send({ error: "Please provide a URL or upload a file" }); + } catch (error: any) { + console.error("Failed to install preset:", error); + reply.status(500).send({ error: error.message || "Failed to install preset" }); + } + }); + + // 上传预设文件(multipart/form-data) + app.post("/api/presets/upload", async (req: any, reply: any) => { + try { + const data = await req.file(); + if (!data) { + reply.status(400).send({ error: "No file uploaded" }); + return; + } + + const tempDir = getTempDir(); + mkdirSync(tempDir, { recursive: true }); + + const tempFile = join(tempDir, `preset-${Date.now()}.ccrsets`); + + // 保存上传的文件到临时位置 + const buffer = await data.toBuffer(); + writeFileSync(tempFile, buffer); + + // 加载预设 + const preset = await loadPresetFromZip(tempFile); + + // 确定预设名称 + const presetName = data.fields.name?.value || preset.metadata?.name || `preset-${Date.now()}`; + + // 检查是否已安装 + if (await isPresetInstalled(presetName)) { + unlinkSync(tempFile); + reply.status(409).send({ error: "Preset already installed" }); + return; + } + + // 解压到目标目录 + const targetDir = getPresetDir(presetName); + await extractPreset(tempFile, targetDir); + + // 清理临时文件 + unlinkSync(tempFile); + + return { + success: true, + presetName, + preset: { + ...preset.metadata, + installed: true, + } + }; + } catch (error: any) { + console.error("Failed to upload preset:", error); + reply.status(500).send({ error: error.message || "Failed to upload preset" }); + } + }); + + // 应用预设(配置敏感信息) + app.post("/api/presets/:name/apply", async (req: any, reply: any) => { + try { + const { name } = req.params; + const { secrets } = req.body; + + const presetDir = getPresetDir(name); + + if (!existsSync(presetDir)) { + reply.status(404).send({ error: "Preset not found" }); + return; + } + + // 读取现有 manifest + const manifest = await readManifestFromDir(presetDir); + + // 将 secrets 信息应用到 manifest 中 + if (secrets) { + for (const [fieldPath, value] of Object.entries(secrets)) { + const keys = fieldPath.split(/[.\[\]]+/).filter(k => k !== ''); + let current = manifest as any; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + if (!current[key]) { + current[key] = {}; + } + current = current[key]; + } + current[keys[keys.length - 1]] = value; + } + } + + // 保存更新后的 manifest + await saveManifest(name, manifest); + + return { success: true, message: "Preset applied successfully" }; + } catch (error: any) { + console.error("Failed to apply preset:", error); + reply.status(500).send({ error: error.message || "Failed to apply preset" }); + } + }); + + // 删除预设 + app.delete("/api/presets/:name", async (req: any, reply: any) => { + try { + const { name } = req.params; + const presetDir = getPresetDir(name); + + if (!existsSync(presetDir)) { + reply.status(404).send({ error: "Preset not found" }); + return; + } + + // 递归删除整个目录 + rmSync(presetDir, { recursive: true, force: true }); + + return { success: true, message: "Preset deleted successfully" }; + } catch (error: any) { + console.error("Failed to delete preset:", error); + reply.status(500).send({ error: error.message || "Failed to delete preset" }); + } + }); + + // 辅助函数:从 ZIP 加载预设 + async function loadPresetFromZip(zipFile: string): Promise { + const AdmZip = (await import('adm-zip')).default; + const zip = new AdmZip(zipFile); + const entry = zip.getEntry('manifest.json'); + if (!entry) { + throw new Error('Invalid preset file: manifest.json not found'); + } + const manifest = JSON.parse(entry.getData().toString('utf-8')) as ManifestFile; + return manifestToPresetFile(manifest); + } + return server; }; diff --git a/packages/shared/package.json b/packages/shared/package.json index 48da9b0..54bf386 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -15,7 +15,14 @@ ], "author": "musistudio", "license": "MIT", + "dependencies": { + "adm-zip": "^0.5.16", + "archiver": "^7.0.1", + "json5": "^2.2.3" + }, "devDependencies": { + "@types/adm-zip": "^0.5.7", + "@types/archiver": "^7.0.0", "@types/node": "^24.0.15", "esbuild": "^0.25.1", "typescript": "^5.8.2" diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b04bfcf..21c0121 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1 +1,9 @@ export * from "./constants"; + +// Export preset-related functionality +export * from './preset/types'; +export * from './preset/sensitiveFields'; +export * from './preset/merge'; +export * from './preset/install'; +export * from './preset/export'; +export * from './preset/readPreset'; diff --git a/packages/shared/src/preset/export.ts b/packages/shared/src/preset/export.ts new file mode 100644 index 0000000..ce9ce99 --- /dev/null +++ b/packages/shared/src/preset/export.ts @@ -0,0 +1,134 @@ +/** + * 预设导出核心功能 + * 注意:这个模块不包含 CLI 交互逻辑,交互逻辑由调用者提供 + */ + +import * as fs from 'fs/promises'; +import * as fsSync from 'fs'; +import * as path from 'path'; +import archiver from 'archiver'; +import { sanitizeConfig } from './sensitiveFields'; +import { PresetFile, PresetMetadata, ManifestFile } from './types'; +import { HOME_DIR } from '../constants'; + +/** + * 导出选项 + */ +export interface ExportOptions { + output?: string; + includeSensitive?: boolean; + description?: string; + author?: string; + tags?: string; +} + +/** + * 导出结果 + */ +export interface ExportResult { + outputPath: string; + sanitizedConfig: any; + metadata: PresetMetadata; + requiredInputs: any[]; + sanitizedCount: number; +} + +/** + * 创建 manifest 对象 + * @param presetName 预设名称 + * @param config 配置对象 + * @param sanitizedConfig 脱敏后的配置 + * @param options 导出选项 + */ +export function createManifest( + presetName: string, + config: any, + sanitizedConfig: any, + options: ExportOptions, + requiredInputs: any[] = [] +): ManifestFile { + const metadata: PresetMetadata = { + name: presetName, + version: '1.0.0', + description: options.description, + author: options.author, + keywords: options.tags ? options.tags.split(',').map(t => t.trim()) : undefined, + }; + + return { + ...metadata, + ...sanitizedConfig, + requiredInputs: options.includeSensitive ? undefined : requiredInputs, + }; +} + +/** + * 导出预设配置 + * @param presetName 预设名称 + * @param config 当前配置 + * @param options 导出选项 + * @returns 导出结果 + */ +export async function exportPreset( + presetName: string, + config: any, + options: ExportOptions = {} +): Promise { + // 1. 收集元数据 + const metadata: PresetMetadata = { + name: presetName, + version: '1.0.0', + description: options.description, + author: options.author, + keywords: options.tags ? options.tags.split(',').map(t => t.trim()) : undefined, + }; + + // 2. 脱敏配置 + const { sanitizedConfig, requiredInputs, sanitizedCount } = await sanitizeConfig(config); + + // 3. 生成manifest.json(扁平化结构) + const manifest: ManifestFile = { + ...metadata, + ...sanitizedConfig, + requiredInputs: options.includeSensitive ? undefined : requiredInputs, + }; + + // 4. 确定输出路径 + const presetsDir = path.join(HOME_DIR, 'presets'); + + // 确保预设目录存在 + await fs.mkdir(presetsDir, { recursive: true }); + + const outputPath = options.output || path.join(presetsDir, `${presetName}.ccrsets`); + + // 5. 创建压缩包 + const output = fsSync.createWriteStream(outputPath); + const archive = archiver('zip', { + zlib: { level: 9 } // 最高压缩级别 + }); + + return new Promise((resolve, reject) => { + output.on('close', () => { + resolve({ + outputPath, + sanitizedConfig, + metadata, + requiredInputs, + sanitizedCount, + }); + }); + + archive.on('error', (err: Error) => { + reject(err); + }); + + // 连接输出流 + archive.pipe(output); + + // 添加manifest.json到压缩包 + archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' }); + + // 完成压缩 + archive.finalize(); + }); +} diff --git a/packages/shared/src/preset/install.ts b/packages/shared/src/preset/install.ts new file mode 100644 index 0000000..b4e87e3 --- /dev/null +++ b/packages/shared/src/preset/install.ts @@ -0,0 +1,239 @@ +/** + * 预设安装核心功能 + * 注意:这个模块不包含 CLI 交互逻辑,交互逻辑由调用者提供 + */ + +import * as fs from 'fs/promises'; +import * as fsSync from 'fs'; +import * as path from 'path'; +import JSON5 from 'json5'; +import AdmZip from 'adm-zip'; +import { PresetFile, MergeStrategy, RequiredInput, ManifestFile } from './types'; +import { HOME_DIR } from '../constants'; + +/** + * 获取预设目录的完整路径 + * @param presetName 预设名称 + */ +export function getPresetDir(presetName: string): string { + return path.join(HOME_DIR, 'presets', presetName); +} + +/** + * 获取临时目录路径 + */ +export function getTempDir(): string { + return path.join(HOME_DIR, 'temp'); +} + +/** + * 解压预设文件到目标目录 + * @param sourceZip 源ZIP文件路径 + * @param targetDir 目标目录 + */ +export async function extractPreset(sourceZip: string, targetDir: string): Promise { + // 检查目标目录是否已存在 + try { + await fs.access(targetDir); + throw new Error(`Preset directory already exists: ${path.basename(targetDir)}`); + } catch (error: any) { + if (error.code !== 'ENOENT') { + throw error; + } + // ENOENT 表示目录不存在,可以继续 + } + + // 创建目标目录 + await fs.mkdir(targetDir, { recursive: true }); + + // 解压文件 + const zip = new AdmZip(sourceZip); + zip.extractAllTo(targetDir, true); +} + +/** + * 从解压目录读取manifest + * @param presetDir 预设目录路径 + */ +export async function readManifestFromDir(presetDir: string): Promise { + const manifestPath = path.join(presetDir, 'manifest.json'); + const content = await fs.readFile(manifestPath, 'utf-8'); + return JSON5.parse(content) as ManifestFile; +} + +/** + * 将manifest转换为PresetFile格式 + */ +export function manifestToPresetFile(manifest: ManifestFile): PresetFile { + const { Providers, Router, PORT, HOST, API_TIMEOUT_MS, PROXY_URL, LOG, LOG_LEVEL, StatusLine, NON_INTERACTIVE_MODE, requiredInputs, ...metadata } = manifest; + return { + metadata, + config: { Providers, Router, PORT, HOST, API_TIMEOUT_MS, PROXY_URL, LOG, LOG_LEVEL, StatusLine, NON_INTERACTIVE_MODE }, + requiredInputs, + }; +} + +/** + * 下载预设文件到临时位置 + * @param url 下载URL + * @returns 临时文件路径 + */ +export async function downloadPresetToTemp(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download preset: ${response.statusText}`); + } + const buffer = await response.arrayBuffer(); + + // 创建临时文件 + const tempDir = getTempDir(); + await fs.mkdir(tempDir, { recursive: true }); + + const tempFile = path.join(tempDir, `preset-${Date.now()}.ccrsets`); + await fs.writeFile(tempFile, Buffer.from(buffer)); + + return tempFile; +} + +/** + * 从本地ZIP文件加载预设 + * @param zipFile ZIP文件路径 + * @returns PresetFile + */ +export async function loadPresetFromZip(zipFile: string): Promise { + const zip = new AdmZip(zipFile); + const entry = zip.getEntry('manifest.json'); + if (!entry) { + throw new Error('Invalid preset file: manifest.json not found'); + } + const manifest = JSON5.parse(entry.getData().toString('utf-8')) as ManifestFile; + return manifestToPresetFile(manifest); +} + +/** + * 加载预设文件 + * @param source 预设来源(文件路径、URL 或预设名称) + */ +export async function loadPreset(source: string): Promise { + // 判断是否是 URL + if (source.startsWith('http://') || source.startsWith('https://')) { + const tempFile = await downloadPresetToTemp(source); + const preset = await loadPresetFromZip(tempFile); + // 删除临时文件 + await fs.unlink(tempFile).catch(() => {}); + return preset; + } + + // 判断是否是绝对路径或相对路径(包含 / 或 \) + if (source.includes('/') || source.includes('\\')) { + // 文件路径 + return await loadPresetFromZip(source); + } + + // 否则作为预设名称处理(从解压目录读取) + const presetDir = getPresetDir(source); + const manifest = await readManifestFromDir(presetDir); + return manifestToPresetFile(manifest); +} + +/** + * 验证预设文件 + */ +export async function validatePreset(preset: PresetFile): Promise<{ + valid: boolean; + errors: string[]; + warnings: string[]; +}> { + const errors: string[] = []; + const warnings: string[] = []; + + // 验证元数据 + if (!preset.metadata) { + warnings.push('Missing metadata section'); + } else { + if (!preset.metadata.name) { + errors.push('Missing preset name in metadata'); + } + if (!preset.metadata.version) { + warnings.push('Missing version in metadata'); + } + } + + // 验证配置部分 + if (!preset.config) { + errors.push('Missing config section'); + } + + // 验证 Providers + if (preset.config.Providers) { + for (const provider of preset.config.Providers) { + if (!provider.name) { + errors.push('Provider missing name field'); + } + if (!provider.api_base_url) { + errors.push(`Provider "${provider.name}" missing api_base_url`); + } + if (!provider.models || provider.models.length === 0) { + warnings.push(`Provider "${provider.name}" has no models`); + } + } + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +/** + * 保存 manifest 到预设目录 + * @param presetName 预设名称 + * @param manifest manifest 对象 + */ +export async function saveManifest(presetName: string, manifest: ManifestFile): Promise { + const presetDir = getPresetDir(presetName); + const manifestPath = path.join(presetDir, 'manifest.json'); + await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8'); +} + +/** + * 查找预设文件 + * @param source 预设来源 + * @returns 文件路径或 null + */ +export async function findPresetFile(source: string): Promise { + // 当前目录文件 + const currentDirFile = path.join(process.cwd(), `${source}.ccrsets`); + + // presets 目录文件 + const presetsDirFile = path.join(HOME_DIR, 'presets', `${source}.ccrsets`); + + // 检查当前目录 + try { + await fs.access(currentDirFile); + return currentDirFile; + } catch { + // 检查presets目录 + try { + await fs.access(presetsDirFile); + return presetsDirFile; + } catch { + return null; + } + } +} + +/** + * 检查预设是否已安装 + * @param presetName 预设名称 + */ +export async function isPresetInstalled(presetName: string): Promise { + const presetDir = getPresetDir(presetName); + try { + await fs.access(presetDir); + return true; + } catch { + return false; + } +} diff --git a/packages/shared/src/preset/merge.ts b/packages/shared/src/preset/merge.ts new file mode 100644 index 0000000..3b3f401 --- /dev/null +++ b/packages/shared/src/preset/merge.ts @@ -0,0 +1,280 @@ +/** + * 配置合并策略 + */ + +import { MergeStrategy, ProviderConfig, RouterConfig, TransformerConfig, ProviderConflictAction } from './types'; + +/** + * 合并 Provider 配置 + */ +async function mergeProviders( + existing: ProviderConfig[], + incoming: ProviderConfig[], + strategy: MergeStrategy, + onProviderConflict?: (providerName: string) => Promise +): Promise { + const result = [...existing]; + const existingNames = new Set(existing.map(p => p.name)); + + for (const provider of incoming) { + if (existingNames.has(provider.name)) { + // Provider 已存在,需要处理冲突 + let action: ProviderConflictAction; + + if (strategy === MergeStrategy.ASK && onProviderConflict) { + action = await onProviderConflict(provider.name); + } else if (strategy === MergeStrategy.OVERWRITE) { + action = 'overwrite'; + } else if (strategy === MergeStrategy.MERGE) { + action = 'merge'; + } else { + action = 'skip'; + } + + switch (action) { + case 'keep': + // 保留现有,不做任何操作 + break; + case 'overwrite': + const index = result.findIndex(p => p.name === provider.name); + result[index] = provider; + break; + case 'merge': + const existingProvider = result.find(p => p.name === provider.name)!; + // 合并模型列表,去重 + const mergedModels = [...new Set([ + ...existingProvider.models, + ...provider.models, + ])]; + existingProvider.models = mergedModels; + + // 合并 transformer 配置 + if (provider.transformer) { + if (!existingProvider.transformer) { + existingProvider.transformer = provider.transformer; + } else { + // 合并 transformer.use + if (provider.transformer.use && existingProvider.transformer.use) { + const mergedTransformers = [...new Set([ + ...existingProvider.transformer.use, + ...provider.transformer.use, + ])]; + existingProvider.transformer.use = mergedTransformers as any; + } + } + } + break; + case 'skip': + // 跳过,不做任何操作 + break; + } + } else { + // 新 Provider,直接添加 + result.push(provider); + } + } + + return result; +} + +/** + * 合并 Router 配置 + */ +async function mergeRouter( + existing: RouterConfig, + incoming: RouterConfig, + strategy: MergeStrategy, + onRouterConflict?: (key: string, existingValue: any, newValue: any) => Promise +): Promise { + const result = { ...existing }; + + for (const [key, value] of Object.entries(incoming)) { + if (value === undefined || value === null) { + continue; + } + + const existingValue = result[key]; + + if (existingValue === undefined || existingValue === null) { + // 现有配置中没有这个路由规则,直接添加 + result[key] = value; + } else { + // 存在冲突 + if (strategy === MergeStrategy.ASK && onRouterConflict) { + const shouldOverwrite = await onRouterConflict(key, existingValue, value); + if (shouldOverwrite) { + result[key] = value; + } + } else if (strategy === MergeStrategy.OVERWRITE) { + result[key] = value; + } else if (strategy === MergeStrategy.MERGE) { + // 对于 Router,merge 策略等同于 skip,保留现有值 + // 或者可以询问用户 + } + // skip 策略:保留现有值,不做任何操作 + } + } + + return result; +} + +/** + * 合并 Transformer 配置 + */ +async function mergeTransformers( + existing: TransformerConfig[], + incoming: TransformerConfig[], + strategy: MergeStrategy, + onTransformerConflict?: (transformerPath: string) => Promise<'keep' | 'overwrite' | 'skip'> +): Promise { + if (!existing || existing.length === 0) { + return incoming; + } + + if (!incoming || incoming.length === 0) { + return existing; + } + + // Transformer 合并逻辑:按路径匹配 + const result = [...existing]; + const existingPaths = new Set(existing.map(t => t.path)); + + for (const transformer of incoming) { + if (!transformer.path) { + // 没有 path 的 transformer,直接添加 + result.push(transformer); + continue; + } + + if (existingPaths.has(transformer.path)) { + // 已存在相同 path 的 transformer + if (strategy === MergeStrategy.ASK && onTransformerConflict) { + const action = await onTransformerConflict(transformer.path); + if (action === 'overwrite') { + const index = result.findIndex(t => t.path === transformer.path); + result[index] = transformer; + } + // keep 和 skip 都不做操作 + } else if (strategy === MergeStrategy.OVERWRITE) { + const index = result.findIndex(t => t.path === transformer.path); + result[index] = transformer; + } + // merge 和 skip 策略:保留现有 + } else { + // 新 transformer,直接添加 + result.push(transformer); + } + } + + return result; +} + +/** + * 合并其他顶级配置 + */ +async function mergeOtherConfig( + existing: any, + incoming: any, + strategy: MergeStrategy, + onConfigConflict?: (key: string) => Promise, + excludeKeys: string[] = ['Providers', 'Router', 'transformers'] +): Promise { + const result = { ...existing }; + + for (const [key, value] of Object.entries(incoming)) { + if (excludeKeys.includes(key)) { + continue; + } + + if (value === undefined || value === null) { + continue; + } + + const existingValue = result[key]; + + if (existingValue === undefined || existingValue === null) { + // 现有配置中没有这个字段,直接添加 + result[key] = value; + } else { + // 存在冲突 + if (strategy === MergeStrategy.ASK && onConfigConflict) { + const shouldOverwrite = await onConfigConflict(key); + if (shouldOverwrite) { + result[key] = value; + } + } else if (strategy === MergeStrategy.OVERWRITE) { + result[key] = value; + } + // merge 和 skip 策略:保留现有值 + } + } + + return result; +} + +/** + * 合并交互回调接口 + */ +export interface MergeCallbacks { + onProviderConflict?: (providerName: string) => Promise; + onRouterConflict?: (key: string, existingValue: any, newValue: any) => Promise; + onTransformerConflict?: (transformerPath: string) => Promise<'keep' | 'overwrite' | 'skip'>; + onConfigConflict?: (key: string) => Promise; +} + +/** + * 主配置合并函数 + * @param baseConfig 基础配置(现有配置) + * @param presetConfig 预设配置 + * @param strategy 合并策略 + * @param callbacks 交互式回调函数 + * @returns 合并后的配置 + */ +export async function mergeConfig( + baseConfig: any, + presetConfig: any, + strategy: MergeStrategy = MergeStrategy.ASK, + callbacks?: MergeCallbacks +): Promise { + const result = { ...baseConfig }; + + // 合并 Providers + if (presetConfig.Providers) { + result.Providers = await mergeProviders( + result.Providers || [], + presetConfig.Providers, + strategy, + callbacks?.onProviderConflict + ); + } + + // 合并 Router + if (presetConfig.Router) { + result.Router = await mergeRouter( + result.Router || {}, + presetConfig.Router, + strategy, + callbacks?.onRouterConflict + ); + } + + // 合并 transformers + if (presetConfig.transformers) { + result.transformers = await mergeTransformers( + result.transformers || [], + presetConfig.transformers, + strategy, + callbacks?.onTransformerConflict + ); + } + + // 合并其他配置 + const otherConfig = await mergeOtherConfig( + result, + presetConfig, + strategy, + callbacks?.onConfigConflict + ); + + return otherConfig; +} diff --git a/packages/shared/src/preset/readPreset.ts b/packages/shared/src/preset/readPreset.ts new file mode 100644 index 0000000..95c8e51 --- /dev/null +++ b/packages/shared/src/preset/readPreset.ts @@ -0,0 +1,30 @@ +/** + * 读取预设配置文件 + * 用于 CLI 快速读取预设配置 + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import JSON5 from 'json5'; +import { HOME_DIR } from '../constants'; + +/** + * 读取 preset 配置文件 + * @param name preset 名称 + * @returns preset 配置对象,如果文件不存在则返回 null + */ +export async function readPresetFile(name: string): Promise { + try { + const presetDir = path.join(HOME_DIR, 'presets', name); + const manifestPath = path.join(presetDir, 'manifest.json'); + const manifest = JSON5.parse(await fs.readFile(manifestPath, 'utf-8')); + // manifest已经是扁平化结构,直接返回 + return manifest; + } catch (error: any) { + if (error.code === 'ENOENT') { + return null; + } + console.error(`Failed to read preset file: ${error.message}`); + return null; + } +} diff --git a/packages/shared/src/preset/sensitiveFields.ts b/packages/shared/src/preset/sensitiveFields.ts new file mode 100644 index 0000000..0d964c1 --- /dev/null +++ b/packages/shared/src/preset/sensitiveFields.ts @@ -0,0 +1,249 @@ +/** + * 敏感字段识别和脱敏功能 + */ + +import { RequiredInput, SanitizeResult } from './types'; + +// 敏感字段模式列表 +const SENSITIVE_PATTERNS = [ + 'api_key', 'apikey', 'apiKey', 'APIKEY', + 'api_secret', 'apisecret', 'apiSecret', + 'secret', 'SECRET', + 'token', 'TOKEN', 'auth_token', + 'password', 'PASSWORD', 'passwd', + 'private_key', 'privateKey', + 'access_key', 'accessKey', +]; + +// 环境变量占位符正则 +const ENV_VAR_REGEX = /^\$\{?[A-Z_][A-Z0-9_]*\}?$/; + +/** + * 检查字段名是否为敏感字段 + */ +function isSensitiveField(fieldName: string): boolean { + const lowerFieldName = fieldName.toLowerCase(); + return SENSITIVE_PATTERNS.some(pattern => + lowerFieldName.includes(pattern.toLowerCase()) + ); +} + +/** + * 生成环境变量名称 + * @param fieldType 字段类型 (provider, transformer, global) + * @param entityName 实体名称 (如 provider name) + * @param fieldName 字段名称 + */ +export function generateEnvVarName( + fieldType: 'provider' | 'transformer' | 'global', + entityName: string, + fieldName: string +): string { + // 生成大写的环境变量名 + // 例如: DEEPSEEK_API_KEY, CUSTOM_TRANSFORMER_SECRET + const prefix = entityName.toUpperCase().replace(/[^A-Z0-9]/g, '_'); + const field = fieldName.toUpperCase().replace(/[^A-Z0-9]/g, '_'); + + // 如果前缀和字段名相同(如 API_KEY),避免重复 + if (prefix === field) { + return prefix; + } + + return `${prefix}_${field}`; +} + +/** + * 检查值是否已经是环境变量占位符 + */ +function isEnvPlaceholder(value: any): boolean { + if (typeof value !== 'string') { + return false; + } + return ENV_VAR_REGEX.test(value.trim()); +} + +/** + * 从环境变量占位符中提取变量名 + * @param value 环境变量值(如 $VAR 或 ${VAR}) + */ +function extractEnvVarName(value: string): string | null { + const trimmed = value.trim(); + + // 匹配 ${VAR_NAME} 格式 + const bracedMatch = trimmed.match(/^\$\{([A-Z_][A-Z0-9_]*)\}$/); + if (bracedMatch) { + return bracedMatch[1]; + } + + // 匹配 $VAR_NAME 格式 + const unbracedMatch = trimmed.match(/^\$([A-Z_][A-Z0-9_]*)$/); + if (unbracedMatch) { + return unbracedMatch[1]; + } + + return null; +} + +/** + * 递归遍历对象,识别和脱敏敏感字段 + * @param config 配置对象 + * @param path 当前字段路径 + * @param requiredInputs 必需输入数组(累积) + * @param sanitizedCount 脱敏字段计数 + */ +function sanitizeObject( + config: any, + path: string = '', + requiredInputs: RequiredInput[] = [], + sanitizedCount: number = 0 +): { sanitized: any; requiredInputs: RequiredInput[]; count: number } { + if (!config || typeof config !== 'object') { + return { sanitized: config, requiredInputs, count: sanitizedCount }; + } + + if (Array.isArray(config)) { + const sanitizedArray: any[] = []; + for (let i = 0; i < config.length; i++) { + const result = sanitizeObject( + config[i], + path ? `${path}[${i}]` : `[${i}]`, + requiredInputs, + sanitizedCount + ); + sanitizedArray.push(result.sanitized); + requiredInputs = result.requiredInputs; + sanitizedCount = result.count; + } + return { sanitized: sanitizedArray, requiredInputs, count: sanitizedCount }; + } + + const sanitizedObj: any = {}; + for (const [key, value] of Object.entries(config)) { + const currentPath = path ? `${path}.${key}` : key; + + // 检查是否是敏感字段 + if (isSensitiveField(key) && typeof value === 'string') { + // 如果值已经是环境变量,保持不变 + if (isEnvPlaceholder(value)) { + sanitizedObj[key] = value; + // 仍然需要记录为必需输入,但使用已有环境变量 + const envVarName = extractEnvVarName(value); + if (envVarName && !requiredInputs.some(input => input.field === currentPath)) { + requiredInputs.push({ + field: currentPath, + prompt: `Enter ${key}`, + placeholder: envVarName, + }); + } + } else { + // 脱敏:替换为环境变量占位符 + // 尝试从路径中推断实体名称 + let entityName = 'CONFIG'; + const pathParts = currentPath.split('.'); + + // 如果路径包含 Providers 或 transformers,尝试提取实体名称 + for (let i = 0; i < pathParts.length; i++) { + if (pathParts[i] === 'Providers' || pathParts[i] === 'transformers') { + // 查找 name 字段 + if (i + 1 < pathParts.length && pathParts[i + 1].match(/^\d+$/)) { + // 这是数组索引,查找同级的 name 字段 + const parentPath = pathParts.slice(0, i + 2).join('.'); + // 在当前上下文中查找 name + const context = config; + if (context.name) { + entityName = context.name; + } + } + break; + } + } + + const envVarName = generateEnvVarName('global', entityName, key); + sanitizedObj[key] = `\${${envVarName}}`; + + // 记录为必需输入 + requiredInputs.push({ + field: currentPath, + prompt: `Enter ${key}`, + placeholder: envVarName, + }); + + sanitizedCount++; + } + } else if (typeof value === 'object' && value !== null) { + // 递归处理嵌套对象 + const result = sanitizeObject(value, currentPath, requiredInputs, sanitizedCount); + sanitizedObj[key] = result.sanitized; + requiredInputs = result.requiredInputs; + sanitizedCount = result.count; + } else { + // 保留原始值 + sanitizedObj[key] = value; + } + } + + return { sanitized: sanitizedObj, requiredInputs, count: sanitizedCount }; +} + +/** + * 脱敏配置对象 + * @param config 原始配置 + * @returns 脱敏结果 + */ +export async function sanitizeConfig(config: any): Promise { + // 深拷贝配置,避免修改原始对象 + const configCopy = JSON.parse(JSON.stringify(config)); + + const result = sanitizeObject(configCopy); + + return { + sanitizedConfig: result.sanitized, + requiredInputs: result.requiredInputs, + sanitizedCount: result.count, + }; +} + +/** + * 填充敏感信息到配置中 + * @param config 预设配置(包含环境变量占位符) + * @param inputs 用户输入的敏感信息 + * @returns 填充后的配置 + */ +export function fillSensitiveInputs(config: any, inputs: Record): any { + const configCopy = JSON.parse(JSON.stringify(config)); + + function fillObject(obj: any, path: string = ''): any { + if (!obj || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map((item, index) => + fillObject(item, path ? `${path}[${index}]` : `[${index}]`) + ); + } + + const result: any = {}; + for (const [key, value] of Object.entries(obj)) { + const currentPath = path ? `${path}.${key}` : key; + + if (typeof value === 'string' && isEnvPlaceholder(value)) { + // 查找是否有用户输入 + const input = inputs[currentPath]; + if (input) { + result[key] = input; + } else { + result[key] = value; + } + } else if (typeof value === 'object' && value !== null) { + result[key] = fillObject(value, currentPath); + } else { + result[key] = value; + } + } + + return result; + } + + return fillObject(configCopy); +} diff --git a/packages/shared/src/preset/types.ts b/packages/shared/src/preset/types.ts new file mode 100644 index 0000000..c5d4872 --- /dev/null +++ b/packages/shared/src/preset/types.ts @@ -0,0 +1,138 @@ +/** + * 预设功能的类型定义 + */ + +// 敏感字段输入要求 +export interface RequiredInput { + field: string; // 字段路径 (如 "Providers[0].api_key") + prompt?: string; // 提示信息 + placeholder?: string; // 占位符环境变量名 + defaultValue?: string; // 默认值 + validator?: RegExp | string; // 验证规则 +} + +// Provider 配置 +export interface ProviderConfig { + name: string; + api_base_url: string; + api_key: string; + models: string[]; + transformer?: any; + [key: string]: any; +} + +// Router 配置 +export interface RouterConfig { + default?: string; + background?: string; + think?: string; + longContext?: string; + longContextThreshold?: number; + webSearch?: string; + image?: string; + [key: string]: string | number | undefined; +} + +// Transformer 配置 +export interface TransformerConfig { + path?: string; + use: Array; + options?: any; + [key: string]: any; +} + +// 预设元数据(扁平化结构,用于manifest.json) +export interface PresetMetadata { + name: string; // 预设名称 + version: string; // 版本号 (semver) + description?: string; // 描述 + author?: string; // 作者 + homepage?: string; // 主页 + repository?: string; // 源码仓库 + license?: string; // 许可证 + keywords?: string[]; // 关键词(原tags) + ccrVersion?: string; // 兼容的 CCR 版本 + source?: string; // 预设来源 URL + sourceType?: 'local' | 'gist' | 'registry'; + checksum?: string; // 预设内容校验和 +} + +// 预设配置部分 +export interface PresetConfigSection { + Providers?: ProviderConfig[]; + Router?: RouterConfig; + transformers?: TransformerConfig[]; + PORT?: number; + HOST?: string; + API_TIMEOUT_MS?: number; + PROXY_URL?: string; + LOG?: boolean; + LOG_LEVEL?: string; + StatusLine?: any; + NON_INTERACTIVE_MODE?: boolean; + [key: string]: any; +} + +// 完整的预设文件格式 +export interface PresetFile { + metadata?: PresetMetadata; + config: PresetConfigSection; + secrets?: { + // 敏感信息存储,格式:字段路径 -> 值 + // 例如:{ "Providers[0].api_key": "sk-xxx", "APIKEY": "my-secret" } + [fieldPath: string]: string; + }; + requiredInputs?: RequiredInput[]; +} + +// manifest.json 格式(压缩包内的文件) +export interface ManifestFile extends PresetMetadata, PresetConfigSection { + requiredInputs?: RequiredInput[]; +} + +// 在线预设索引条目 +export interface PresetIndexEntry { + id: string; // 唯一标识 + name: string; // 显示名称 + description?: string; // 简短描述 + version: string; // 最新版本 + author?: string; // 作者 + downloads?: number; // 下载次数 + stars?: number; // 点赞数 + tags?: string[]; // 标签 + url: string; // 下载地址 + checksum?: string; // SHA256 校验和 + ccrVersion?: string; // 兼容版本 +} + +// 在线预设仓库索引 +export interface PresetRegistry { + version: string; // 索引格式版本 + lastUpdated: string; // 最后更新时间 + presets: PresetIndexEntry[]; +} + +// 配置验证结果 +export interface ValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +// 合并策略枚举 +export enum MergeStrategy { + ASK = 'ask', // 交互式询问 + OVERWRITE = 'overwrite', // 覆盖现有 + MERGE = 'merge', // 智能合并 + SKIP = 'skip', // 跳过冲突项 +} + +// 脱敏结果 +export interface SanitizeResult { + sanitizedConfig: any; + requiredInputs: RequiredInput[]; + sanitizedCount: number; +} + +// Provider 冲突处理动作 +export type ProviderConflictAction = 'keep' | 'overwrite' | 'merge' | 'skip'; diff --git a/packages/ui/package.json b/packages/ui/package.json index 44299ad..fe350ca 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -32,6 +32,7 @@ "react-dom": "^19.1.0", "react-i18next": "^15.6.1", "react-router-dom": "^7.7.0", + "remixicon": "^4.7.0", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7" }, diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 356884b..e000e53 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -10,13 +10,14 @@ import { LogViewer } from "@/components/LogViewer"; import { Button } from "@/components/ui/button"; import { useConfig } from "@/components/ConfigProvider"; import { api } from "@/lib/api"; -import { Settings, Languages, Save, RefreshCw, FileJson, CircleArrowUp, FileText } from "lucide-react"; +import { Settings, Languages, Save, RefreshCw, FileJson, CircleArrowUp, FileText, FileCog } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Toast } from "@/components/ui/toast"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Dialog, DialogContent, @@ -270,19 +271,51 @@ function App() { } return ( -
+ +

{t('app.title')}

- - - + + + + + +

{t('app.settings')}

+
+
+ + + + + +

{t('app.json_editor')}

+
+
+ + + + + +

{t('app.log_viewer')}

+
+
+ + + + + +

{t('app.presets')}

+
+
+ + + + + +

{t('app.check_updates')}

+
+
)}
+ ); } diff --git a/packages/ui/src/components/Presets.tsx b/packages/ui/src/components/Presets.tsx new file mode 100644 index 0000000..2a29072 --- /dev/null +++ b/packages/ui/src/components/Presets.tsx @@ -0,0 +1,689 @@ +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api } from "@/lib/api"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Upload, Link, Trash2, Info, Download, CheckCircle2, AlertCircle, Loader2, ArrowLeft, Store, Search, Package } from "lucide-react"; +import { Toast } from "@/components/ui/toast"; + +interface PresetMetadata { + id: string; + name: string; + version: string; + description?: string; + author?: string; + homepage?: string; + repository?: string; + license?: string; + keywords?: string[]; + ccrVersion?: string; + source?: string; + sourceType?: 'local' | 'gist' | 'registry'; + checksum?: string; + installed: boolean; +} + +interface PresetDetail extends PresetMetadata { + config?: any; + requiredInputs?: Array<{ field: string; prompt?: string; placeholder?: string }>; +} + +interface MarketPreset { + id: string; + name: string; + version: string; + description?: string; + author?: string; + homepage?: string; + repository?: string; + license?: string; + keywords?: string[]; + downloadUrl: string; + downloads?: number; + rating?: number; +} + +export function Presets() { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [presets, setPresets] = useState([]); + const [loading, setLoading] = useState(true); + const [installDialogOpen, setInstallDialogOpen] = useState(false); + const [detailDialogOpen, setDetailDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [marketDialogOpen, setMarketDialogOpen] = useState(false); + const [selectedPreset, setSelectedPreset] = useState(null); + const [presetToDelete, setPresetToDelete] = useState(null); + const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null); + const [installMethod, setInstallMethod] = useState<'file' | 'url'>('file'); + const [installUrl, setInstallUrl] = useState(''); + const [installFile, setInstallFile] = useState(null); + const [installName, setInstallName] = useState(''); + const [isInstalling, setIsInstalling] = useState(false); + const [secrets, setSecrets] = useState>({}); + const [isApplying, setIsApplying] = useState(false); + const [marketSearch, setMarketSearch] = useState(''); + const [marketPresets, setMarketPresets] = useState([]); + const [marketLoading, setMarketLoading] = useState(false); + const [installingFromMarket, setInstallingFromMarket] = useState(null); + + // 返回上一页 + const handleGoBack = () => { + navigate('/dashboard'); + }; + + // 加载市场预设 + const loadMarketPresets = async () => { + setMarketLoading(true); + try { + // TODO: 替换为实际的市场 API + // const response = await api.getMarketPresets(); + // setMarketPresets(response.presets || []); + + // 模拟数据 + const mockMarketPresets: MarketPreset[] = [ + { + id: 'openai-compatible', + name: 'OpenAI Compatible', + version: '1.0.0', + description: 'Full-featured OpenAI API compatible preset with support for GPT-4, GPT-3.5, and more.', + author: 'CCR Community', + homepage: 'https://github.com/example/openai-preset', + repository: 'https://github.com/example/openai-preset', + license: 'MIT', + keywords: ['openai', 'gpt', 'chat'], + downloadUrl: 'https://example.com/openai.ccrsets', + downloads: 1234, + rating: 4.8 + }, + { + id: 'anthropic-optimized', + name: 'Anthropic Optimized', + version: '1.2.0', + description: 'Optimized configuration for Claude and other Anthropic models with enhanced token management.', + author: 'CCR Team', + homepage: 'https://github.com/example/anthropic-preset', + repository: 'https://github.com/example/anthropic-preset', + license: 'Apache-2.0', + keywords: ['anthropic', 'claude', 'ai'], + downloadUrl: 'https://example.com/anthropic.ccrsets', + downloads: 892, + rating: 4.9 + }, + { + id: 'multi-provider', + name: 'Multi-Provider Router', + version: '2.0.0', + description: 'Intelligent routing across multiple providers based on cost, speed, and capability.', + author: 'CCR Community', + homepage: 'https://github.com/example/multi-provider-preset', + repository: 'https://github.com/example/multi-provider-preset', + license: 'MIT', + keywords: ['router', 'multi-provider', 'optimization'], + downloadUrl: 'https://example.com/multi-provider.ccrsets', + downloads: 567, + rating: 4.6 + }, + { + id: 'development-tools', + name: 'Development Tools', + version: '1.1.0', + description: 'Optimized for coding and development tasks with special focus on code generation and debugging.', + author: 'DevTeam', + homepage: 'https://github.com/example/dev-tools-preset', + repository: 'https://github.com/example/dev-tools-preset', + license: 'MIT', + keywords: ['development', 'coding', 'programming'], + downloadUrl: 'https://example.com/dev-tools.ccrsets', + downloads: 445, + rating: 4.7 + } + ]; + setMarketPresets(mockMarketPresets); + } catch (error) { + console.error('Failed to load market presets:', error); + setToast({ message: t('presets.load_market_failed'), type: 'error' }); + } finally { + setMarketLoading(false); + } + }; + + // 从市场安装预设 + const handleInstallFromMarket = async (preset: MarketPreset) => { + try { + setInstallingFromMarket(preset.id); + await api.installPresetFromUrl(preset.downloadUrl); + setToast({ message: t('presets.preset_installed'), type: 'success' }); + setMarketDialogOpen(false); + await loadPresets(); + } catch (error: any) { + console.error('Failed to install preset:', error); + setToast({ message: t('presets.preset_install_failed', { error: error.message }), type: 'error' }); + } finally { + setInstallingFromMarket(null); + } + }; + + // 打开市场对话框时加载预设 + useEffect(() => { + if (marketDialogOpen && marketPresets.length === 0) { + loadMarketPresets(); + } + }, [marketDialogOpen]); + + // 过滤市场预设 + const filteredMarketPresets = marketPresets.filter(preset => + preset.name.toLowerCase().includes(marketSearch.toLowerCase()) || + preset.description?.toLowerCase().includes(marketSearch.toLowerCase()) || + preset.author?.toLowerCase().includes(marketSearch.toLowerCase()) || + preset.keywords?.some(keyword => keyword.toLowerCase().includes(marketSearch.toLowerCase())) + ); + + // 加载预设列表 + const loadPresets = async () => { + try { + setLoading(true); + const response = await api.getPresets(); + setPresets(response.presets || []); + } catch (error) { + console.error('Failed to load presets:', error); + setToast({ message: t('presets.load_presets_failed'), type: 'error' }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + loadPresets(); + }, []); + + // 查看预设详情 + const handleViewDetail = async (preset: PresetMetadata) => { + try { + const detail = await api.getPreset(preset.id); + setSelectedPreset({ ...preset, ...detail }); + setDetailDialogOpen(true); + + // 初始化 secrets + if (detail.requiredInputs) { + const initialSecrets: Record = {}; + for (const input of detail.requiredInputs) { + initialSecrets[input.field] = ''; + } + setSecrets(initialSecrets); + } + } catch (error) { + console.error('Failed to load preset details:', error); + setToast({ message: t('presets.load_preset_details_failed'), type: 'error' }); + } + }; + + // 安装预设 + const handleInstall = async () => { + try { + setIsInstalling(true); + + if (installMethod === 'url' && installUrl) { + await api.installPresetFromUrl(installUrl, installName || undefined); + } else if (installMethod === 'file' && installFile) { + await api.uploadPresetFile(installFile, installName || undefined); + } else { + setToast({ message: t('presets.please_provide_file_or_url'), type: 'warning' }); + return; + } + + setToast({ message: t('presets.preset_installed'), type: 'success' }); + setInstallDialogOpen(false); + setInstallUrl(''); + setInstallFile(null); + setInstallName(''); + await loadPresets(); + } catch (error: any) { + console.error('Failed to install preset:', error); + setToast({ message: t('presets.preset_install_failed', { error: error.message }), type: 'error' }); + } finally { + setIsInstalling(false); + } + }; + + // 应用预设(配置敏感信息) + const handleApplyPreset = async () => { + try { + setIsApplying(true); + + // 验证所有必填项都已填写 + if (selectedPreset?.requiredInputs) { + for (const input of selectedPreset.requiredInputs) { + if (!secrets[input.field] || secrets[input.field].trim() === '') { + setToast({ message: t('presets.please_fill_field', { field: input.field }), type: 'warning' }); + return; + } + } + } + + await api.applyPreset(selectedPreset!.name, secrets); + setToast({ message: t('presets.preset_applied'), type: 'success' }); + setDetailDialogOpen(false); + setSecrets({}); + } catch (error: any) { + console.error('Failed to apply preset:', error); + setToast({ message: t('presets.preset_apply_failed', { error: error.message }), type: 'error' }); + } finally { + setIsApplying(false); + } + }; + + // 删除预设 + const handleDelete = async () => { + if (!presetToDelete) return; + + try { + await api.deletePreset(presetToDelete); + setToast({ message: t('presets.preset_deleted'), type: 'success' }); + setDeleteDialogOpen(false); + setPresetToDelete(null); + await loadPresets(); + } catch (error: any) { + console.error('Failed to delete preset:', error); + setToast({ message: t('presets.preset_delete_failed', { error: error.message }), type: 'error' }); + } + }; + + return ( + + + + {t('presets.title')} ({presets.length}) + + + + {loading ? ( +
+ +
+ ) : presets.length === 0 ? ( +
+ +

{t('presets.no_presets')}

+

{t('presets.no_presets_hint')}

+
+ ) : ( +
+ {presets.map((preset) => ( +
+
+
+

{preset.name}

+ v{preset.version} +
+ {preset.description && ( +

{preset.description}

+ )} + {preset.author && ( +

by {preset.author}

+ )} +
+
+ + +
+
+ ))} +
+ )} +
+ + {/* Install Dialog */} + + + + {t('presets.install_dialog_title')} + + {t('presets.install_dialog_description')} + + +
+
+ + +
+ + {installMethod === 'file' ? ( +
+ + setInstallFile(e.target.files?.[0] || null)} + /> +
+ ) : ( +
+ + setInstallUrl(e.target.value)} + /> +
+ )} + +
+ + setInstallName(e.target.value)} + /> +
+
+ + + + +
+
+ + {/* Detail Dialog */} + + + + + {selectedPreset?.name} + {selectedPreset?.version && ( + v{selectedPreset.version} + )} + + +
+ {selectedPreset?.description && ( +

{selectedPreset.description}

+ )} + + {selectedPreset?.author && ( +

+ Author: {selectedPreset.author} +

+ )} + + {selectedPreset?.homepage && ( +

+ Homepage: {selectedPreset.homepage} +

+ )} + + {selectedPreset?.repository && ( +

+ Repository: {selectedPreset.repository} +

+ )} + + {selectedPreset?.keywords && selectedPreset.keywords.length > 0 && ( +
+ Keywords: +
+ {selectedPreset.keywords.map((keyword) => ( + + {keyword} + + ))} +
+
+ )} + + {selectedPreset?.requiredInputs && selectedPreset.requiredInputs.length > 0 && ( +
+

{t('presets.required_information')}

+ {selectedPreset.requiredInputs.map((input) => ( +
+ + setSecrets({ ...secrets, [input.field]: e.target.value })} + /> +
+ ))} +
+ )} +
+ + + {selectedPreset?.requiredInputs && selectedPreset.requiredInputs.length > 0 && ( + + )} + +
+
+ + {/* Market Presets Dialog */} + + + + + + {t('presets.market_title')} + + + {t('presets.market_description')} + + + +
+
+ + setMarketSearch(e.target.value)} + className="pl-9" + /> +
+
+ +
+ {marketLoading ? ( +
+ +
+ ) : filteredMarketPresets.length === 0 ? ( +
+ +

{t('presets.no_presets_found')}

+

{t('presets.no_presets_found_hint')}

+
+ ) : ( +
+ {filteredMarketPresets.map((preset) => ( +
+
+
+
+

{preset.name}

+ v{preset.version} + {preset.rating && ( +
+ + {preset.rating} +
+ )} +
+ {preset.description && ( +

{preset.description}

+ )} +
+ {preset.author && ( +
+ {t('presets.by', { author: preset.author })} + {preset.repository && ( + + + + )} +
+ )} + {preset.downloads && ( + {t('presets.downloads', { count: preset.downloads })} + )} + {preset.license && ( + {preset.license} + )} +
+ {preset.keywords && preset.keywords.length > 0 && ( +
+ {preset.keywords.map((keyword) => ( + + {keyword} + + ))} +
+ )} +
+ +
+
+ ))} +
+ )} +
+
+
+ + {/* Delete Confirmation Dialog */} + + + + {t('presets.delete_dialog_title')} + + {t('presets.delete_dialog_description', { name: presetToDelete })} + + + + + + + + + + {toast && ( + setToast(null)} + /> + )} +
+ ); +} diff --git a/packages/ui/src/components/ui/tooltip.tsx b/packages/ui/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..6299e79 --- /dev/null +++ b/packages/ui/src/components/ui/tooltip.tsx @@ -0,0 +1,31 @@ +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/packages/ui/src/lib/api.ts b/packages/ui/src/lib/api.ts index b98e787..ed7985a 100644 --- a/packages/ui/src/lib/api.ts +++ b/packages/ui/src/lib/api.ts @@ -235,6 +235,73 @@ class ApiClient { async clearLogs(filePath: string): Promise { return this.delete(`/logs?file=${encodeURIComponent(filePath)}`); } + + // ========== Preset API methods ========== + + // Get presets list + async getPresets(): Promise<{ presets: Array }> { + return this.get<{ presets: Array }>('/presets'); + } + + // Get preset details + async getPreset(name: string): Promise { + return this.get(`/presets/${encodeURIComponent(name)}`); + } + + // Install preset from URL + async installPresetFromUrl(url: string, name?: string): Promise { + return this.post('/presets/install', { url, name }); + } + + // Upload preset file + async uploadPresetFile(file: File, name?: string): Promise { + const formData = new FormData(); + formData.append('file', file); + if (name) { + formData.append('name', name); + } + + const url = `${this.baseUrl}/presets/upload`; + + const headers: Record = { + 'Accept': 'application/json', + }; + + // Use temp API key if available, otherwise use regular API key + if (this.tempApiKey) { + headers['X-Temp-API-Key'] = this.tempApiKey; + } else if (this.apiKey) { + headers['X-API-Key'] = this.apiKey; + } + + const response = await fetch(url, { + method: 'POST', + headers, + body: formData, + }); + + if (response.status === 401) { + localStorage.removeItem('apiKey'); + window.dispatchEvent(new CustomEvent('unauthorized')); + return new Promise(() => {}) as any; + } + + if (!response.ok) { + throw new Error(`Failed to upload preset: ${response.status} ${response.statusText}`); + } + + return response.json(); + } + + // Apply preset (configure sensitive fields) + async applyPreset(name: string, secrets: Record): Promise { + return this.post(`/presets/${encodeURIComponent(name)}/apply`, { secrets }); + } + + // Delete preset + async deletePreset(name: string): Promise { + return this.delete(`/presets/${encodeURIComponent(name)}`); + } } // Create a default instance of the API client diff --git a/packages/ui/src/locales/en.json b/packages/ui/src/locales/en.json index f9d34cf..b09babf 100644 --- a/packages/ui/src/locales/en.json +++ b/packages/ui/src/locales/en.json @@ -25,7 +25,12 @@ "no_updates_available": "No updates available", "update_check_failed": "Failed to check for updates", "update_successful": "Update successful", - "update_failed": "Update failed" + "update_failed": "Update failed", + "json_editor": "JSON Editor", + "log_viewer": "Log Viewer", + "presets": "Presets", + "language": "Language", + "check_updates": "Check for Updates" }, "login": { "title": "Sign in to your account", @@ -233,5 +238,50 @@ "worker_init_failed": "Failed to initialize worker", "grouping_not_supported": "Log grouping not supported by server", "back": "Back" + }, + "presets": { + "title": "Presets", + "market_title": "Preset Market", + "market_description": "Browse and install presets from the community marketplace", + "no_presets": "No presets installed", + "no_presets_hint": "Install a preset to get started", + "search_placeholder": "Search presets by name, description, author, or keywords...", + "no_presets_found": "No presets found", + "no_presets_found_hint": "Try adjusting your search terms", + "loading": "Loading...", + "by": "by {{author}}", + "downloads": "{{count}} downloads", + "github_repository": "GitHub Repository", + "view_details": "View Details", + "install": "Install", + "installing": "Installing...", + "apply": "Apply Preset", + "applying": "Applying...", + "close": "Close", + "delete": "Delete", + "install_dialog_title": "Install Preset", + "install_dialog_description": "Install a preset from a file or URL", + "upload_file": "Upload File", + "from_url": "From URL", + "preset_file": "Preset File (.ccrsets)", + "preset_url": "Preset URL", + "preset_url_placeholder": "https://example.com/preset.ccrsets", + "preset_name": "Preset Name (Optional)", + "preset_name_placeholder": "Auto-generated from file", + "please_provide_file_or_url": "Please provide a file or URL", + "detail_dialog_title": "Preset Details", + "required_information": "Required Information", + "delete_dialog_title": "Delete Preset", + "delete_dialog_description": "Are you sure you want to delete preset \"{{name}}\"? This action cannot be undone.", + "preset_installed": "Preset installed successfully", + "preset_install_failed": "Failed to install preset: {{error}}", + "preset_applied": "Preset applied successfully", + "preset_apply_failed": "Failed to apply preset: {{error}}", + "preset_deleted": "Preset deleted successfully", + "preset_delete_failed": "Failed to delete preset: {{error}}", + "load_presets_failed": "Failed to load presets", + "load_preset_details_failed": "Failed to load preset details", + "please_fill_field": "Please fill in {{field}}", + "load_market_failed": "Failed to load market presets" } } diff --git a/packages/ui/src/locales/zh.json b/packages/ui/src/locales/zh.json index 568ba02..d8dd619 100644 --- a/packages/ui/src/locales/zh.json +++ b/packages/ui/src/locales/zh.json @@ -25,7 +25,12 @@ "no_updates_available": "当前已是最新版本", "update_check_failed": "检查更新失败", "update_successful": "更新成功", - "update_failed": "更新失败" + "update_failed": "更新失败", + "json_editor": "JSON 编辑器", + "log_viewer": "日志查看器", + "presets": "预设", + "language": "语言", + "check_updates": "检查更新" }, "login": { "title": "登录到您的账户", @@ -233,5 +238,50 @@ "worker_init_failed": "Worker初始化失败", "grouping_not_supported": "服务器不支持日志分组", "back": "返回" + }, + "presets": { + "title": "预设", + "market_title": "预设市场", + "market_description": "浏览并从社区市场安装预设", + "no_presets": "暂无已安装的预设", + "no_presets_hint": "安装一个预设以开始使用", + "search_placeholder": "按名称、描述、作者或关键词搜索预设...", + "no_presets_found": "未找到预设", + "no_presets_found_hint": "请尝试调整搜索条件", + "loading": "加载中...", + "by": "{{author}} 创作", + "downloads": "{{count}} 次下载", + "github_repository": "GitHub 仓库", + "view_details": "查看详情", + "install": "安装", + "installing": "安装中...", + "apply": "应用预设", + "applying": "应用中...", + "close": "关闭", + "delete": "删除", + "install_dialog_title": "安装预设", + "install_dialog_description": "从文件或URL安装预设", + "upload_file": "上传文件", + "from_url": "从 URL", + "preset_file": "预设文件 (.ccrsets)", + "preset_url": "预设 URL", + "preset_url_placeholder": "https://example.com/preset.ccrsets", + "preset_name": "预设名称 (可选)", + "preset_name_placeholder": "从文件自动生成", + "please_provide_file_or_url": "请提供文件或 URL", + "detail_dialog_title": "预设详情", + "required_information": "必需信息", + "delete_dialog_title": "删除预设", + "delete_dialog_description": "您确定要删除预设 \"{{name}}\" 吗?此操作无法撤销。", + "preset_installed": "预设安装成功", + "preset_install_failed": "预设安装失败:{{error}}", + "preset_applied": "预设应用成功", + "preset_apply_failed": "预设应用失败:{{error}}", + "preset_deleted": "预设删除成功", + "preset_delete_failed": "预设删除失败:{{error}}", + "load_presets_failed": "加载预设失败", + "load_preset_details_failed": "加载预设详情失败", + "please_fill_field": "请填写 {{field}}", + "load_market_failed": "加载市场预设失败" } } diff --git a/packages/ui/src/main.tsx b/packages/ui/src/main.tsx index 1ed2501..137244b 100644 --- a/packages/ui/src/main.tsx +++ b/packages/ui/src/main.tsx @@ -2,6 +2,7 @@ import './i18n'; import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' +import 'remixicon/fonts/remixicon.css' import { RouterProvider } from 'react-router-dom'; import { router } from './routes'; import { ConfigProvider } from '@/components/ConfigProvider'; diff --git a/packages/ui/src/routes.tsx b/packages/ui/src/routes.tsx index f5fcd69..2d06e2b 100644 --- a/packages/ui/src/routes.tsx +++ b/packages/ui/src/routes.tsx @@ -2,6 +2,7 @@ import { createMemoryRouter, Navigate } from 'react-router-dom'; import App from './App'; import { Login } from '@/components/Login'; import { DebugPage } from '@/components/DebugPage'; +import { Presets } from '@/components/Presets'; import ProtectedRoute from '@/components/ProtectedRoute'; import PublicRoute from '@/components/PublicRoute'; @@ -18,6 +19,10 @@ export const router = createMemoryRouter([ path: '/dashboard', element: , }, + { + path: '/presets', + element: , + }, { path: '/debug', element: , diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d9a328..5689f02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,12 @@ importers: '@inquirer/prompts': specifier: ^5.0.0 version: 5.5.0 + adm-zip: + specifier: ^0.5.16 + version: 0.5.16 + archiver: + specifier: ^7.0.1 + version: 7.0.1 find-process: specifier: ^2.0.0 version: 2.0.0 @@ -85,6 +91,9 @@ importers: specifier: ^1.1.1 version: 1.1.1 devDependencies: + '@types/archiver': + specifier: ^7.0.0 + version: 7.0.0 '@types/node': specifier: ^24.0.15 version: 24.7.0 @@ -100,12 +109,18 @@ importers: packages/server: dependencies: + '@fastify/multipart': + specifier: ^9.0.0 + version: 9.3.0 '@fastify/static': specifier: ^8.2.0 version: 8.2.0 '@musistudio/llms': specifier: ^1.0.51 version: 1.0.51(ws@8.18.3) + adm-zip: + specifier: ^0.5.16 + version: 0.5.16 dotenv: specifier: ^16.4.7 version: 16.6.1 @@ -131,6 +146,9 @@ importers: '@CCR/shared': specifier: workspace:* version: link:../shared + '@types/adm-zip': + specifier: ^0.5.7 + version: 0.5.7 '@types/node': specifier: ^24.0.15 version: 24.7.0 @@ -148,7 +166,23 @@ importers: version: 5.8.3 packages/shared: + dependencies: + adm-zip: + specifier: ^0.5.16 + version: 0.5.16 + archiver: + specifier: ^7.0.1 + version: 7.0.1 + json5: + specifier: ^2.2.3 + version: 2.2.3 devDependencies: + '@types/adm-zip': + specifier: ^0.5.7 + version: 0.5.7 + '@types/archiver': + specifier: ^7.0.0 + version: 7.0.0 '@types/node': specifier: ^24.0.15 version: 24.7.0 @@ -227,6 +261,9 @@ importers: react-router-dom: specifier: ^7.7.0 version: 7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + remixicon: + specifier: ^4.7.0 + version: 4.7.0 tailwind-merge: specifier: ^3.3.1 version: 3.4.0 @@ -1837,9 +1874,15 @@ packages: '@fastify/ajv-compiler@4.0.2': resolution: {integrity: sha512-Rkiu/8wIjpsf46Rr+Fitd3HRP+VsxUFDDeag0hs9L0ksfnwx2g7SPQQTFL0E8Qv+rfXzQOxBJnjUB9ITUDjfWQ==} + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + '@fastify/cors@11.1.0': resolution: {integrity: sha512-sUw8ed8wP2SouWZTIbA7V2OQtMNpLj2W6qJOYhNdcmINTu6gsxVYXjQiM9mdi8UUDlcoDDJ/W2syPo1WB2QjYA==} + '@fastify/deepmerge@3.1.0': + resolution: {integrity: sha512-lCVONBQINyNhM6LLezB6+2afusgEYR4G8xenMsfe+AT+iZ7Ca6upM5Ha8UkZuYSnuMw3GWl/BiPXnLMi/gSxuQ==} + '@fastify/error@4.2.0': resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} @@ -1852,6 +1895,9 @@ packages: '@fastify/merge-json-schemas@0.2.1': resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + '@fastify/multipart@9.3.0': + resolution: {integrity: sha512-NpeKipTOjjL1dA7SSlRMrOWWtrE8/0yKOmeudkdQoEaz4sVDJw5MVdZIahsWhvpc3YTN7f04f9ep/Y65RKoOWA==} + '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} @@ -2090,6 +2136,10 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@pnpm/config.env-replace@1.1.0': resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} engines: {node: '>=12.22.0'} @@ -2812,6 +2862,12 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@types/adm-zip@0.5.7': + resolution: {integrity: sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==} + + '@types/archiver@7.0.0': + resolution: {integrity: sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2951,6 +3007,9 @@ packages: '@types/react@18.3.27': resolution: {integrity: sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==} + '@types/readdir-glob@1.1.5': + resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} + '@types/retry@0.12.2': resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==} @@ -3116,6 +3175,10 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + abstract-logging@2.0.1: resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} @@ -3147,6 +3210,10 @@ packages: resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==} engines: {node: '>= 10.0.0'} + adm-zip@0.5.16: + resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} + engines: {node: '>=12.0'} + agent-base@7.1.4: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} @@ -3237,6 +3304,14 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} + arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -3264,6 +3339,9 @@ packages: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -3278,6 +3356,14 @@ packages: avvio@9.1.0: resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} + b4a@1.7.3: + resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + babel-loader@9.2.1: resolution: {integrity: sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==} engines: {node: '>= 14.15.0'} @@ -3309,6 +3395,14 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -3362,12 +3456,19 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -3564,6 +3665,10 @@ packages: common-path-prefix@3.0.0: resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==} + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} + compressible@2.0.18: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} @@ -3643,6 +3748,15 @@ packages: typescript: optional: true + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} @@ -4157,9 +4271,16 @@ packages: resolution: {integrity: sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==} engines: {node: '>= 0.8'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -4197,6 +4318,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -4413,6 +4537,10 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + hasBin: true + glob@11.0.3: resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} engines: {node: 20 || >=22} @@ -4655,6 +4783,9 @@ packages: peerDependencies: postcss: ^8.1.0 + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -4857,6 +4988,9 @@ packages: resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} engines: {node: '>=0.10.0'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.1.1: resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} engines: {node: 20 || >=22} @@ -4960,6 +5094,10 @@ packages: launch-editor@2.12.0: resolution: {integrity: sha512-giOHXoOtifjdHqUamwKq6c49GzBdLjvxrd2D+Q4V6uOHopJv7p9VJxikDsQ/CBXZbEITgUqSVHXLTG3VhPP1Dg==} + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -5097,6 +5235,9 @@ packages: resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.2: resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} engines: {node: 20 || >=22} @@ -5409,6 +5550,10 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} @@ -5702,6 +5847,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.0: resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} engines: {node: 20 || >=22} @@ -6208,6 +6357,10 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -6423,6 +6576,13 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -6513,6 +6673,9 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + remixicon@4.7.0: + resolution: {integrity: sha512-g2pHOofQWARWpxdbrQu5+K3C8ZbqguQFzE54HIMWFCpFa63pumaAltIgZmFMRQpKKBScRWQASQfWxS9asNCcHQ==} + renderkid@3.0.0: resolution: {integrity: sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==} @@ -6840,6 +7003,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + streamx@2.23.0: + resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -6951,6 +7117,9 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + terser-webpack-plugin@5.3.16: resolution: {integrity: sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==} engines: {node: '>= 10.13.0'} @@ -6972,6 +7141,9 @@ packages: engines: {node: '>=10'} hasBin: true + text-decoder@1.2.3: + resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -7499,6 +7671,10 @@ packages: resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} engines: {node: '>=18'} + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} + zod@4.2.1: resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} @@ -9702,11 +9878,15 @@ snapshots: ajv-formats: 3.0.1(ajv@8.17.1) fast-uri: 3.1.0 + '@fastify/busboy@3.2.0': {} + '@fastify/cors@11.1.0': dependencies: fastify-plugin: 5.1.0 toad-cache: 3.7.0 + '@fastify/deepmerge@3.1.0': {} + '@fastify/error@4.2.0': {} '@fastify/fast-json-stringify-compiler@5.0.3': @@ -9719,6 +9899,14 @@ snapshots: dependencies: dequal: 2.0.3 + '@fastify/multipart@9.3.0': + dependencies: + '@fastify/busboy': 3.2.0 + '@fastify/deepmerge': 3.1.0 + '@fastify/error': 4.2.0 + fastify-plugin: 5.1.0 + secure-json-parse: 4.1.0 + '@fastify/proxy-addr@5.1.0': dependencies: '@fastify/forwarded': 3.0.1 @@ -10065,6 +10253,9 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@pkgjs/parseargs@0.11.0': + optional: true + '@pnpm/config.env-replace@1.1.0': {} '@pnpm/network.ca-file@1.0.2': @@ -10703,6 +10894,14 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@types/adm-zip@0.5.7': + dependencies: + '@types/node': 24.7.0 + + '@types/archiver@7.0.0': + dependencies: + '@types/readdir-glob': 1.1.5 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 @@ -10872,6 +11071,10 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/readdir-glob@1.1.5': + dependencies: + '@types/node': 24.7.0 + '@types/retry@0.12.2': {} '@types/sax@1.2.7': @@ -11107,6 +11310,10 @@ snapshots: '@xtuc/long@4.2.2': {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + abstract-logging@2.0.1: {} accepts@1.3.8: @@ -11130,6 +11337,8 @@ snapshots: address@1.2.2: {} + adm-zip@0.5.16: {} + agent-base@7.1.4: {} aggregate-error@3.1.0: @@ -11225,6 +11434,29 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + archiver-utils@5.0.2: + dependencies: + glob: 10.5.0 + graceful-fs: 4.2.11 + is-stream: 2.0.1 + lazystream: 1.0.1 + lodash: 4.17.21 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.6 + buffer-crc32: 1.0.0 + readable-stream: 4.7.0 + readdir-glob: 1.1.3 + tar-stream: 3.1.7 + zip-stream: 6.0.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + arg@4.1.3: {} arg@5.0.2: {} @@ -11245,6 +11477,8 @@ snapshots: astring@1.9.0: {} + async@3.2.6: {} + atomic-sleep@1.0.0: {} autoprefixer@10.4.23(postcss@8.5.6): @@ -11261,6 +11495,8 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.19.1 + b4a@1.7.3: {} + babel-loader@9.2.1(@babel/core@7.28.5)(webpack@5.104.1(esbuild@0.25.10)): dependencies: '@babel/core': 7.28.5 @@ -11300,6 +11536,8 @@ snapshots: balanced-match@1.0.2: {} + bare-events@2.8.2: {} + base64-js@1.5.1: {} baseline-browser-mapping@2.9.11: {} @@ -11379,10 +11617,17 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer-crc32@1.0.0: {} + buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -11570,6 +11815,14 @@ snapshots: common-path-prefix@3.0.0: {} + compress-commons@6.0.2: + dependencies: + crc-32: 1.2.2 + crc32-stream: 6.0.0 + is-stream: 2.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + compressible@2.0.18: dependencies: mime-db: 1.54.0 @@ -11650,6 +11903,13 @@ snapshots: optionalDependencies: typescript: 5.9.3 + crc-32@1.2.2: {} + + crc32-stream@6.0.0: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + create-require@1.1.1: {} cross-spawn@6.0.6: @@ -12232,8 +12492,16 @@ snapshots: '@types/node': 24.7.0 require-like: 0.1.2 + event-target-shim@5.0.1: {} + eventemitter3@4.0.7: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + events@3.3.0: {} eventsource-parser@3.0.6: {} @@ -12312,6 +12580,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -12564,6 +12834,15 @@ snapshots: glob-to-regexp@0.4.1: {} + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@11.0.3: dependencies: foreground-child: 3.3.1 @@ -12941,6 +13220,8 @@ snapshots: dependencies: postcss: 8.5.6 + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -13074,6 +13355,12 @@ snapshots: isobject@3.0.1: {} + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jackspeak@4.1.1: dependencies: '@isaacs/cliui': 8.0.2 @@ -13183,6 +13470,10 @@ snapshots: picocolors: 1.1.1 shell-quote: 1.8.3 + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + leven@3.1.0: {} levn@0.4.1: @@ -13289,6 +13580,8 @@ snapshots: lowercase-keys@3.0.0: {} + lru-cache@10.4.3: {} + lru-cache@11.2.2: {} lru-cache@5.1.1: @@ -13874,6 +14167,10 @@ snapshots: dependencies: brace-expansion: 1.1.12 + minimatch@5.1.6: + dependencies: + brace-expansion: 2.0.2 + minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 @@ -14141,6 +14438,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + path-scurry@2.0.0: dependencies: lru-cache: 11.2.2 @@ -14676,6 +14978,8 @@ snapshots: process-warning@5.0.0: {} + process@0.11.10: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -14894,6 +15198,18 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.6 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -15049,6 +15365,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + remixicon@4.7.0: {} + renderkid@3.0.0: dependencies: css-select: 4.3.0 @@ -15417,6 +15735,15 @@ snapshots: std-env@3.10.0: {} + streamx@2.23.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.3 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -15556,6 +15883,15 @@ snapshots: tapable@2.3.0: {} + tar-stream@3.1.7: + dependencies: + b4a: 1.7.3 + fast-fifo: 1.3.2 + streamx: 2.23.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + terser-webpack-plugin@5.3.16(esbuild@0.25.10)(webpack@5.104.1(esbuild@0.25.10)): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -15574,6 +15910,12 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + text-decoder@1.2.3: + dependencies: + b4a: 1.7.3 + transitivePeerDependencies: + - react-native-b4a + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -16090,6 +16432,12 @@ snapshots: yoctocolors-cjs@2.1.3: {} + zip-stream@6.0.1: + dependencies: + archiver-utils: 5.0.2 + compress-commons: 6.0.2 + readable-stream: 4.7.0 + zod@4.2.1: {} zwitch@2.0.4: {}