From 02c81743a09cc54447f953e5b4d4f95ad85f1ad9 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 5 Mar 2026 18:29:50 +0700 Subject: [PATCH] feat: add API profile lifecycle discover/copy/export/import parity --- README.md | 4 + src/api/services/index.ts | 19 + src/api/services/profile-lifecycle-service.ts | 380 ++++++++++++++++++ .../services/profile-lifecycle-validation.ts | 150 +++++++ src/api/services/profile-types.ts | 73 ++++ src/commands/api-command.ts | 309 ++++++++++++++ src/commands/help-command.ts | 4 + src/web-server/routes/profile-routes.ts | 210 +++++++++- .../api/profile-lifecycle-service.test.ts | 117 ++++++ .../profile-routes-lifecycle.test.ts | 110 +++++ ui/src/hooks/use-profiles.ts | 74 +++- ui/src/lib/api-client.ts | 91 +++++ ui/src/pages/api.tsx | 222 ++++++++-- 13 files changed, 1732 insertions(+), 31 deletions(-) create mode 100644 src/api/services/profile-lifecycle-service.ts create mode 100644 src/api/services/profile-lifecycle-validation.ts create mode 100644 tests/unit/api/profile-lifecycle-service.test.ts create mode 100644 tests/unit/web-server/profile-routes-lifecycle.test.ts diff --git a/README.md b/README.md index 8eecda7c..1b36e316 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,10 @@ ccs ollama # Local Ollama (no API key needed) ccs glm # GLM (API key) ccs km # Kimi API profile (API key) ccs api create --preset alibaba-coding-plan # Alibaba Coding Plan profile +ccs api discover --register # Auto-register orphan *.settings.json +ccs api copy glm glm-backup # Duplicate profile config + settings +ccs api export glm --out ./glm.ccs-profile.json # Export for cross-device transfer +ccs api import ./glm.ccs-profile.json # Import exported profile bundle ``` ### Droid Alias (`argv[0]` pattern) diff --git a/src/api/services/index.ts b/src/api/services/index.ts index 57cceba4..ae519d48 100644 --- a/src/api/services/index.ts +++ b/src/api/services/index.ts @@ -16,6 +16,15 @@ export { type CreateApiProfileResult, type RemoveApiProfileResult, type UpdateApiProfileTargetResult, + type ProfileValidationIssue, + type ProfileValidationSummary, + type ApiProfileOrphanCandidate, + type DiscoverApiProfileOrphansResult, + type RegisterApiProfileOrphansResult, + type CopyApiProfileResult, + type ApiProfileExportBundle, + type ExportApiProfileResult, + type ImportApiProfileResult, } from './profile-types'; // Profile read operations @@ -30,6 +39,16 @@ export { // Profile write operations export { createApiProfile, removeApiProfile, updateApiProfileTarget } from './profile-writer'; +// Lifecycle validation and operations +export { validateApiProfileSettingsPayload } from './profile-lifecycle-validation'; +export { + discoverApiProfileOrphans, + registerApiProfileOrphans, + copyApiProfile, + exportApiProfile, + importApiProfileBundle, +} from './profile-lifecycle-service'; + // OpenRouter catalog and picker export { isOpenRouterUrl, fetchOpenRouterModels, type OpenRouterModel } from './openrouter-catalog'; export { pickOpenRouterModel, type OpenRouterSelection } from './openrouter-picker'; diff --git a/src/api/services/profile-lifecycle-service.ts b/src/api/services/profile-lifecycle-service.ts new file mode 100644 index 00000000..f7e9e3fe --- /dev/null +++ b/src/api/services/profile-lifecycle-service.ts @@ -0,0 +1,380 @@ +/** + * API profile lifecycle service. + * + * Discovery, registration, copy, export, and import for API profiles. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { Config, Settings } from '../../types'; +import type { TargetType } from '../../targets/target-adapter'; +import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager'; +import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector'; +import { isSensitiveKey } from '../../utils/sensitive-keys'; +import { isReservedName } from '../../config/reserved-names'; +import { + isUnifiedMode, + loadOrCreateUnifiedConfig, + saveUnifiedConfig, +} from '../../config/unified-config-loader'; +import { validateApiName } from './validation-service'; +import { listApiProfiles } from './profile-reader'; +import { validateApiProfileSettingsPayload } from './profile-lifecycle-validation'; +import type { + ApiProfileExportBundle, + CopyApiProfileResult, + DiscoverApiProfileOrphansResult, + ExportApiProfileResult, + ImportApiProfileResult, + RegisterApiProfileOrphansResult, +} from './profile-types'; + +const SETTINGS_FILE_SUFFIX = '.settings.json'; +const REDACTED_TOKEN_SENTINEL = '__CCS_REDACTED__'; + +function validateProfileNameForPath(name: string, label: string): string | null { + const validationError = validateApiName(name); + if (validationError) { + return `Invalid ${label} profile name "${name}": ${validationError}`; + } + return null; +} + +function getProfileSettingsPath(name: string): string { + return path.join(getCcsDir(), `${name}${SETTINGS_FILE_SUFFIX}`); +} + +function writeJsonObjectAtomically(filePath: string, value: unknown): void { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const tempPath = `${filePath}.tmp`; + fs.writeFileSync(tempPath, JSON.stringify(value, null, 2) + '\n', 'utf8'); + fs.renameSync(tempPath, filePath); +} + +function registerApiProfileInConfig(name: string, target: TargetType, force = false): void { + if (isUnifiedMode()) { + const config = loadOrCreateUnifiedConfig(); + if (config.profiles[name] && !force) { + throw new Error(`API profile already exists: ${name}`); + } + + config.profiles[name] = { + type: 'api', + settings: `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`, + ...(target !== 'claude' && { target }), + }; + saveUnifiedConfig(config); + return; + } + + const configPath = getConfigPath(); + const config = loadConfigSafe() as Config; + if (config.profiles[name] && !force) { + throw new Error(`API profile already exists: ${name}`); + } + + config.profiles[name] = `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`; + config.profile_targets = config.profile_targets || {}; + if (target === 'claude') { + delete config.profile_targets[name]; + } else { + config.profile_targets[name] = target; + } + + writeJsonObjectAtomically(configPath, config); +} + +function getRegisteredSettingsFileNames(): Set { + const { profiles, variants } = listApiProfiles(); + const names = new Set(); + + for (const profile of profiles) { + names.add(`${profile.name}${SETTINGS_FILE_SUFFIX}`); + } + + for (const variant of variants) { + if (!variant.settings || variant.settings === '-') continue; + names.add(path.basename(variant.settings.replace(/^~\/\.ccs\//, ''))); + } + + return names; +} + +function getProfileTarget(name: string): TargetType { + const { profiles } = listApiProfiles(); + return profiles.find((profile) => profile.name === name)?.target || 'claude'; +} + +function readJsonObject(filePath: string): Record { + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Settings file must contain a JSON object.'); + } + return parsed as Record; +} + +function rollbackSettingsFile( + filePath: string, + previousContent: string | null, + existedBefore: boolean +): void { + if (existedBefore && previousContent !== null) { + fs.writeFileSync(filePath, previousContent, 'utf8'); + return; + } + + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } +} + +export function discoverApiProfileOrphans(): DiscoverApiProfileOrphansResult { + const ccsDir = getCcsDir(); + if (!fs.existsSync(ccsDir)) { + return { orphans: [] }; + } + + const registeredSettings = getRegisteredSettingsFileNames(); + const files = fs.readdirSync(ccsDir).filter((file) => file.endsWith(SETTINGS_FILE_SUFFIX)); + const ignoredNames = new Set(['cursor.settings.json']); + + const orphans = files + .filter((file) => !registeredSettings.has(file)) + .filter((file) => !file.startsWith('base-')) + .filter((file) => !ignoredNames.has(file)) + .filter((file) => !isReservedName(file.slice(0, -SETTINGS_FILE_SUFFIX.length))) + .map((file) => { + const name = file.slice(0, -SETTINGS_FILE_SUFFIX.length); + const settingsPath = path.join(ccsDir, file); + + try { + const settings = readJsonObject(settingsPath); + return { + name, + settingsPath, + validation: validateApiProfileSettingsPayload(settings), + }; + } catch (error) { + return { + name, + settingsPath, + validation: { + valid: false, + issues: [ + { + level: 'error' as const, + code: 'invalid_json', + message: (error as Error).message, + field: 'settings', + hint: 'Fix JSON syntax before registration.', + }, + ], + }, + }; + } + }); + + return { orphans }; +} + +export function registerApiProfileOrphans(options?: { + names?: string[]; + target?: TargetType; + force?: boolean; +}): RegisterApiProfileOrphansResult { + const discovered = discoverApiProfileOrphans(); + const selected = + options?.names === undefined + ? discovered.orphans + : discovered.orphans.filter((orphan) => options.names?.includes(orphan.name)); + + const result: RegisterApiProfileOrphansResult = { registered: [], skipped: [] }; + for (const orphan of selected) { + if (!options?.force && !orphan.validation.valid) { + result.skipped.push({ + name: orphan.name, + reason: 'Validation failed. Use --force to register.', + }); + continue; + } + + try { + registerApiProfileInConfig(orphan.name, options?.target || 'claude', options?.force || false); + ensureProfileHooks(orphan.name); + result.registered.push(orphan.name); + } catch (error) { + result.skipped.push({ name: orphan.name, reason: (error as Error).message }); + } + } + + return result; +} + +export function copyApiProfile( + source: string, + destination: string, + options?: { target?: TargetType; force?: boolean } +): CopyApiProfileResult { + const sourceError = validateProfileNameForPath(source, 'source'); + if (sourceError) return { success: false, error: sourceError }; + + const destinationError = validateApiName(destination); + if (destinationError) return { success: false, error: destinationError }; + + const sourceSettingsPath = getProfileSettingsPath(source); + if (!fs.existsSync(sourceSettingsPath)) { + return { success: false, error: `Source profile settings not found: ${source}` }; + } + + const destinationSettingsPath = getProfileSettingsPath(destination); + if (fs.existsSync(destinationSettingsPath) && !options?.force) { + return { success: false, error: `Destination settings already exist: ${destination}` }; + } + + try { + const sourceSettings = readJsonObject(sourceSettingsPath) as Settings; + const validation = validateApiProfileSettingsPayload(sourceSettings); + if (!validation.valid && !options?.force) { + return { + success: false, + error: 'Source profile has validation errors. Use --force to copy.', + }; + } + + const destinationExisted = fs.existsSync(destinationSettingsPath); + const previousDestinationContent = destinationExisted + ? fs.readFileSync(destinationSettingsPath, 'utf8') + : null; + + writeJsonObjectAtomically(destinationSettingsPath, sourceSettings); + ensureProfileHooks(destination); + try { + registerApiProfileInConfig( + destination, + options?.target || getProfileTarget(source), + options?.force + ); + } catch (registrationError) { + rollbackSettingsFile(destinationSettingsPath, previousDestinationContent, destinationExisted); + throw registrationError; + } + + return { + success: true, + name: destination, + settingsPath: destinationSettingsPath, + warnings: validation.issues + .filter((issue) => issue.level === 'warning') + .map((issue) => issue.message), + }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } +} + +export function exportApiProfile(name: string, includeSecrets = false): ExportApiProfileResult { + const nameError = validateProfileNameForPath(name, 'profile'); + if (nameError) return { success: false, error: nameError }; + + const settingsPath = getProfileSettingsPath(name); + if (!fs.existsSync(settingsPath)) { + return { success: false, error: `Profile settings not found: ${name}` }; + } + + try { + const settings = readJsonObject(settingsPath); + let redacted = false; + if (!includeSecrets) { + const env = settings.env; + if (typeof env === 'object' && env !== null) { + for (const [key, value] of Object.entries(env as Record)) { + if (!isSensitiveKey(key) || typeof value !== 'string') continue; + (env as Record)[key] = REDACTED_TOKEN_SENTINEL; + redacted = true; + } + } + } + + const bundle: ApiProfileExportBundle = { + schemaVersion: 1, + exportedAt: new Date().toISOString(), + profile: { + name, + target: getProfileTarget(name), + }, + settings, + }; + + return { success: true, bundle, redacted }; + } catch (error) { + return { success: false, error: (error as Error).message }; + } +} + +export function importApiProfileBundle( + bundle: unknown, + options?: { name?: string; target?: TargetType; force?: boolean } +): ImportApiProfileResult { + if (typeof bundle !== 'object' || bundle === null || Array.isArray(bundle)) { + return { success: false, error: 'Import bundle must be a JSON object.' }; + } + + const input = bundle as Partial; + if (input.schemaVersion !== 1 || !input.profile || !input.settings) { + return { + success: false, + error: 'Invalid bundle schema. Expected schemaVersion=1 with profile and settings.', + }; + } + + const name = options?.name || input.profile.name; + const nameError = validateApiName(name); + if (nameError) return { success: false, error: nameError }; + + const settings = JSON.parse(JSON.stringify(input.settings)) as Record; + const env = settings.env as Record | undefined; + const warnings: string[] = []; + if (env?.ANTHROPIC_AUTH_TOKEN === REDACTED_TOKEN_SENTINEL) { + env.ANTHROPIC_AUTH_TOKEN = ''; + warnings.push('Imported bundle had redacted token. Set ANTHROPIC_AUTH_TOKEN before use.'); + } + + const validation = validateApiProfileSettingsPayload(settings); + if (!validation.valid && !options?.force) { + return { success: false, error: 'Import validation failed.', validation }; + } + + const settingsPath = getProfileSettingsPath(name); + try { + const settingsExisted = fs.existsSync(settingsPath); + const previousSettingsContent = settingsExisted ? fs.readFileSync(settingsPath, 'utf8') : null; + + writeJsonObjectAtomically(settingsPath, settings); + ensureProfileHooks(name); + try { + registerApiProfileInConfig( + name, + options?.target || input.profile.target || 'claude', + options?.force + ); + } catch (registrationError) { + rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted); + throw registrationError; + } + + warnings.push( + ...validation.issues + .filter((issue) => issue.level === 'warning') + .map((issue) => issue.message) + ); + + return { success: true, name, warnings, validation }; + } catch (error) { + return { success: false, error: (error as Error).message, validation }; + } +} diff --git a/src/api/services/profile-lifecycle-validation.ts b/src/api/services/profile-lifecycle-validation.ts new file mode 100644 index 00000000..4ae697dc --- /dev/null +++ b/src/api/services/profile-lifecycle-validation.ts @@ -0,0 +1,150 @@ +/** + * Profile lifecycle validation helpers. + * + * Shared by orphan discovery, copy, export/import, and dashboard routes. + */ + +import { + extractProviderFromPathname, + getDeniedModelIdReasonForProvider, +} from '../../cliproxy/model-id-normalizer'; +import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; +import type { CLIProxyProvider } from '../../cliproxy/types'; +import type { ProfileValidationIssue, ProfileValidationSummary } from './profile-types'; + +const MODEL_ENV_KEYS = [ + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', +] as const; + +const ALLOWED_ANTHROPIC_ENV_KEYS = new Set([ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_API_KEY', + ...MODEL_ENV_KEYS, +]); + +function resolveProviderFromBaseUrl(baseUrl: string): CLIProxyProvider | null { + if (!baseUrl.trim()) return null; + + try { + const parsed = new URL(baseUrl); + const extracted = extractProviderFromPathname(parsed.pathname); + return extracted ? mapExternalProviderName(extracted) : null; + } catch { + const extracted = extractProviderFromPathname(baseUrl); + return extracted ? mapExternalProviderName(extracted) : null; + } +} + +function pushIssue( + issues: ProfileValidationIssue[], + level: ProfileValidationIssue['level'], + code: string, + message: string, + field?: string, + hint?: string +): void { + issues.push({ level, code, message, field, hint }); +} + +function asObject(value: unknown): Record | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return null; + } + return value as Record; +} + +/** + * Validate an API profile settings payload and return actionable diagnostics. + */ +export function validateApiProfileSettingsPayload(settings: unknown): ProfileValidationSummary { + const issues: ProfileValidationIssue[] = []; + const settingsObj = asObject(settings); + + if (!settingsObj) { + pushIssue( + issues, + 'error', + 'invalid_settings_type', + 'Settings payload must be a JSON object.', + 'settings', + 'Expected object like { "env": { ... } }' + ); + return { valid: false, issues }; + } + + const envObj = asObject(settingsObj.env); + if (!envObj) { + pushIssue( + issues, + 'error', + 'missing_env_object', + 'settings.env must be a JSON object.', + 'settings.env', + 'Add env keys such as ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN.' + ); + return { valid: false, issues }; + } + + const baseUrl = + typeof envObj.ANTHROPIC_BASE_URL === 'string' ? envObj.ANTHROPIC_BASE_URL.trim() : ''; + if (!baseUrl) { + pushIssue( + issues, + 'error', + 'missing_base_url', + 'ANTHROPIC_BASE_URL is required.', + 'env.ANTHROPIC_BASE_URL', + 'Example: https://api.openai.com/v1 or provider-specific endpoint.' + ); + } + + const authToken = + typeof envObj.ANTHROPIC_AUTH_TOKEN === 'string' ? envObj.ANTHROPIC_AUTH_TOKEN.trim() : ''; + if (!authToken) { + pushIssue( + issues, + 'warning', + 'missing_auth_token', + 'ANTHROPIC_AUTH_TOKEN is empty; profile may not run until token is configured.', + 'env.ANTHROPIC_AUTH_TOKEN', + 'Set token after import if exported in redacted mode.' + ); + } + + const provider = resolveProviderFromBaseUrl(baseUrl); + for (const modelKey of MODEL_ENV_KEYS) { + const value = envObj[modelKey]; + if (typeof value !== 'string' || value.trim().length === 0) continue; + const denyReason = getDeniedModelIdReasonForProvider(value, provider); + if (denyReason) { + pushIssue( + issues, + 'error', + 'model_denylisted', + `${modelKey}: ${denyReason}`, + `env.${modelKey}`, + 'Choose a supported model for the provider endpoint.' + ); + } + } + + for (const key of Object.keys(envObj)) { + if (key.startsWith('ANTHROPIC_') && !ALLOWED_ANTHROPIC_ENV_KEYS.has(key)) { + pushIssue( + issues, + 'warning', + 'unknown_anthropic_env_key', + `Unknown ANTHROPIC env key: ${key}`, + `env.${key}`, + 'Check for typos or provider-unsupported settings.' + ); + } + } + + const hasErrors = issues.some((issue) => issue.level === 'error'); + return { valid: !hasErrors, issues }; +} diff --git a/src/api/services/profile-types.ts b/src/api/services/profile-types.ts index 62da4667..54d63ebe 100644 --- a/src/api/services/profile-types.ts +++ b/src/api/services/profile-types.ts @@ -56,3 +56,76 @@ export interface UpdateApiProfileTargetResult { target?: TargetType; error?: string; } + +/** Validation severity for profile lifecycle checks */ +export type ProfileValidationLevel = 'error' | 'warning'; + +/** Field-level validation issue emitted by lifecycle operations */ +export interface ProfileValidationIssue { + level: ProfileValidationLevel; + code: string; + message: string; + field?: string; + hint?: string; +} + +/** Validation summary for settings payload */ +export interface ProfileValidationSummary { + valid: boolean; + issues: ProfileValidationIssue[]; +} + +/** Orphan settings file candidate discovered on disk */ +export interface ApiProfileOrphanCandidate { + name: string; + settingsPath: string; + validation: ProfileValidationSummary; +} + +/** Discovery result for orphan settings files */ +export interface DiscoverApiProfileOrphansResult { + orphans: ApiProfileOrphanCandidate[]; +} + +/** Registration result for orphan settings files */ +export interface RegisterApiProfileOrphansResult { + registered: string[]; + skipped: Array<{ name: string; reason: string }>; +} + +/** Copy result for API profile duplication */ +export interface CopyApiProfileResult { + success: boolean; + name?: string; + settingsPath?: string; + warnings?: string[]; + error?: string; +} + +/** Portable export bundle schema */ +export interface ApiProfileExportBundle { + schemaVersion: 1; + exportedAt: string; + profile: { + name: string; + target: TargetType; + }; + settings: Record; +} + +/** Export operation result */ +export interface ExportApiProfileResult { + success: boolean; + bundle?: ApiProfileExportBundle; + redacted?: boolean; + error?: string; +} + +/** Import operation result */ +export interface ImportApiProfileResult { + success: boolean; + name?: string; + warnings?: string[]; + validation?: ProfileValidationSummary; + error?: string; +} diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index f44f6f49..f1d4fc6f 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -8,6 +8,8 @@ * Business logic delegated to src/api/services/. */ +import * as fs from 'fs'; +import * as path from 'path'; import { initUI, header, @@ -39,6 +41,11 @@ import { getPresetById, getPresetAliases, getPresetIds, + discoverApiProfileOrphans, + registerApiProfileOrphans, + copyApiProfile, + exportApiProfile, + importApiProfileBundle, type ModelMapping, type ProviderPreset, } from '../api/services'; @@ -140,6 +147,30 @@ function parseTargetValue(value: string): TargetType | null { return null; } +function parseOptionalTargetFlag( + args: string[], + knownFlags: readonly string[] +): { target?: TargetType; remainingArgs: string[]; errors: string[] } { + const extracted = extractOption(args, ['--target'], { + allowDashValue: true, + knownFlags, + }); + if (!extracted.found) { + return { remainingArgs: args, errors: [] }; + } + if (extracted.missingValue || !extracted.value) { + return { remainingArgs: extracted.remainingArgs, errors: ['Missing value for --target'] }; + } + const target = parseTargetValue(extracted.value); + if (!target) { + return { + remainingArgs: extracted.remainingArgs, + errors: [`Invalid --target value "${extracted.value}". Use: claude or droid`], + }; + } + return { target, remainingArgs: extracted.remainingArgs, errors: [] }; +} + /** Parse command line arguments for api commands */ export function parseApiCommandArgs(args: string[]): ApiCommandArgs { const result: ApiCommandArgs = { @@ -611,6 +642,246 @@ async function handleRemove(args: string[]): Promise { console.log(''); } +/** Handle 'ccs api discover' command */ +async function handleDiscover(args: string[]): Promise { + await initUI(); + const register = hasAnyFlag(args, ['--register']); + const jsonOutput = hasAnyFlag(args, ['--json']); + const force = hasAnyFlag(args, ['--force']); + + const targetParsed = parseOptionalTargetFlag(args, [...API_KNOWN_FLAGS, '--register', '--json']); + if (targetParsed.errors.length > 0) { + targetParsed.errors.forEach((errorMessage) => console.log(fail(errorMessage))); + process.exit(1); + } + + const result = discoverApiProfileOrphans(); + if (jsonOutput) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(header('Discover Orphan API Profiles')); + console.log(''); + + if (result.orphans.length === 0) { + console.log(ok('No orphan settings files found.')); + console.log(''); + return; + } + + const rows = result.orphans.map((orphan) => { + const status = orphan.validation.valid ? color('[OK]', 'success') : color('[X]', 'error'); + const issueSummary = + orphan.validation.issues.length > 0 + ? orphan.validation.issues[0].message + : 'Ready to register'; + return [orphan.name, status, issueSummary]; + }); + + console.log( + table(rows, { + head: ['Profile', 'Status', 'Validation'], + colWidths: [20, 10, 64], + }) + ); + console.log(''); + + if (!register) { + console.log(info('To register discovered profiles:')); + console.log(` ${color('ccs api discover --register', 'command')}`); + console.log(''); + return; + } + + const registration = registerApiProfileOrphans({ + target: targetParsed.target || 'claude', + force, + }); + console.log(ok(`Registered: ${registration.registered.length}`)); + if (registration.skipped.length > 0) { + console.log(warn(`Skipped: ${registration.skipped.length}`)); + registration.skipped.forEach((item) => { + console.log(` - ${item.name}: ${item.reason}`); + }); + } + console.log(''); +} + +/** Handle 'ccs api copy' command */ +async function handleCopy(args: string[]): Promise { + await initUI(); + const parsedArgs = parseApiCommandArgs(args); + if (parsedArgs.errors.length > 0) { + parsedArgs.errors.forEach((errorMessage) => console.log(fail(errorMessage))); + process.exit(1); + } + + const positionals = extractPositionalArgs(args); + const source = positionals[0]; + let destination = positionals[1]; + + if (!source) { + console.log(fail('Source profile is required. Usage: ccs api copy ')); + process.exit(1); + } + + if (!destination) { + destination = await InteractivePrompt.input('Destination profile name'); + } + + if (!parsedArgs.yes) { + const confirmed = await InteractivePrompt.confirm( + `Copy profile "${source}" to "${destination}"?`, + { default: true } + ); + if (!confirmed) { + console.log(info('Cancelled')); + process.exit(0); + } + } + + const result = copyApiProfile(source, destination, { + target: parsedArgs.target, + force: parsedArgs.force, + }); + + if (!result.success) { + console.log(fail(result.error || 'Failed to copy profile')); + process.exit(1); + } + + console.log(ok(`Profile copied: ${source} -> ${destination}`)); + if (result.warnings && result.warnings.length > 0) { + result.warnings.forEach((warningMessage) => console.log(warn(warningMessage))); + } + console.log(''); +} + +/** Handle 'ccs api export' command */ +async function handleExport(args: string[]): Promise { + await initUI(); + const includeSecrets = hasAnyFlag(args, ['--include-secrets']); + + const outExtracted = extractOption(args, ['--out'], { + allowDashValue: true, + knownFlags: [...API_KNOWN_FLAGS, '--out', '--include-secrets'], + }); + if (outExtracted.found && (outExtracted.missingValue || !outExtracted.value)) { + console.log(fail('Missing value for --out')); + process.exit(1); + } + const outPath = outExtracted.value; + const positionals = extractPositionalArgs(outExtracted.remainingArgs); + const name = positionals[0]; + + if (!name) { + console.log(fail('Profile name is required. Usage: ccs api export [--out ]')); + process.exit(1); + } + + const result = exportApiProfile(name, includeSecrets); + if (!result.success || !result.bundle) { + console.log(fail(result.error || 'Failed to export profile')); + process.exit(1); + } + + const resolvedOutputPath = path.resolve(outPath || `${name}.ccs-profile.json`); + fs.mkdirSync(path.dirname(resolvedOutputPath), { recursive: true }); + fs.writeFileSync(resolvedOutputPath, JSON.stringify(result.bundle, null, 2) + '\n', 'utf8'); + + console.log(ok(`Profile exported to: ${resolvedOutputPath}`)); + if (result.redacted) { + console.log(warn('Token was redacted in export. Use --include-secrets to include it.')); + } + console.log(''); +} + +/** Handle 'ccs api import' command */ +async function handleImport(args: string[]): Promise { + await initUI(); + const force = hasAnyFlag(args, ['--force']); + const yes = hasAnyFlag(args, ['--yes', '-y']); + + const nameExtracted = extractOption(args, ['--name'], { + allowDashValue: true, + knownFlags: [...API_KNOWN_FLAGS, '--name'], + }); + if (nameExtracted.found && (nameExtracted.missingValue || !nameExtracted.value)) { + console.log(fail('Missing value for --name')); + process.exit(1); + } + + const targetParsed = parseOptionalTargetFlag(nameExtracted.remainingArgs, [ + ...API_KNOWN_FLAGS, + '--name', + ]); + if (targetParsed.errors.length > 0) { + targetParsed.errors.forEach((errorMessage) => console.log(fail(errorMessage))); + process.exit(1); + } + + const positionals = extractPositionalArgs(targetParsed.remainingArgs); + const importPath = positionals[0]; + if (!importPath) { + console.log( + fail('Import file path is required. Usage: ccs api import [--name ]') + ); + process.exit(1); + } + + if (!fs.existsSync(importPath)) { + console.log(fail(`File not found: ${importPath}`)); + process.exit(1); + } + + const raw = fs.readFileSync(importPath, 'utf8'); + let bundle: unknown; + try { + bundle = JSON.parse(raw); + } catch (error) { + console.log(fail(`Invalid JSON file: ${(error as Error).message}`)); + process.exit(1); + } + + if (!yes) { + const confirmed = await InteractivePrompt.confirm( + `Import profile bundle from "${importPath}"?`, + { + default: true, + } + ); + if (!confirmed) { + console.log(info('Cancelled')); + process.exit(0); + } + } + + const result = importApiProfileBundle(bundle, { + name: nameExtracted.value, + target: targetParsed.target, + force, + }); + + if (!result.success) { + console.log(fail(result.error || 'Failed to import profile')); + if (result.validation?.issues?.length) { + console.log(''); + result.validation.issues.forEach((issue) => { + const indicator = issue.level === 'error' ? color('[X]', 'error') : color('[!]', 'warning'); + console.log(`${indicator} ${issue.message}`); + }); + } + process.exit(1); + } + + console.log(ok(`Profile imported: ${result.name}`)); + if (result.warnings && result.warnings.length > 0) { + result.warnings.forEach((warningMessage) => console.log(warn(warningMessage))); + } + console.log(''); +} + /** Show help for api commands */ async function showHelp(): Promise { await initUI(); @@ -628,6 +899,16 @@ async function showHelp(): Promise { console.log(subheader('Commands')); console.log(` ${color('create [name]', 'command')} Create new API profile (interactive)`); console.log(` ${color('list', 'command')} List all API profiles`); + console.log( + ` ${color('discover', 'command')} Discover orphan *.settings.json and register` + ); + console.log(` ${color('copy ', 'command')} Duplicate API profile settings + config`); + console.log( + ` ${color('export ', 'command')} Export profile bundle for cross-device transfer` + ); + console.log( + ` ${color('import ', 'command')} Import profile bundle and register profile` + ); console.log(` ${color('remove ', 'command')} Remove an API profile`); console.log(''); console.log(subheader('Options')); @@ -640,6 +921,11 @@ async function showHelp(): Promise { console.log( ` ${color('--target ', 'command')} Default target: claude or droid (create)` ); + console.log(` ${color('--register', 'command')} Register discovered orphan settings`); + console.log(` ${color('--json', 'command')} JSON output for discover command`); + console.log(` ${color('--out ', 'command')} Export bundle output path`); + console.log(` ${color('--include-secrets', 'command')} Include token in export bundle`); + console.log(` ${color('--name ', 'command')} Override profile name during import`); console.log(` ${color('--force', 'command')} Overwrite existing (create)`); console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts`); console.log(''); @@ -670,6 +956,17 @@ async function showHelp(): Promise { console.log(` ${dim('# Remove API profile')}`); console.log(` ${color('ccs api remove myapi', 'command')}`); console.log(''); + console.log(` ${dim('# Discover and register orphan settings files')}`); + console.log(` ${color('ccs api discover', 'command')}`); + console.log(` ${color('ccs api discover --register', 'command')}`); + console.log(''); + console.log(` ${dim('# Duplicate an existing API profile')}`); + console.log(` ${color('ccs api copy glm glm-backup', 'command')}`); + console.log(''); + console.log(` ${dim('# Export and import across devices')}`); + console.log(` ${color('ccs api export glm --out ./glm.ccs-profile.json', 'command')}`); + console.log(` ${color('ccs api import ./glm.ccs-profile.json', 'command')}`); + console.log(''); console.log(` ${dim('# Show all API profiles')}`); console.log(` ${color('ccs api list', 'command')}`); console.log(''); @@ -691,6 +988,18 @@ export async function handleApiCommand(args: string[]): Promise { case 'list': await handleList(); break; + case 'discover': + await handleDiscover(args.slice(1)); + break; + case 'copy': + await handleCopy(args.slice(1)); + break; + case 'export': + await handleExport(args.slice(1)); + break; + case 'import': + await handleImport(args.slice(1)); + break; case 'remove': case 'delete': case 'rm': diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 5f5f4c56..ca11447f 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -141,6 +141,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs ollama-cloud', 'Ollama Cloud (API key required)'], ['', ''], // Spacer ['ccs api create', 'Create custom API profile'], + ['ccs api discover --register', 'Discover/register orphan settings files'], + ['ccs api copy ', 'Duplicate API profile'], + ['ccs api export ', 'Export profile bundle'], + ['ccs api import ', 'Import profile bundle'], ['ccs api remove', 'Remove an API profile'], ['ccs api list', 'List all API profiles'], ] diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index f8c87cab..ac7c2179 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -11,8 +11,15 @@ import { createApiProfile, removeApiProfile, updateApiProfileTarget, -} from '../../api/services/profile-writer'; -import { apiProfileExists, listApiProfiles } from '../../api/services/profile-reader'; + discoverApiProfileOrphans, + registerApiProfileOrphans, + copyApiProfile, + exportApiProfile, + importApiProfileBundle, + apiProfileExists, + listApiProfiles, + validateApiName, +} from '../../api/services'; import { normalizeDroidProvider } from '../../targets/droid-provider'; import { updateSettingsFile, parseTarget } from './route-helpers'; @@ -22,6 +29,29 @@ function isDenylistError(message: string | undefined): boolean { return typeof message === 'string' && message.toLowerCase().includes('denylist'); } +function getUnknownKeys( + payload: Record, + allowedKeys: readonly string[] +): string[] { + const allowed = new Set(allowedKeys); + return Object.keys(payload).filter((key) => !allowed.has(key)); +} + +function validatePayloadShape( + body: unknown, + allowedKeys: readonly string[] +): { ok: true; payload: Record } | { ok: false; error: string } { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return { ok: false, error: 'Request body must be a JSON object' }; + } + const payload = body as Record; + const unknownKeys = getUnknownKeys(payload, allowedKeys); + if (unknownKeys.length > 0) { + return { ok: false, error: `Unknown profile field(s): ${unknownKeys.join(', ')}` }; + } + return { ok: true, payload }; +} + // ==================== Profile CRUD ==================== /** @@ -47,6 +77,23 @@ router.get('/', (_req: Request, res: Response): void => { * POST /api/profiles - Create new profile */ router.post('/', (req: Request, res: Response): void => { + const shape = validatePayloadShape(req.body, [ + 'name', + 'baseUrl', + 'apiKey', + 'model', + 'opusModel', + 'sonnetModel', + 'haikuModel', + 'target', + 'droidProvider', + 'provider', + ]); + if (!shape.ok) { + res.status(400).json({ error: shape.error }); + return; + } + const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body; const providerHint = req.body?.droidProvider ?? req.body?.provider; const parsedProvider = normalizeDroidProvider(providerHint); @@ -111,10 +158,169 @@ router.post('/', (req: Request, res: Response): void => { }); }); +/** + * GET /api/profiles/orphans - Discover orphan ~/.ccs/*.settings.json files + */ +router.get('/orphans', (_req: Request, res: Response): void => { + try { + const result = discoverApiProfileOrphans(); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/profiles/orphans/register - Register discovered orphan settings + */ +router.post('/orphans/register', (req: Request, res: Response): void => { + const shape = validatePayloadShape(req.body ?? {}, ['names', 'target', 'force']); + if (!shape.ok) { + res.status(400).json({ error: shape.error }); + return; + } + + const payload = shape.payload; + const names = Array.isArray(payload.names) + ? payload.names.filter((value): value is string => typeof value === 'string') + : undefined; + const target = parseTarget(payload.target); + const force = payload.force === true; + + if (payload.target !== undefined && target === null) { + res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + return; + } + + try { + const result = registerApiProfileOrphans({ + names, + target: target || 'claude', + force, + }); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * POST /api/profiles/:name/copy - Duplicate an API profile + */ +router.post('/:name/copy', (req: Request, res: Response): void => { + const shape = validatePayloadShape(req.body, ['destination', 'target', 'force']); + if (!shape.ok) { + res.status(400).json({ error: shape.error }); + return; + } + + const { name } = req.params; + const sourceNameError = validateApiName(name); + if (sourceNameError) { + res.status(400).json({ error: sourceNameError }); + return; + } + const destination = shape.payload.destination; + const target = parseTarget(shape.payload.target); + const force = shape.payload.force === true; + + if (typeof destination !== 'string' || destination.trim().length === 0) { + res.status(400).json({ error: 'destination is required' }); + return; + } + if (shape.payload.target !== undefined && target === null) { + res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + return; + } + + const result = copyApiProfile(name, destination.trim(), { target: target || undefined, force }); + if (!result.success) { + res.status(400).json({ error: result.error || 'Failed to copy profile' }); + return; + } + + res.status(201).json(result); +}); + +/** + * POST /api/profiles/:name/export - Export profile as a portable bundle + */ +router.post('/:name/export', (req: Request, res: Response): void => { + const shape = validatePayloadShape(req.body ?? {}, ['includeSecrets']); + if (!shape.ok) { + res.status(400).json({ error: shape.error }); + return; + } + + const { name } = req.params; + const profileNameError = validateApiName(name); + if (profileNameError) { + res.status(400).json({ error: profileNameError }); + return; + } + const includeSecrets = shape.payload.includeSecrets === true; + const result = exportApiProfile(name, includeSecrets); + if (!result.success || !result.bundle) { + res.status(400).json({ error: result.error || 'Failed to export profile' }); + return; + } + + res.json(result); +}); + +/** + * POST /api/profiles/import - Import profile bundle into local registry + */ +router.post('/import', (req: Request, res: Response): void => { + const shape = validatePayloadShape(req.body, ['bundle', 'name', 'target', 'force']); + if (!shape.ok) { + res.status(400).json({ error: shape.error }); + return; + } + + const target = parseTarget(shape.payload.target); + if (shape.payload.target !== undefined && target === null) { + res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + return; + } + + const result = importApiProfileBundle(shape.payload.bundle, { + name: typeof shape.payload.name === 'string' ? shape.payload.name : undefined, + target: target || undefined, + force: shape.payload.force === true, + }); + + if (!result.success) { + res.status(400).json({ + error: result.error || 'Failed to import profile', + validation: result.validation, + }); + return; + } + + res.status(201).json(result); +}); + /** * PUT /api/profiles/:name - Update profile */ router.put('/:name', (req: Request, res: Response): void => { + const shape = validatePayloadShape(req.body, [ + 'baseUrl', + 'apiKey', + 'model', + 'opusModel', + 'sonnetModel', + 'haikuModel', + 'target', + 'droidProvider', + 'provider', + ]); + if (!shape.ok) { + res.status(400).json({ error: shape.error }); + return; + } + const { name } = req.params; const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body; const providerHint = req.body?.droidProvider ?? req.body?.provider; diff --git a/tests/unit/api/profile-lifecycle-service.test.ts b/tests/unit/api/profile-lifecycle-service.test.ts new file mode 100644 index 00000000..d6820f0b --- /dev/null +++ b/tests/unit/api/profile-lifecycle-service.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + copyApiProfile, + discoverApiProfileOrphans, + exportApiProfile, + registerApiProfileOrphans, +} from '../../../src/api/services/profile-lifecycle-service'; + +describe('profile lifecycle service', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-lifecycle-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('discovers only API profile orphans (skips registered and reserved names)', () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { glm: '~/.ccs/glm.settings.json' } }, null, 2) + '\n' + ); + + fs.writeFileSync( + path.join(ccsDir, 'glm.settings.json'), + JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) + + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'extra.settings.json'), + JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) + + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'gemini.settings.json'), + JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) + + '\n' + ); + + const result = discoverApiProfileOrphans(); + expect(result.orphans.map((orphan) => orphan.name)).toEqual(['extra']); + }); + + it('treats explicit empty names list as no-op during orphan registration', () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'lonely.settings.json'), + JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) + + '\n' + ); + fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); + + const result = registerApiProfileOrphans({ names: [] }); + expect(result.registered).toEqual([]); + expect(result.skipped).toEqual([]); + }); + + it('redacts all sensitive env values during export when includeSecrets=false', () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + + fs.writeFileSync( + path.join(ccsDir, 'config.json'), + JSON.stringify({ profiles: { glm: '~/.ccs/glm.settings.json' } }, null, 2) + '\n' + ); + fs.writeFileSync( + path.join(ccsDir, 'glm.settings.json'), + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'https://api.example.com', + ANTHROPIC_AUTH_TOKEN: 'token-1', + OPENROUTER_API_KEY: 'token-2', + }, + }, + null, + 2 + ) + '\n' + ); + + const result = exportApiProfile('glm', false); + expect(result.success).toBe(true); + expect(result.bundle?.settings).toBeDefined(); + + const env = (result.bundle?.settings.env as Record) || {}; + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('__CCS_REDACTED__'); + expect(env.OPENROUTER_API_KEY).toBe('__CCS_REDACTED__'); + }); + + it('rejects invalid source profile names in copy flow', () => { + const result = copyApiProfile('../escape', 'safe-name'); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid source profile name'); + }); +}); + diff --git a/tests/unit/web-server/profile-routes-lifecycle.test.ts b/tests/unit/web-server/profile-routes-lifecycle.test.ts new file mode 100644 index 00000000..6f7ddcb9 --- /dev/null +++ b/tests/unit/web-server/profile-routes-lifecycle.test.ts @@ -0,0 +1,110 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Server } from 'http'; +import profileRoutes from '../../../src/web-server/routes/profile-routes'; + +describe('profile-routes lifecycle endpoints', () => { + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/profiles', profileRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-routes-lifecycle-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('rejects unknown fields on profile create payload', async () => { + const response = await fetch(`${baseUrl}/api/profiles`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: 'demo', + baseUrl: 'https://api.example.com', + apiKey: 'token', + unknownField: true, + }), + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string }; + expect(body.error).toContain('Unknown profile field(s)'); + }); + + it('does not register all orphans when names=[] is explicitly passed', async () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n'); + fs.writeFileSync( + path.join(ccsDir, 'lonely.settings.json'), + JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) + + '\n' + ); + + const response = await fetch(`${baseUrl}/api/profiles/orphans/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ names: [] }), + }); + expect(response.status).toBe(200); + + const body = (await response.json()) as { registered: string[]; skipped: Array }; + expect(body.registered).toEqual([]); + expect(body.skipped).toEqual([]); + }); + + it('validates source profile name on export endpoint', async () => { + const response = await fetch(`${baseUrl}/api/profiles/1invalid/export`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { error: string }; + expect(body.error).toContain('API name must start with letter'); + }); +}); + diff --git a/ui/src/hooks/use-profiles.ts b/ui/src/hooks/use-profiles.ts index f87c944e..2922c588 100644 --- a/ui/src/hooks/use-profiles.ts +++ b/ui/src/hooks/use-profiles.ts @@ -4,7 +4,14 @@ */ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { api, type CreateProfile, type UpdateProfile } from '@/lib/api-client'; +import { + api, + type CreateProfile, + type UpdateProfile, + type RegisterProfileOrphansRequest, + type CopyProfileRequest, + type ImportProfileRequest, +} from '@/lib/api-client'; import { toast } from 'sonner'; export function useProfiles() { @@ -59,3 +66,68 @@ export function useDeleteProfile() { }, }); } + +export function useDiscoverProfileOrphans() { + return useMutation({ + mutationFn: () => api.profiles.discoverOrphans(), + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +export function useRegisterProfileOrphans() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: RegisterProfileOrphansRequest) => api.profiles.registerOrphans(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + toast.success('Orphan profiles registration complete'); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +export function useCopyProfile() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ name, data }: { name: string; data: CopyProfileRequest }) => + api.profiles.copy(name, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + toast.success('Profile copied successfully'); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +export function useExportProfile() { + return useMutation({ + mutationFn: ({ name, includeSecrets }: { name: string; includeSecrets?: boolean }) => + api.profiles.export(name, includeSecrets ?? false), + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +export function useImportProfile() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: ImportProfileRequest) => api.profiles.import(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + toast.success('Profile imported successfully'); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 5f581f2f..7f178efe 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -124,6 +124,76 @@ export interface UpdateProfile { target?: CliTarget; } +export interface ProfileValidationIssue { + level: 'error' | 'warning'; + code: string; + message: string; + field?: string; + hint?: string; +} + +export interface ProfileValidationSummary { + valid: boolean; + issues: ProfileValidationIssue[]; +} + +export interface ApiProfileOrphanCandidate { + name: string; + settingsPath: string; + validation: ProfileValidationSummary; +} + +export interface DiscoverProfileOrphansResponse { + orphans: ApiProfileOrphanCandidate[]; +} + +export interface RegisterProfileOrphansRequest { + names?: string[]; + target?: CliTarget; + force?: boolean; +} + +export interface RegisterProfileOrphansResponse { + registered: string[]; + skipped: Array<{ name: string; reason: string }>; +} + +export interface CopyProfileRequest { + destination: string; + target?: CliTarget; + force?: boolean; +} + +export interface ApiProfileExportBundle { + schemaVersion: 1; + exportedAt: string; + profile: { + name: string; + target: CliTarget; + }; + settings: Record; +} + +export interface ExportProfileResponse { + success: boolean; + bundle: ApiProfileExportBundle; + redacted?: boolean; +} + +export interface ImportProfileRequest { + bundle: ApiProfileExportBundle; + name?: string; + target?: CliTarget; + force?: boolean; +} + +export interface ImportProfileResponse { + success: boolean; + name?: string; + warnings?: string[]; + validation?: ProfileValidationSummary; +} + export interface Variant { name: string; provider: CLIProxyProvider; @@ -634,6 +704,27 @@ export const api = { body: JSON.stringify(data), }), delete: (name: string) => request(`/profiles/${name}`, { method: 'DELETE' }), + discoverOrphans: () => request('/profiles/orphans'), + registerOrphans: (data: RegisterProfileOrphansRequest) => + request('/profiles/orphans/register', { + method: 'POST', + body: JSON.stringify(data), + }), + copy: (name: string, data: CopyProfileRequest) => + request(`/profiles/${name}/copy`, { + method: 'POST', + body: JSON.stringify(data), + }), + export: (name: string, includeSecrets = false) => + request(`/profiles/${name}/export`, { + method: 'POST', + body: JSON.stringify({ includeSecrets }), + }), + import: (data: ImportProfileRequest) => + request('/profiles/import', { + method: 'POST', + body: JSON.stringify(data), + }), }, cliproxy: { list: () => request<{ variants: Variant[] }>('/cliproxy'), diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index bbc0384f..1952da56 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import { type ChangeEvent, useMemo, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -12,6 +12,9 @@ import { Server, FileJson, RefreshCw, + Copy, + Download, + Upload, } from 'lucide-react'; import { ProfileEditor } from '@/components/profile-editor'; import { ProfileCreateDialog } from '@/components/profiles/profile-create-dialog'; @@ -19,18 +22,32 @@ import { OpenRouterBanner } from '@/components/profiles/openrouter-banner'; import { OpenRouterQuickStart } from '@/components/profiles/openrouter-quick-start'; import { OpenRouterPromoCard } from '@/components/profiles/openrouter-promo-card'; import { AlibabaCodingPlanPromoCard } from '@/components/profiles/alibaba-coding-plan-promo-card'; -import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles'; +import { + useProfiles, + useDeleteProfile, + useDiscoverProfileOrphans, + useRegisterProfileOrphans, + useCopyProfile, + useExportProfile, + useImportProfile, +} from '@/hooks/use-profiles'; import { useOpenRouterModels } from '@/hooks/use-openrouter-models'; import { ConfirmDialog } from '@/components/shared/confirm-dialog'; -import type { Profile } from '@/lib/api-client'; +import type { ApiProfileExportBundle, Profile } from '@/lib/api-client'; import { cn } from '@/lib/utils'; import { CopyButton } from '@/components/ui/copy-button'; import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; export function ApiPage() { const { t } = useTranslation(); const { data, isLoading, isError, refetch } = useProfiles(); const deleteMutation = useDeleteProfile(); + const discoverOrphansMutation = useDiscoverProfileOrphans(); + const registerOrphansMutation = useRegisterProfileOrphans(); + const copyProfileMutation = useCopyProfile(); + const exportProfileMutation = useExportProfile(); + const importProfileMutation = useImportProfile(); const [selectedProfile, setSelectedProfile] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [isCreateDialogOpen, setCreateDialogOpen] = useState(false); @@ -40,6 +57,7 @@ export function ApiPage() { const [deleteConfirm, setDeleteConfirm] = useState(null); const [editorHasChanges, setEditorHasChanges] = useState(false); const [pendingSwitch, setPendingSwitch] = useState(null); + const importFileInputRef = useRef(null); useOpenRouterModels(); const profiles = useMemo(() => data?.profiles || [], [data?.profiles]); @@ -50,6 +68,15 @@ export function ApiPage() { const selectedProfileData = selectedProfile ? profiles.find((p) => p.name === selectedProfile) : null; + + const switchToProfile = (name: string) => { + if (editorHasChanges && selectedProfile !== name) { + setPendingSwitch(name); + } else { + setSelectedProfile(name); + } + }; + const handleDelete = (name: string) => { deleteMutation.mutate(name, { onSuccess: () => { @@ -63,17 +90,106 @@ export function ApiPage() { const handleCreateSuccess = (name: string) => { setCreateDialogOpen(false); - if (editorHasChanges && selectedProfile !== null) { - setPendingSwitch(name); - } else { - setSelectedProfile(name); - } + switchToProfile(name); }; const handleProfileSelect = (name: string) => { - if (editorHasChanges && selectedProfile !== name) { - setPendingSwitch(name); - } else { - setSelectedProfile(name); + switchToProfile(name); + }; + + const triggerDownload = (filename: string, bundle: ApiProfileExportBundle) => { + const content = JSON.stringify(bundle, null, 2) + '\n'; + const blob = new Blob([content], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + }; + + const handleDiscoverOrphans = async () => { + try { + const result = await discoverOrphansMutation.mutateAsync(); + if (result.orphans.length === 0) { + toast.success('No orphan profile settings found'); + return; + } + + const validCount = result.orphans.filter((orphan) => orphan.validation.valid).length; + const shouldRegister = window.confirm( + `Found ${result.orphans.length} orphan settings file(s). Register ${validCount} valid profile(s) now?` + ); + + if (!shouldRegister) return; + + const registration = await registerOrphansMutation.mutateAsync({}); + const skippedMessage = + registration.skipped.length > 0 ? `, skipped ${registration.skipped.length}` : ''; + toast.success(`Registered ${registration.registered.length} profile(s)${skippedMessage}`); + } catch (error) { + toast.error((error as Error).message); + } + }; + + const handleCopySelectedProfile = async () => { + if (!selectedProfileData) return; + const destination = window.prompt( + `Copy profile "${selectedProfileData.name}" to new profile name:`, + `${selectedProfileData.name}-copy` + ); + if (!destination) return; + + try { + await copyProfileMutation.mutateAsync({ + name: selectedProfileData.name, + data: { destination: destination.trim() }, + }); + switchToProfile(destination.trim()); + } catch (error) { + toast.error((error as Error).message); + } + }; + + const handleExportSelectedProfile = async () => { + if (!selectedProfileData) return; + try { + const result = await exportProfileMutation.mutateAsync({ name: selectedProfileData.name }); + triggerDownload(`${selectedProfileData.name}.ccs-profile.json`, result.bundle); + if (result.redacted) { + toast.info( + 'Export created with redacted token. Use include-secrets flow in CLI if needed.' + ); + } else { + toast.success('Profile export downloaded'); + } + } catch (error) { + toast.error((error as Error).message); + } + }; + + const handleImportClick = () => { + importFileInputRef.current?.click(); + }; + + const handleImportFileChange = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ''; + if (!file) return; + + try { + const rawText = await file.text(); + const bundle = JSON.parse(rawText) as ApiProfileExportBundle; + const result = await importProfileMutation.mutateAsync({ bundle }); + if (result.name) { + switchToProfile(result.name); + } + if (result.warnings && result.warnings.length > 0) { + toast.info(result.warnings.join('\n')); + } + } catch (error) { + toast.error((error as Error).message || 'Failed to import profile bundle'); } }; @@ -88,15 +204,35 @@ export function ApiPage() {

{t('apiProfiles.title')}

- +
+ + + +
@@ -204,13 +340,35 @@ export function ApiPage() {
{selectedProfileData ? ( - setDeleteConfirm(selectedProfileData.name)} - onHasChangesUpdate={setEditorHasChanges} - /> + <> +
+ + +
+ setDeleteConfirm(selectedProfileData.name)} + onHasChangesUpdate={setEditorHasChanges} + /> + ) : ( { @@ -230,6 +388,14 @@ export function ApiPage() {
+ void handleImportFileChange(event)} + /> +