mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 20:15:40 +00:00
fix(auth): preserve xAI profile compatibility
This commit is contained in:
1 parent
4e4c949967
commit
44718e98ef
19 files changed
+1166
-106
No files matched your search
@@ -5,7 +5,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Validation services
|
// Validation services
|
||||||
export { validateApiName, validateUrl, getUrlWarning, sanitizeBaseUrl } from './validation-service';
|
export {
|
||||||
|
validateApiName,
|
||||||
|
validateApiNameSyntax,
|
||||||
|
validateUrl,
|
||||||
|
getUrlWarning,
|
||||||
|
sanitizeBaseUrl,
|
||||||
|
} from './validation-service';
|
||||||
|
|
||||||
// Profile types
|
// Profile types
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -9,14 +9,19 @@ import * as path from 'path';
|
|||||||
import type { Config, Settings } from '../../types';
|
import type { Config, Settings } from '../../types';
|
||||||
import type { TargetType } from '../../targets/target-adapter';
|
import type { TargetType } from '../../targets/target-adapter';
|
||||||
import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata';
|
import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata';
|
||||||
|
import { ConfigError, ProfileError } from '../../errors/error-types';
|
||||||
import { getConfigPath } from '../../utils/config-manager';
|
import { getConfigPath } from '../../utils/config-manager';
|
||||||
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
|
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
|
||||||
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
|
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
|
||||||
import { isSensitiveKey } from '../../utils/sensitive-keys';
|
import { isSensitiveKey } from '../../utils/sensitive-keys';
|
||||||
import { isReservedName } from '../../config/reserved-names';
|
import {
|
||||||
|
canOverwriteGrandfatheredReservedProfileName,
|
||||||
|
isGrandfatheredReservedProfileName,
|
||||||
|
isReservedName,
|
||||||
|
} from '../../config/reserved-names';
|
||||||
|
|
||||||
import { validateApiName } from './validation-service';
|
import { validateApiName, validateApiNameSyntax } from './validation-service';
|
||||||
import { listApiProfiles } from './profile-reader';
|
import { apiProfileExists, listApiProfiles } from './profile-reader';
|
||||||
import { validateApiProfileSettingsPayload } from './profile-lifecycle-validation';
|
import { validateApiProfileSettingsPayload } from './profile-lifecycle-validation';
|
||||||
import type {
|
import type {
|
||||||
ApiProfileExportBundle,
|
ApiProfileExportBundle,
|
||||||
@@ -44,7 +49,7 @@ function parseTargetValue(value: unknown): TargetType | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateProfileNameForPath(name: string, label: string): string | null {
|
function validateProfileNameForPath(name: string, label: string): string | null {
|
||||||
const validationError = validateApiName(name);
|
const validationError = validateApiNameSyntax(name);
|
||||||
if (validationError) {
|
if (validationError) {
|
||||||
return `Invalid ${label} profile name "${name}": ${validationError}`;
|
return `Invalid ${label} profile name "${name}": ${validationError}`;
|
||||||
}
|
}
|
||||||
@@ -70,7 +75,7 @@ function registerApiProfileInConfig(name: string, target: TargetType, force = fa
|
|||||||
if (isUnifiedMode()) {
|
if (isUnifiedMode()) {
|
||||||
mutateConfig((config) => {
|
mutateConfig((config) => {
|
||||||
if (config.profiles[name] && !force) {
|
if (config.profiles[name] && !force) {
|
||||||
throw new Error(`API profile already exists: ${name}`);
|
throw new ProfileError(`API profile already exists: ${name}`, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
config.profiles[name] = {
|
config.profiles[name] = {
|
||||||
@@ -85,7 +90,7 @@ function registerApiProfileInConfig(name: string, target: TargetType, force = fa
|
|||||||
const configPath = getConfigPath();
|
const configPath = getConfigPath();
|
||||||
const config = loadConfigSafe() as Config;
|
const config = loadConfigSafe() as Config;
|
||||||
if (config.profiles[name] && !force) {
|
if (config.profiles[name] && !force) {
|
||||||
throw new Error(`API profile already exists: ${name}`);
|
throw new ProfileError(`API profile already exists: ${name}`, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
config.profiles[name] = `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`;
|
config.profiles[name] = `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`;
|
||||||
@@ -124,7 +129,7 @@ function readJsonObject(filePath: string): Record<string, unknown> {
|
|||||||
const raw = fs.readFileSync(filePath, 'utf8');
|
const raw = fs.readFileSync(filePath, 'utf8');
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||||
throw new Error('Settings file must contain a JSON object.');
|
throw new ConfigError('Settings file must contain a JSON object.', filePath);
|
||||||
}
|
}
|
||||||
return parsed as Record<string, unknown>;
|
return parsed as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -158,7 +163,10 @@ export function discoverApiProfileOrphans(): DiscoverApiProfileOrphansResult {
|
|||||||
.filter((file) => !registeredSettings.has(file))
|
.filter((file) => !registeredSettings.has(file))
|
||||||
.filter((file) => !file.startsWith('base-'))
|
.filter((file) => !file.startsWith('base-'))
|
||||||
.filter((file) => !ignoredNames.has(file))
|
.filter((file) => !ignoredNames.has(file))
|
||||||
.filter((file) => !isReservedName(file.slice(0, -SETTINGS_FILE_SUFFIX.length)))
|
.filter((file) => {
|
||||||
|
const name = file.slice(0, -SETTINGS_FILE_SUFFIX.length);
|
||||||
|
return !isReservedName(name) || isGrandfatheredReservedProfileName(name);
|
||||||
|
})
|
||||||
.map((file) => {
|
.map((file) => {
|
||||||
const name = file.slice(0, -SETTINGS_FILE_SUFFIX.length);
|
const name = file.slice(0, -SETTINGS_FILE_SUFFIX.length);
|
||||||
const settingsPath = path.join(ccsDir, file);
|
const settingsPath = path.join(ccsDir, file);
|
||||||
@@ -206,7 +214,7 @@ export function registerApiProfileOrphans(options?: {
|
|||||||
|
|
||||||
const result: RegisterApiProfileOrphansResult = { registered: [], skipped: [] };
|
const result: RegisterApiProfileOrphansResult = { registered: [], skipped: [] };
|
||||||
for (const orphan of selected) {
|
for (const orphan of selected) {
|
||||||
const nameError = validateApiName(orphan.name);
|
const nameError = validateApiNameSyntax(orphan.name);
|
||||||
if (nameError) {
|
if (nameError) {
|
||||||
result.skipped.push({
|
result.skipped.push({
|
||||||
name: orphan.name,
|
name: orphan.name,
|
||||||
@@ -247,7 +255,16 @@ export function copyApiProfile(
|
|||||||
if (sourceError) return { success: false, error: sourceError };
|
if (sourceError) return { success: false, error: sourceError };
|
||||||
|
|
||||||
const destinationError = validateApiName(destination);
|
const destinationError = validateApiName(destination);
|
||||||
if (destinationError) return { success: false, error: destinationError };
|
const canOverwriteGrandfatheredDestination = canOverwriteGrandfatheredReservedProfileName(
|
||||||
|
destination,
|
||||||
|
{
|
||||||
|
force: options?.force === true,
|
||||||
|
exists: apiProfileExists(destination),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (destinationError && !canOverwriteGrandfatheredDestination) {
|
||||||
|
return { success: false, error: destinationError };
|
||||||
|
}
|
||||||
|
|
||||||
const sourceSettingsPath = getProfileSettingsPath(source);
|
const sourceSettingsPath = getProfileSettingsPath(source);
|
||||||
if (!fs.existsSync(sourceSettingsPath)) {
|
if (!fs.existsSync(sourceSettingsPath)) {
|
||||||
@@ -363,7 +380,13 @@ export function importApiProfileBundle(
|
|||||||
|
|
||||||
const name = options?.name || input.profile.name;
|
const name = options?.name || input.profile.name;
|
||||||
const nameError = validateApiName(name);
|
const nameError = validateApiName(name);
|
||||||
if (nameError) return { success: false, error: nameError };
|
const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(name, {
|
||||||
|
force: options?.force === true,
|
||||||
|
exists: apiProfileExists(name),
|
||||||
|
});
|
||||||
|
if (nameError && !canOverwriteGrandfatheredProfile) {
|
||||||
|
return { success: false, error: nameError };
|
||||||
|
}
|
||||||
|
|
||||||
const bundleTarget = parseTargetValue(input.profile.target);
|
const bundleTarget = parseTargetValue(input.profile.target);
|
||||||
if (input.profile.target !== undefined && bundleTarget === null) {
|
if (input.profile.target !== undefined && bundleTarget === null) {
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
|
|||||||
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
|
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
|
||||||
import type { TargetType } from '../../targets/target-adapter';
|
import type { TargetType } from '../../targets/target-adapter';
|
||||||
import { resolveDroidProvider } from '../../targets/droid-provider';
|
import { resolveDroidProvider } from '../../targets/droid-provider';
|
||||||
import { isReservedName } from '../../config/reserved-names';
|
import {
|
||||||
|
canOverwriteGrandfatheredReservedProfileName,
|
||||||
|
isReservedName,
|
||||||
|
} from '../../config/reserved-names';
|
||||||
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
|
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
|
||||||
import {
|
import {
|
||||||
extractProviderFromPathname,
|
extractProviderFromPathname,
|
||||||
@@ -266,8 +269,18 @@ export function createApiProfile(
|
|||||||
models: ModelMapping,
|
models: ModelMapping,
|
||||||
target: TargetType = 'claude',
|
target: TargetType = 'claude',
|
||||||
provider?: string,
|
provider?: string,
|
||||||
extraModels?: string[]
|
extraModels?: string[],
|
||||||
|
options?: { force?: boolean }
|
||||||
): CreateApiProfileResult {
|
): CreateApiProfileResult {
|
||||||
|
const nameError = validateApiName(name);
|
||||||
|
const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(name, {
|
||||||
|
force: options?.force === true,
|
||||||
|
exists: apiProfileExists(name),
|
||||||
|
});
|
||||||
|
if (nameError && !canOverwriteGrandfatheredProfile) {
|
||||||
|
return { success: false, settingsFile: '', error: nameError };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deniedReason = getDeniedModelReason(baseUrl, models);
|
const deniedReason = getDeniedModelReason(baseUrl, models);
|
||||||
if (deniedReason) {
|
if (deniedReason) {
|
||||||
@@ -304,10 +317,17 @@ export function createCliproxyBridgeProfile(
|
|||||||
const providedName = options.name?.trim();
|
const providedName = options.name?.trim();
|
||||||
if (providedName) {
|
if (providedName) {
|
||||||
const nameError = validateApiName(providedName);
|
const nameError = validateApiName(providedName);
|
||||||
if (nameError) {
|
const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(
|
||||||
|
providedName,
|
||||||
|
{
|
||||||
|
force: options.force === true,
|
||||||
|
exists: apiProfileExists(providedName),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (nameError && !canOverwriteGrandfatheredProfile) {
|
||||||
return { success: false, settingsFile: '', error: nameError };
|
return { success: false, settingsFile: '', error: nameError };
|
||||||
}
|
}
|
||||||
if (isReservedName(providedName)) {
|
if (isReservedName(providedName) && !canOverwriteGrandfatheredProfile) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
settingsFile: '',
|
settingsFile: '',
|
||||||
@@ -332,7 +352,9 @@ export function createCliproxyBridgeProfile(
|
|||||||
resolved.apiKey,
|
resolved.apiKey,
|
||||||
resolved.models,
|
resolved.models,
|
||||||
resolved.target,
|
resolved.target,
|
||||||
provider
|
provider,
|
||||||
|
undefined,
|
||||||
|
{ force: options.force }
|
||||||
);
|
);
|
||||||
const detectedBridge = resolveCliproxyBridgeMetadata({
|
const detectedBridge = resolveCliproxyBridgeMetadata({
|
||||||
env: {
|
env: {
|
||||||
|
|||||||
@@ -8,10 +8,13 @@
|
|||||||
import { isReservedName, isWindowsReservedName } from '../../config/reserved-names';
|
import { isReservedName, isWindowsReservedName } from '../../config/reserved-names';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate API profile name
|
* Validate API profile name syntax for an existing profile reference.
|
||||||
|
*
|
||||||
|
* Existing profiles may use a name that became reserved after they were
|
||||||
|
* created, so this validator intentionally excludes built-in-name policy.
|
||||||
* @returns Error message if invalid, null if valid
|
* @returns Error message if invalid, null if valid
|
||||||
*/
|
*/
|
||||||
export function validateApiName(name: string): string | null {
|
export function validateApiNameSyntax(name: string): string | null {
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return 'API name is required';
|
return 'API name is required';
|
||||||
}
|
}
|
||||||
@@ -21,15 +24,27 @@ export function validateApiName(name: string): string | null {
|
|||||||
if (name.length > 32) {
|
if (name.length > 32) {
|
||||||
return 'API name must be 32 characters or less';
|
return 'API name must be 32 characters or less';
|
||||||
}
|
}
|
||||||
if (isReservedName(name)) {
|
|
||||||
return `'${name}' is a reserved name`;
|
|
||||||
}
|
|
||||||
if (isWindowsReservedName(name)) {
|
if (isWindowsReservedName(name)) {
|
||||||
return `'${name}' is a Windows reserved device name and cannot be used`;
|
return `'${name}' is a Windows reserved device name and cannot be used`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a name for creation of a new API profile.
|
||||||
|
* @returns Error message if invalid or reserved, null if valid
|
||||||
|
*/
|
||||||
|
export function validateApiName(name: string): string | null {
|
||||||
|
const syntaxError = validateApiNameSyntax(name);
|
||||||
|
if (syntaxError) {
|
||||||
|
return syntaxError;
|
||||||
|
}
|
||||||
|
if (isReservedName(name)) {
|
||||||
|
return `'${name}' is a reserved name`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate URL format
|
* Validate URL format
|
||||||
* @returns Error message if invalid, null if valid
|
* @returns Error message if invalid, null if valid
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* project workspace context with other accounts in the same context group.
|
* project workspace context with other accounts in the same context group.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { isReservedName } from '../config/reserved-names';
|
||||||
|
|
||||||
export type AccountContextMode = 'isolated' | 'shared';
|
export type AccountContextMode = 'isolated' | 'shared';
|
||||||
export type AccountContinuityMode = 'standard' | 'deeper';
|
export type AccountContinuityMode = 'standard' | 'deeper';
|
||||||
|
|
||||||
@@ -36,7 +38,6 @@ export const DEFAULT_ACCOUNT_CONTEXT_GROUP = 'default';
|
|||||||
export const DEFAULT_ACCOUNT_CONTINUITY_MODE: AccountContinuityMode = 'standard';
|
export const DEFAULT_ACCOUNT_CONTINUITY_MODE: AccountContinuityMode = 'standard';
|
||||||
export const MAX_CONTEXT_GROUP_LENGTH = 64;
|
export const MAX_CONTEXT_GROUP_LENGTH = 64;
|
||||||
export const ACCOUNT_PROFILE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
export const ACCOUNT_PROFILE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
||||||
const RESERVED_ACCOUNT_PROFILE_NAMES = new Set(['default']);
|
|
||||||
|
|
||||||
const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
||||||
|
|
||||||
@@ -58,10 +59,7 @@ export function isValidContextGroupName(value: string): boolean {
|
|||||||
* Validate account profile naming constraints.
|
* Validate account profile naming constraints.
|
||||||
*/
|
*/
|
||||||
export function isValidAccountProfileName(value: string): boolean {
|
export function isValidAccountProfileName(value: string): boolean {
|
||||||
return (
|
return ACCOUNT_PROFILE_NAME_PATTERN.test(value) && !isReservedName(value);
|
||||||
ACCOUNT_PROFILE_NAME_PATTERN.test(value) &&
|
|
||||||
!RESERVED_ACCOUNT_PROFILE_NAMES.has(value.toLowerCase())
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
maybeShowPoolOnboardingHint,
|
maybeShowPoolOnboardingHint,
|
||||||
countNativeClaudeProfiles,
|
countNativeClaudeProfiles,
|
||||||
} from '../../cliproxy/routing/pool-onboarding-hint';
|
} from '../../cliproxy/routing/pool-onboarding-hint';
|
||||||
|
import { canOverwriteGrandfatheredReservedProfileName } from '../../config/reserved-names';
|
||||||
|
|
||||||
function sanitizeProfileNameForInstance(name: string): string {
|
function sanitizeProfileNameForInstance(name: string): string {
|
||||||
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
|
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
|
||||||
@@ -63,7 +64,19 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
|||||||
exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
|
exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidAccountProfileName(profileName)) {
|
// Check exact account ownership before applying the narrow grandfathered
|
||||||
|
// repair exception. API profiles and case variants do not qualify.
|
||||||
|
const existsLegacy = ctx.registry.hasProfile(profileName);
|
||||||
|
const existsUnified = ctx.registry.hasAccountUnified(profileName);
|
||||||
|
const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(
|
||||||
|
profileName,
|
||||||
|
{
|
||||||
|
force: force === true,
|
||||||
|
exists: existsLegacy || existsUnified,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValidAccountProfileName(profileName) && !canOverwriteGrandfatheredProfile) {
|
||||||
exitWithError(
|
exitWithError(
|
||||||
'Invalid profile name. Use letters/numbers/dash/underscore and start with a letter.',
|
'Invalid profile name. Use letters/numbers/dash/underscore and start with a letter.',
|
||||||
ExitCode.PROFILE_ERROR
|
ExitCode.PROFILE_ERROR
|
||||||
@@ -71,8 +84,6 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if profile already exists (check both legacy and unified)
|
// Check if profile already exists (check both legacy and unified)
|
||||||
const existsLegacy = ctx.registry.hasProfile(profileName);
|
|
||||||
const existsUnified = ctx.registry.hasAccountUnified(profileName);
|
|
||||||
if (!force && (existsLegacy || existsUnified)) {
|
if (!force && (existsLegacy || existsUnified)) {
|
||||||
// Keep the --force hint in the exitWithError message so it appears in the single output line
|
// Keep the --force hint in the exitWithError message so it appears in the single output line
|
||||||
exitWithError(
|
exitWithError(
|
||||||
|
|||||||
+177
-46
@@ -26,8 +26,11 @@ import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profi
|
|||||||
import type { CLIProxyProvider } from '../cliproxy/types';
|
import type { CLIProxyProvider } from '../cliproxy/types';
|
||||||
import {
|
import {
|
||||||
CLIPROXY_PROVIDER_IDS,
|
CLIPROXY_PROVIDER_IDS,
|
||||||
|
isCLIProxyProvider,
|
||||||
resolveCLIProxyProviderShortcut,
|
resolveCLIProxyProviderShortcut,
|
||||||
} from '../cliproxy/provider-capabilities';
|
} from '../cliproxy/provider-capabilities';
|
||||||
|
import { isGrandfatheredReservedProfileName } from '../config/reserved-names';
|
||||||
|
import { ConfigError } from '../errors/error-types';
|
||||||
import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants';
|
import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants';
|
||||||
import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer';
|
import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer';
|
||||||
import type { TargetType } from '../targets/target-adapter';
|
import type { TargetType } from '../targets/target-adapter';
|
||||||
@@ -85,6 +88,50 @@ export interface ProfileNotFoundError extends Error {
|
|||||||
availableProfiles: string;
|
availableProfiles: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasOwnEntry(value: unknown, key: string): boolean {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' && value !== null && Object.prototype.hasOwnProperty.call(value, key)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isResolvableCompositeVariant(value: unknown): value is CompositeVariantConfig {
|
||||||
|
if (!isObjectRecord(value) || value.type !== 'composite') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
value.default_tier !== 'opus' &&
|
||||||
|
value.default_tier !== 'sonnet' &&
|
||||||
|
value.default_tier !== 'haiku'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!isObjectRecord(value.tiers)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const tiers = value.tiers;
|
||||||
|
|
||||||
|
return (['opus', 'sonnet', 'haiku'] as const).every((tierName) => {
|
||||||
|
const tier = tiers[tierName];
|
||||||
|
return (
|
||||||
|
isObjectRecord(tier) &&
|
||||||
|
typeof tier.provider === 'string' &&
|
||||||
|
isCLIProxyProvider(tier.provider) &&
|
||||||
|
typeof tier.model === 'string' &&
|
||||||
|
tier.model.trim().length > 0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isResolvableAccountProfile(value: unknown): value is ProfileMetadata {
|
||||||
|
return (
|
||||||
|
isObjectRecord(value) && typeof value.created === 'string' && value.created.trim().length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Profile Detector Class
|
* Profile Detector Class
|
||||||
*/
|
*/
|
||||||
@@ -133,28 +180,22 @@ class ProfileDetector {
|
|||||||
config: UnifiedConfig
|
config: UnifiedConfig
|
||||||
): ProfileDetectionResult | null {
|
): ProfileDetectionResult | null {
|
||||||
// Check CLIProxy variants first
|
// Check CLIProxy variants first
|
||||||
if (config.cliproxy?.variants?.[profileName]) {
|
if (hasOwnEntry(config.cliproxy?.variants, profileName)) {
|
||||||
const variant = config.cliproxy.variants[profileName];
|
const variant = config.cliproxy?.variants?.[profileName];
|
||||||
|
if (!isObjectRecord(variant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle composite variants
|
// Handle composite variants
|
||||||
if ('type' in variant && variant.type === 'composite') {
|
if ('type' in variant && variant.type === 'composite') {
|
||||||
const composite = variant as CompositeVariantConfig;
|
if (!isResolvableCompositeVariant(variant)) {
|
||||||
|
|
||||||
// Defensive: check for missing tiers or default_tier
|
|
||||||
if (!composite.tiers || !composite.default_tier) {
|
|
||||||
console.warn(
|
console.warn(
|
||||||
`[!] Warning: Composite variant '${profileName}' has missing tiers or default_tier`
|
`[!] Warning: Composite variant '${profileName}' has invalid or incomplete tiers/default_tier`
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const composite = variant;
|
||||||
const defaultTierConfig = composite.tiers[composite.default_tier];
|
const defaultTierConfig = composite.tiers[composite.default_tier];
|
||||||
if (!defaultTierConfig) {
|
|
||||||
console.warn(
|
|
||||||
`[!] Warning: Composite variant '${profileName}' missing config for default tier '${composite.default_tier}'`
|
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'cliproxy',
|
type: 'cliproxy',
|
||||||
@@ -170,6 +211,9 @@ class ProfileDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const singleVariant = variant as CLIProxyVariantConfig;
|
const singleVariant = variant as CLIProxyVariantConfig;
|
||||||
|
if (!isCLIProxyProvider(singleVariant.provider)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: 'cliproxy',
|
type: 'cliproxy',
|
||||||
name: profileName,
|
name: profileName,
|
||||||
@@ -182,11 +226,18 @@ class ProfileDetector {
|
|||||||
|
|
||||||
// Check API profiles (supports compatibility aliases, e.g. km -> kimi)
|
// Check API profiles (supports compatibility aliases, e.g. km -> kimi)
|
||||||
for (const candidate of getProfileLookupCandidates(profileName)) {
|
for (const candidate of getProfileLookupCandidates(profileName)) {
|
||||||
if (!config.profiles?.[candidate]) {
|
if (!hasOwnEntry(config.profiles, candidate)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = config.profiles[candidate];
|
const profile = config.profiles[candidate];
|
||||||
|
if (
|
||||||
|
!isObjectRecord(profile) ||
|
||||||
|
typeof profile.settings !== 'string' ||
|
||||||
|
profile.settings.trim().length === 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const settingsPath = profile.settings;
|
const settingsPath = profile.settings;
|
||||||
const settingsEnv = loadSettingsFromFile(settingsPath);
|
const settingsEnv = loadSettingsFromFile(settingsPath);
|
||||||
const viaLegacyAlias = isLegacyProfileAlias(profileName, candidate);
|
const viaLegacyAlias = isLegacyProfileAlias(profileName, candidate);
|
||||||
@@ -204,8 +255,11 @@ class ProfileDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check accounts
|
// Check accounts
|
||||||
if (config.accounts?.[profileName]) {
|
if (hasOwnEntry(config.accounts, profileName)) {
|
||||||
const account = config.accounts[profileName];
|
const account = config.accounts[profileName];
|
||||||
|
if (!isResolvableAccountProfile(account)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: 'account',
|
type: 'account',
|
||||||
name: profileName,
|
name: profileName,
|
||||||
@@ -225,6 +279,20 @@ class ProfileDetector {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hasUnifiedConfiguredProfile(profileName: string, config: UnifiedConfig): boolean {
|
||||||
|
return (
|
||||||
|
hasOwnEntry(config.cliproxy?.variants, profileName) ||
|
||||||
|
hasOwnEntry(config.profiles, profileName) ||
|
||||||
|
hasOwnEntry(config.accounts, profileName)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createConfiguredCollisionError(profileName: string, source: string): ConfigError {
|
||||||
|
return new ConfigError(
|
||||||
|
`Configured profile "${profileName}" in ${source} is invalid and cannot be resolved as a grandfathered profile.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read settings-based config (config.json)
|
* Read settings-based config (config.json)
|
||||||
*/
|
*/
|
||||||
@@ -264,11 +332,12 @@ class ProfileDetector {
|
|||||||
*
|
*
|
||||||
* Priority order:
|
* Priority order:
|
||||||
* 0. Hardcoded special runtime profiles (copilot, cursor)
|
* 0. Hardcoded special runtime profiles (copilot, cursor)
|
||||||
* 0.5. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen, ...)
|
* 0.5. Hardcoded CLIProxy profiles (except grandfathered xai/grok collisions)
|
||||||
* 1. Unified config profiles (if config.yaml exists or CCS_UNIFIED_CONFIG=1)
|
* 1. Unified config profiles (if config.yaml exists or CCS_UNIFIED_CONFIG=1)
|
||||||
* 2. User-defined CLIProxy variants (config.cliproxy section) [legacy]
|
* 2. User-defined CLIProxy variants (config.cliproxy section) [legacy]
|
||||||
* 3. Settings-based profiles (config.profiles section) [legacy]
|
* 3. Settings-based profiles (config.profiles section) [legacy]
|
||||||
* 4. Account-based profiles (profiles.json) [legacy]
|
* 4. Account-based profiles (profiles.json) [legacy]
|
||||||
|
* 5. Built-in xai/grok shortcut fallback when no configured profile exists
|
||||||
*/
|
*/
|
||||||
detectProfileType(profileName: string | null | undefined): ProfileDetectionResult {
|
detectProfileType(profileName: string | null | undefined): ProfileDetectionResult {
|
||||||
// Special case: 'default' means use default profile
|
// Special case: 'default' means use default profile
|
||||||
@@ -336,7 +405,8 @@ class ProfileDetector {
|
|||||||
|
|
||||||
// Priority 0.5: Check CLIProxy profiles (gemini, codex, agy, qwen, ...)
|
// Priority 0.5: Check CLIProxy profiles (gemini, codex, agy, qwen, ...)
|
||||||
const builtinProvider = resolveCLIProxyProviderShortcut(profileName);
|
const builtinProvider = resolveCLIProxyProviderShortcut(profileName);
|
||||||
if (builtinProvider) {
|
const shouldGrandfatherConfiguredProfile = builtinProvider === 'xai';
|
||||||
|
if (builtinProvider && !shouldGrandfatherConfiguredProfile) {
|
||||||
return {
|
return {
|
||||||
type: 'cliproxy',
|
type: 'cliproxy',
|
||||||
name: builtinProvider,
|
name: builtinProvider,
|
||||||
@@ -349,6 +419,12 @@ class ProfileDetector {
|
|||||||
if (unifiedConfig) {
|
if (unifiedConfig) {
|
||||||
const result = this.resolveFromUnifiedConfig(profileName, unifiedConfig);
|
const result = this.resolveFromUnifiedConfig(profileName, unifiedConfig);
|
||||||
if (result) return result;
|
if (result) return result;
|
||||||
|
if (
|
||||||
|
isGrandfatheredReservedProfileName(profileName) &&
|
||||||
|
this.hasUnifiedConfiguredProfile(profileName, unifiedConfig)
|
||||||
|
) {
|
||||||
|
throw this.createConfiguredCollisionError(profileName, 'config.yaml');
|
||||||
|
}
|
||||||
// Fall through to legacy if not found in unified config
|
// Fall through to legacy if not found in unified config
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,22 +433,36 @@ class ProfileDetector {
|
|||||||
const legacyTargetMap = (config as { profile_targets?: Record<string, TargetType> })
|
const legacyTargetMap = (config as { profile_targets?: Record<string, TargetType> })
|
||||||
.profile_targets;
|
.profile_targets;
|
||||||
|
|
||||||
if (config.cliproxy && config.cliproxy[profileName]) {
|
if (hasOwnEntry(config.cliproxy, profileName)) {
|
||||||
const variant = config.cliproxy[profileName];
|
const variant = config.cliproxy?.[profileName];
|
||||||
return {
|
if (!isObjectRecord(variant) || !isCLIProxyProvider(variant.provider as string)) {
|
||||||
type: 'cliproxy',
|
if (isGrandfatheredReservedProfileName(profileName)) {
|
||||||
name: profileName,
|
throw this.createConfiguredCollisionError(profileName, 'config.json');
|
||||||
target: variant.target,
|
}
|
||||||
provider: variant.provider as CLIProxyProfileName,
|
} else {
|
||||||
settingsPath: variant.settings,
|
return {
|
||||||
port: variant.port,
|
type: 'cliproxy',
|
||||||
};
|
name: profileName,
|
||||||
|
target: variant.target,
|
||||||
|
provider: variant.provider,
|
||||||
|
settingsPath: variant.settings,
|
||||||
|
port: variant.port,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 3: Check settings-based profiles (glm, km) - LEGACY FALLBACK
|
// Priority 3: Check settings-based profiles (glm, km) - LEGACY FALLBACK
|
||||||
if (config.profiles) {
|
if (config.profiles) {
|
||||||
for (const candidate of getProfileLookupCandidates(profileName)) {
|
for (const candidate of getProfileLookupCandidates(profileName)) {
|
||||||
if (!config.profiles[candidate]) {
|
if (!hasOwnEntry(config.profiles, candidate)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsPath = config.profiles[candidate];
|
||||||
|
if (typeof settingsPath !== 'string' || settingsPath.trim().length === 0) {
|
||||||
|
if (candidate === profileName && isGrandfatheredReservedProfileName(profileName)) {
|
||||||
|
throw this.createConfiguredCollisionError(profileName, 'config.json');
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,7 +470,7 @@ class ProfileDetector {
|
|||||||
return {
|
return {
|
||||||
type: 'settings',
|
type: 'settings',
|
||||||
name: profileName,
|
name: profileName,
|
||||||
settingsPath: config.profiles[candidate],
|
settingsPath,
|
||||||
target: legacyTargetMap?.[candidate],
|
target: legacyTargetMap?.[candidate],
|
||||||
message: viaLegacyAlias
|
message: viaLegacyAlias
|
||||||
? `Using legacy API profile "${candidate}" for "${profileName}".`
|
? `Using legacy API profile "${candidate}" for "${profileName}".`
|
||||||
@@ -392,11 +482,28 @@ class ProfileDetector {
|
|||||||
// Priority 4: Check account-based profiles (work, personal) - LEGACY FALLBACK
|
// Priority 4: Check account-based profiles (work, personal) - LEGACY FALLBACK
|
||||||
const profiles = this.readProfiles();
|
const profiles = this.readProfiles();
|
||||||
|
|
||||||
if (profiles.profiles && profiles.profiles[profileName]) {
|
if (hasOwnEntry(profiles.profiles, profileName)) {
|
||||||
|
const profile = profiles.profiles[profileName];
|
||||||
|
if (!isResolvableAccountProfile(profile)) {
|
||||||
|
if (isGrandfatheredReservedProfileName(profileName)) {
|
||||||
|
throw this.createConfiguredCollisionError(profileName, 'profiles.json');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
type: 'account',
|
||||||
|
name: profileName,
|
||||||
|
profile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// xai and grok became reserved when the built-in xAI provider shipped. Existing
|
||||||
|
// configured profiles keep working, while new installs still get the shortcuts.
|
||||||
|
if (builtinProvider) {
|
||||||
return {
|
return {
|
||||||
type: 'account',
|
type: 'cliproxy',
|
||||||
name: profileName,
|
name: builtinProvider,
|
||||||
profile: profiles.profiles[profileName],
|
provider: builtinProvider,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,25 +533,49 @@ class ProfileDetector {
|
|||||||
if (unifiedConfig?.default) {
|
if (unifiedConfig?.default) {
|
||||||
const result = this.resolveFromUnifiedConfig(unifiedConfig.default, unifiedConfig);
|
const result = this.resolveFromUnifiedConfig(unifiedConfig.default, unifiedConfig);
|
||||||
if (result) return result;
|
if (result) return result;
|
||||||
|
if (
|
||||||
|
isGrandfatheredReservedProfileName(unifiedConfig.default) &&
|
||||||
|
this.hasUnifiedConfiguredProfile(unifiedConfig.default, unifiedConfig)
|
||||||
|
) {
|
||||||
|
throw this.createConfiguredCollisionError(unifiedConfig.default, 'config.yaml');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if account-based default exists (legacy)
|
// Check if account-based default exists (legacy)
|
||||||
const profiles = this.readProfiles();
|
const profiles = this.readProfiles();
|
||||||
|
|
||||||
if (unifiedConfig?.default && profiles.profiles[unifiedConfig.default]) {
|
if (unifiedConfig?.default && hasOwnEntry(profiles.profiles, unifiedConfig.default)) {
|
||||||
return {
|
const profile = profiles.profiles[unifiedConfig.default];
|
||||||
type: 'account',
|
if (
|
||||||
name: unifiedConfig.default,
|
isGrandfatheredReservedProfileName(unifiedConfig.default) &&
|
||||||
profile: profiles.profiles[unifiedConfig.default],
|
!isResolvableAccountProfile(profile)
|
||||||
};
|
) {
|
||||||
|
throw this.createConfiguredCollisionError(unifiedConfig.default, 'profiles.json');
|
||||||
|
}
|
||||||
|
if (profile) {
|
||||||
|
return {
|
||||||
|
type: 'account',
|
||||||
|
name: unifiedConfig.default,
|
||||||
|
profile,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (profiles.default && profiles.profiles[profiles.default]) {
|
if (profiles.default && hasOwnEntry(profiles.profiles, profiles.default)) {
|
||||||
return {
|
const profile = profiles.profiles[profiles.default];
|
||||||
type: 'account',
|
if (
|
||||||
name: profiles.default,
|
isGrandfatheredReservedProfileName(profiles.default) &&
|
||||||
profile: profiles.profiles[profiles.default],
|
!isResolvableAccountProfile(profile)
|
||||||
};
|
) {
|
||||||
|
throw this.createConfiguredCollisionError(profiles.default, 'profiles.json');
|
||||||
|
}
|
||||||
|
if (profile) {
|
||||||
|
return {
|
||||||
|
type: 'account',
|
||||||
|
name: profiles.default,
|
||||||
|
profile,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if settings-based default exists
|
// Check if settings-based default exists
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
isCLIProxyProvider,
|
isCLIProxyProvider,
|
||||||
} from '../../cliproxy/provider-capabilities';
|
} from '../../cliproxy/provider-capabilities';
|
||||||
import { syncToLocalConfig } from '../../cliproxy/sync/local-config-sync';
|
import { syncToLocalConfig } from '../../cliproxy/sync/local-config-sync';
|
||||||
|
import { canOverwriteGrandfatheredReservedProfileName } from '../../config/reserved-names';
|
||||||
import type { TargetType } from '../../targets/target-adapter';
|
import type { TargetType } from '../../targets/target-adapter';
|
||||||
import { color, dim, fail, header, info, infoBox, initUI, warn } from '../../utils/ui';
|
import { color, dim, fail, header, info, infoBox, initUI, warn } from '../../utils/ui';
|
||||||
import { InteractivePrompt } from '../../utils/prompt';
|
import { InteractivePrompt } from '../../utils/prompt';
|
||||||
@@ -63,7 +64,8 @@ function showPresetDeprecationNotice(presetId?: string): void {
|
|||||||
|
|
||||||
async function resolveProfileName(
|
async function resolveProfileName(
|
||||||
providedName: string | undefined,
|
providedName: string | undefined,
|
||||||
preset: ProviderPreset | null
|
preset: ProviderPreset | null,
|
||||||
|
force: boolean
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const name = providedName || preset?.defaultProfileName;
|
const name = providedName || preset?.defaultProfileName;
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -73,7 +75,11 @@ async function resolveProfileName(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const error = validateApiName(name);
|
const error = validateApiName(name);
|
||||||
if (error) {
|
const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(name, {
|
||||||
|
force,
|
||||||
|
exists: apiProfileExists(name),
|
||||||
|
});
|
||||||
|
if (error && !canOverwriteGrandfatheredProfile) {
|
||||||
console.log(fail(error));
|
console.log(fail(error));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -83,11 +89,19 @@ async function resolveProfileName(
|
|||||||
async function resolveCliproxyProfileName(
|
async function resolveCliproxyProfileName(
|
||||||
provider: string,
|
provider: string,
|
||||||
providedName: string | undefined,
|
providedName: string | undefined,
|
||||||
yes: boolean | undefined
|
yes: boolean | undefined,
|
||||||
|
force: boolean
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
if (providedName) {
|
if (providedName) {
|
||||||
const error = validateApiName(providedName);
|
const error = validateApiName(providedName);
|
||||||
if (error) {
|
const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(
|
||||||
|
providedName,
|
||||||
|
{
|
||||||
|
force,
|
||||||
|
exists: apiProfileExists(providedName),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (error && !canOverwriteGrandfatheredProfile) {
|
||||||
console.log(fail(error));
|
console.log(fail(error));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -368,7 +382,8 @@ export async function handleApiCreateCommand(args: string[]): Promise<void> {
|
|||||||
const name = await resolveCliproxyProfileName(
|
const name = await resolveCliproxyProfileName(
|
||||||
cliproxyProvider,
|
cliproxyProvider,
|
||||||
parsedArgs.name,
|
parsedArgs.name,
|
||||||
parsedArgs.yes
|
parsedArgs.yes,
|
||||||
|
parsedArgs.force === true
|
||||||
);
|
);
|
||||||
const target = await resolveDefaultTarget(null, parsedArgs.target, parsedArgs.yes);
|
const target = await resolveDefaultTarget(null, parsedArgs.target, parsedArgs.yes);
|
||||||
|
|
||||||
@@ -456,7 +471,7 @@ export async function handleApiCreateCommand(args: string[]): Promise<void> {
|
|||||||
|
|
||||||
showPresetDeprecationNotice(parsedArgs.preset);
|
showPresetDeprecationNotice(parsedArgs.preset);
|
||||||
const preset = resolvePresetOrExit(parsedArgs.preset);
|
const preset = resolvePresetOrExit(parsedArgs.preset);
|
||||||
const name = await resolveProfileName(parsedArgs.name, preset);
|
const name = await resolveProfileName(parsedArgs.name, preset, parsedArgs.force === true);
|
||||||
|
|
||||||
if (apiProfileExists(name) && !parsedArgs.force) {
|
if (apiProfileExists(name) && !parsedArgs.force) {
|
||||||
console.log(fail(`API '${name}' already exists`));
|
console.log(fail(`API '${name}' already exists`));
|
||||||
@@ -503,7 +518,8 @@ export async function handleApiCreateCommand(args: string[]): Promise<void> {
|
|||||||
finalModels,
|
finalModels,
|
||||||
target,
|
target,
|
||||||
undefined,
|
undefined,
|
||||||
parsedArgs.extraModels
|
parsedArgs.extraModels,
|
||||||
|
{ force: parsedArgs.force === true }
|
||||||
);
|
);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
console.log(fail(`Failed to create API profile: ${result.error}`));
|
console.log(fail(`Failed to create API profile: ${result.error}`));
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
} from '../../cliproxy/services';
|
} from '../../cliproxy/services';
|
||||||
import { DEFAULT_BACKEND } from '../../cliproxy/binary/platform-detector';
|
import { DEFAULT_BACKEND } from '../../cliproxy/binary/platform-detector';
|
||||||
import { CompositeTierConfig } from '../../config/unified-config-types';
|
import { CompositeTierConfig } from '../../config/unified-config-types';
|
||||||
|
import { canOverwriteGrandfatheredReservedProfileName } from '../../config/reserved-names';
|
||||||
import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity';
|
import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity';
|
||||||
import { isUnifiedMode } from '../../config/config-loader-facade';
|
import { isUnifiedMode } from '../../config/config-loader-facade';
|
||||||
|
|
||||||
@@ -120,6 +121,24 @@ export function parseProfileArgs(args: string[]): CliproxyProfileArgs {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateVariantCreateName(
|
||||||
|
name: string,
|
||||||
|
force: boolean,
|
||||||
|
exists: boolean = variantExists(name)
|
||||||
|
): string | null {
|
||||||
|
const error = validateProfileName(name);
|
||||||
|
if (
|
||||||
|
error &&
|
||||||
|
!canOverwriteGrandfatheredReservedProfileName(name, {
|
||||||
|
force,
|
||||||
|
exists,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function formatModelOption(model: ModelEntry): string {
|
function formatModelOption(model: ModelEntry): string {
|
||||||
const tierBadge =
|
const tierBadge =
|
||||||
model.tier === 'ultra'
|
model.tier === 'ultra'
|
||||||
@@ -306,7 +325,7 @@ export async function handleCreate(
|
|||||||
validate: validateProfileName,
|
validate: validateProfileName,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const error = validateProfileName(name);
|
const error = validateVariantCreateName(name, parsedArgs.force === true);
|
||||||
if (error) {
|
if (error) {
|
||||||
console.log(fail(error));
|
console.log(fail(error));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { ConfigError } from '../errors/error-types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reserved profile names that cannot be used for user-defined profiles.
|
* Reserved profile names that cannot be used for user-defined profiles.
|
||||||
* These names are reserved for CLIProxy providers and CLI commands.
|
* These names are reserved for CLIProxy providers and CLI commands.
|
||||||
@@ -6,6 +8,8 @@ export const RESERVED_PROFILE_NAMES = [
|
|||||||
// CLIProxy providers (built-in OAuth)
|
// CLIProxy providers (built-in OAuth)
|
||||||
'gemini',
|
'gemini',
|
||||||
'codex',
|
'codex',
|
||||||
|
'xai',
|
||||||
|
'grok',
|
||||||
'agy',
|
'agy',
|
||||||
'qwen',
|
'qwen',
|
||||||
'iflow',
|
'iflow',
|
||||||
@@ -32,6 +36,14 @@ export const RESERVED_PROFILE_NAMES = [
|
|||||||
|
|
||||||
export type ReservedProfileName = (typeof RESERVED_PROFILE_NAMES)[number];
|
export type ReservedProfileName = (typeof RESERVED_PROFILE_NAMES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reserved names that may still identify profiles created before the
|
||||||
|
* corresponding built-in provider shortcuts shipped.
|
||||||
|
*/
|
||||||
|
export const GRANDFATHERED_RESERVED_PROFILE_NAMES = ['xai', 'grok'] as const;
|
||||||
|
export type GrandfatheredReservedProfileName =
|
||||||
|
(typeof GRANDFATHERED_RESERVED_PROFILE_NAMES)[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Windows reserved device names - cannot be used as filenames on Windows.
|
* Windows reserved device names - cannot be used as filenames on Windows.
|
||||||
* Case-insensitive on Windows filesystem.
|
* Case-insensitive on Windows filesystem.
|
||||||
@@ -70,6 +82,27 @@ export function isReservedName(name: string): boolean {
|
|||||||
return RESERVED_PROFILE_NAMES.includes(name.toLowerCase() as ReservedProfileName);
|
return RESERVED_PROFILE_NAMES.includes(name.toLowerCase() as ReservedProfileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether a reserved name may identify an existing grandfathered profile.
|
||||||
|
* This does not permit creating a new profile with the name.
|
||||||
|
*/
|
||||||
|
export function isGrandfatheredReservedProfileName(name: string): boolean {
|
||||||
|
return GRANDFATHERED_RESERVED_PROFILE_NAMES.includes(
|
||||||
|
name.toLowerCase() as GrandfatheredReservedProfileName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allow a reserved grandfathered name only for an explicit overwrite of the
|
||||||
|
* exact profile that already owns it.
|
||||||
|
*/
|
||||||
|
export function canOverwriteGrandfatheredReservedProfileName(
|
||||||
|
name: string,
|
||||||
|
options: { force: boolean; exists: boolean }
|
||||||
|
): boolean {
|
||||||
|
return options.force && options.exists && isGrandfatheredReservedProfileName(name);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a name is a Windows reserved device name.
|
* Check if a name is a Windows reserved device name.
|
||||||
* These cause filesystem errors on Windows systems.
|
* These cause filesystem errors on Windows systems.
|
||||||
@@ -89,7 +122,7 @@ export function isWindowsReservedName(name: string): boolean {
|
|||||||
*/
|
*/
|
||||||
export function validateProfileName(name: string): void {
|
export function validateProfileName(name: string): void {
|
||||||
if (isReservedName(name)) {
|
if (isReservedName(name)) {
|
||||||
throw new Error(
|
throw new ConfigError(
|
||||||
`Profile name '${name}' is reserved. Reserved names: ${RESERVED_PROFILE_NAMES.join(', ')}`
|
`Profile name '${name}' is reserved. Reserved names: ${RESERVED_PROFILE_NAMES.join(', ')}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
apiProfileExists,
|
apiProfileExists,
|
||||||
listCliproxyBridgeProviders,
|
listCliproxyBridgeProviders,
|
||||||
listApiProfiles,
|
listApiProfiles,
|
||||||
validateApiName,
|
validateApiNameSyntax,
|
||||||
} from '../../api/services';
|
} from '../../api/services';
|
||||||
import { normalizeDroidProvider } from '../../targets/droid-provider';
|
import { normalizeDroidProvider } from '../../targets/droid-provider';
|
||||||
import { getPersistedTargetChoices } from '../../targets/target-metadata';
|
import { getPersistedTargetChoices } from '../../targets/target-metadata';
|
||||||
@@ -284,7 +284,7 @@ router.post('/orphans/register', (req: Request, res: Response): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
names = payload.names.map((value) => value.trim());
|
names = payload.names.map((value) => value.trim());
|
||||||
const invalidName = names.find((name) => validateApiName(name) !== null);
|
const invalidName = names.find((name) => validateApiNameSyntax(name) !== null);
|
||||||
if (invalidName) {
|
if (invalidName) {
|
||||||
res.status(400).json({ error: `Invalid profile name in names: ${invalidName}` });
|
res.status(400).json({ error: `Invalid profile name in names: ${invalidName}` });
|
||||||
return;
|
return;
|
||||||
@@ -321,7 +321,7 @@ router.post('/:name/copy', (req: Request, res: Response): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { name } = req.params;
|
const { name } = req.params;
|
||||||
const sourceNameError = validateApiName(name);
|
const sourceNameError = validateApiNameSyntax(name);
|
||||||
if (sourceNameError) {
|
if (sourceNameError) {
|
||||||
res.status(400).json({ error: sourceNameError });
|
res.status(400).json({ error: sourceNameError });
|
||||||
return;
|
return;
|
||||||
@@ -359,7 +359,7 @@ router.post('/:name/export', (req: Request, res: Response): void => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { name } = req.params;
|
const { name } = req.params;
|
||||||
const profileNameError = validateApiName(name);
|
const profileNameError = validateApiNameSyntax(name);
|
||||||
if (profileNameError) {
|
if (profileNameError) {
|
||||||
res.status(400).json({ error: profileNameError });
|
res.status(400).json({ error: profileNameError });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ describe('account context helpers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects reserved account profile names', () => {
|
it('rejects reserved account profile names', () => {
|
||||||
expect(isValidAccountProfileName('default')).toBe(false);
|
for (const name of ['default', 'Default', 'xai', 'XAI', 'grok', 'GROK']) {
|
||||||
expect(isValidAccountProfileName('Default')).toBe(false);
|
expect(isValidAccountProfileName(name)).toBe(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to default shared group for invalid persisted metadata', () => {
|
it('falls back to default shared group for invalid persisted metadata', () => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
importApiProfileBundle,
|
importApiProfileBundle,
|
||||||
registerApiProfileOrphans,
|
registerApiProfileOrphans,
|
||||||
} from '../../../src/api/services/profile-lifecycle-service';
|
} from '../../../src/api/services/profile-lifecycle-service';
|
||||||
|
import { createApiProfile } from '../../../src/api/services/profile-writer';
|
||||||
import {
|
import {
|
||||||
loadConfigSafe,
|
loadConfigSafe,
|
||||||
runWithScopedConfigDir,
|
runWithScopedConfigDir,
|
||||||
@@ -68,7 +69,7 @@ describe('profile lifecycle service', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('discovers only API profile orphans (skips registered and reserved names)', async () => {
|
it('discovers grandfathered xai/grok orphans while skipping other reserved names', async () => {
|
||||||
const ccsDir = path.join(tempHome, '.ccs');
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
fs.mkdirSync(ccsDir, { recursive: true });
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
|
||||||
@@ -101,9 +102,59 @@ describe('profile lifecycle service', () => {
|
|||||||
2
|
2
|
||||||
) + '\n'
|
) + '\n'
|
||||||
);
|
);
|
||||||
|
for (const profileName of ['xai', 'grok']) {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, `${profileName}.settings.json`),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://api.example.com',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'token',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
) + '\n'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await runInScopedCcsDir(() => discoverApiProfileOrphans());
|
const result = await runInScopedCcsDir(() => discoverApiProfileOrphans());
|
||||||
expect(result.orphans.map((orphan) => orphan.name)).toEqual(['extra']);
|
expect(result.orphans.map((orphan) => orphan.name).sort()).toEqual(['extra', 'grok', 'xai']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers a grandfathered xai orphan without opening general reserved-name creation', async () => {
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'xai.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'
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'config.json'),
|
||||||
|
JSON.stringify({ profiles: {} }, null, 2) + '\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await runInScopedCcsDir(() =>
|
||||||
|
registerApiProfileOrphans({ names: ['xai', 'gemini'] })
|
||||||
|
);
|
||||||
|
const config = await runInScopedCcsDir(() => loadConfigSafe());
|
||||||
|
|
||||||
|
expect(result.registered).toEqual(['xai']);
|
||||||
|
expect(result.skipped).toEqual([]);
|
||||||
|
expect(config.profiles.xai).toBe('~/.ccs/xai.settings.json');
|
||||||
|
expect(config.profiles.gemini).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats explicit empty names list as no-op during orphan registration', async () => {
|
it('treats explicit empty names list as no-op during orphan registration', async () => {
|
||||||
@@ -140,7 +191,10 @@ describe('profile lifecycle service', () => {
|
|||||||
2
|
2
|
||||||
) + '\n'
|
) + '\n'
|
||||||
);
|
);
|
||||||
fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n');
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'config.json'),
|
||||||
|
JSON.stringify({ profiles: {} }, null, 2) + '\n'
|
||||||
|
);
|
||||||
|
|
||||||
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation(() => {
|
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation(() => {
|
||||||
throw new Error('copy failed');
|
throw new Error('copy failed');
|
||||||
@@ -168,8 +222,15 @@ describe('profile lifecycle service', () => {
|
|||||||
2
|
2
|
||||||
) + '\n'
|
) + '\n'
|
||||||
);
|
);
|
||||||
fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n');
|
fs.writeFileSync(
|
||||||
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 12\nwebsearch:\n enabled: false\n', 'utf8');
|
path.join(ccsDir, 'config.json'),
|
||||||
|
JSON.stringify({ profiles: {} }, null, 2) + '\n'
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'config.yaml'),
|
||||||
|
'version: 12\nwebsearch:\n enabled: false\n',
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
const originalCopyFileSync = fs.copyFileSync.bind(fs);
|
const originalCopyFileSync = fs.copyFileSync.bind(fs);
|
||||||
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation((source, destination) => {
|
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation((source, destination) => {
|
||||||
@@ -195,7 +256,10 @@ describe('profile lifecycle service', () => {
|
|||||||
|
|
||||||
const malformedPath = path.join(ccsDir, 'bad.settings.json');
|
const malformedPath = path.join(ccsDir, 'bad.settings.json');
|
||||||
fs.writeFileSync(malformedPath, '{ invalid json', 'utf8');
|
fs.writeFileSync(malformedPath, '{ invalid json', 'utf8');
|
||||||
fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n');
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'config.json'),
|
||||||
|
JSON.stringify({ profiles: {} }, null, 2) + '\n'
|
||||||
|
);
|
||||||
|
|
||||||
const result = await runInScopedCcsDir(() =>
|
const result = await runInScopedCcsDir(() =>
|
||||||
registerApiProfileOrphans({ names: ['bad'], force: true })
|
registerApiProfileOrphans({ names: ['bad'], force: true })
|
||||||
@@ -247,6 +311,173 @@ describe('profile lifecycle service', () => {
|
|||||||
expect(result.error).toContain('Invalid source profile name');
|
expect(result.error).toContain('Invalid source profile name');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('copies and exports grandfathered sources but rejects reserved destinations', async () => {
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'config.json'),
|
||||||
|
JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n'
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'xai.settings.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } },
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
) + '\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
const exported = await runInScopedCcsDir(() => exportApiProfile('xai'));
|
||||||
|
const copied = await runInScopedCcsDir(() => copyApiProfile('xai', 'xai-backup'));
|
||||||
|
const rejectedDestination = await runInScopedCcsDir(() => copyApiProfile('xai', 'GROK'));
|
||||||
|
|
||||||
|
expect(exported.success).toBe(true);
|
||||||
|
expect(exported.bundle?.profile.name).toBe('xai');
|
||||||
|
expect(copied.success).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(ccsDir, 'xai-backup.settings.json'))).toBe(true);
|
||||||
|
expect(rejectedDestination.success).toBe(false);
|
||||||
|
expect(rejectedDestination.error).toContain('reserved name');
|
||||||
|
expect(fs.existsSync(path.join(ccsDir, 'GROK.settings.json'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows force copy only onto an exact existing grandfathered destination', async () => {
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'config.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
profiles: {
|
||||||
|
source: '~/.ccs/source.settings.json',
|
||||||
|
xai: '~/.ccs/xai.settings.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
) + '\n'
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'source.settings.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://source.example.com',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'source-token',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
) + '\n'
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'xai.settings.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{ env: { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_AUTH_TOKEN: 'old' } },
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
) + '\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
const nonForced = await runInScopedCcsDir(() => copyApiProfile('source', 'xai'));
|
||||||
|
const forced = await runInScopedCcsDir(() => copyApiProfile('source', 'xai', { force: true }));
|
||||||
|
const absentForced = await runInScopedCcsDir(() =>
|
||||||
|
copyApiProfile('source', 'grok', { force: true })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(nonForced.success).toBe(false);
|
||||||
|
expect(nonForced.error).toContain('reserved name');
|
||||||
|
expect(forced.success).toBe(true);
|
||||||
|
expect(absentForced.success).toBe(false);
|
||||||
|
expect(absentForced.error).toContain('reserved name');
|
||||||
|
expect(fs.existsSync(path.join(ccsDir, 'grok.settings.json'))).toBe(false);
|
||||||
|
|
||||||
|
const copiedSettings = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(ccsDir, 'xai.settings.json'), 'utf8')
|
||||||
|
) as {
|
||||||
|
env: Record<string, string>;
|
||||||
|
};
|
||||||
|
expect(copiedSettings.env.ANTHROPIC_BASE_URL).toBe('https://source.example.com');
|
||||||
|
expect(copiedSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('source-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an absent reserved name even when profile creation is forced', async () => {
|
||||||
|
const result = await runInScopedCcsDir(() =>
|
||||||
|
createApiProfile(
|
||||||
|
'XAI',
|
||||||
|
'https://api.example.com',
|
||||||
|
'token',
|
||||||
|
{
|
||||||
|
default: 'model',
|
||||||
|
opus: 'model',
|
||||||
|
sonnet: 'model',
|
||||||
|
haiku: 'model',
|
||||||
|
},
|
||||||
|
'claude',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ force: true }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('reserved name');
|
||||||
|
expect(fs.existsSync(path.join(getScopedCcsDir(), 'XAI.settings.json'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows force to repair an exact existing xai API profile', async () => {
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'config.json'),
|
||||||
|
JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n'
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'xai.settings.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{ env: { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_AUTH_TOKEN: 'old' } },
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
) + '\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
const nonForced = await runInScopedCcsDir(() =>
|
||||||
|
createApiProfile('xai', 'https://new.example.com', 'new-token', {
|
||||||
|
default: 'model',
|
||||||
|
opus: 'model',
|
||||||
|
sonnet: 'model',
|
||||||
|
haiku: 'model',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const forced = await runInScopedCcsDir(() =>
|
||||||
|
createApiProfile(
|
||||||
|
'xai',
|
||||||
|
'https://new.example.com',
|
||||||
|
'new-token',
|
||||||
|
{
|
||||||
|
default: 'model',
|
||||||
|
opus: 'model',
|
||||||
|
sonnet: 'model',
|
||||||
|
haiku: 'model',
|
||||||
|
},
|
||||||
|
'claude',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ force: true }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(nonForced.success).toBe(false);
|
||||||
|
expect(nonForced.error).toContain('reserved name');
|
||||||
|
expect(forced.success).toBe(true);
|
||||||
|
const settings = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(ccsDir, 'xai.settings.json'), 'utf8')
|
||||||
|
) as {
|
||||||
|
env: Record<string, string>;
|
||||||
|
};
|
||||||
|
expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://new.example.com');
|
||||||
|
expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('new-token');
|
||||||
|
});
|
||||||
|
|
||||||
it('rolls back copied settings when local WebSearch tool setup fails', async () => {
|
it('rolls back copied settings when local WebSearch tool setup fails', async () => {
|
||||||
const ccsDir = path.join(tempHome, '.ccs');
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
fs.mkdirSync(ccsDir, { recursive: true });
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
@@ -294,6 +525,62 @@ describe('profile lifecycle service', () => {
|
|||||||
expect(result.error).toContain('Invalid bundle profile target');
|
expect(result.error).toContain('Invalid bundle profile target');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('allows force import only for an exact existing grandfathered profile', async () => {
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'config.json'),
|
||||||
|
JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n'
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'xai.settings.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{ env: { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_AUTH_TOKEN: 'old' } },
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
) + '\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
const bundle = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
profile: { name: 'xai', target: 'claude' },
|
||||||
|
settings: {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_BASE_URL: 'https://repaired.example.com',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'repaired-token',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const nonForced = await runInScopedCcsDir(() => importApiProfileBundle(bundle));
|
||||||
|
const forced = await runInScopedCcsDir(() => importApiProfileBundle(bundle, { force: true }));
|
||||||
|
const absentForced = await runInScopedCcsDir(() =>
|
||||||
|
importApiProfileBundle(
|
||||||
|
{
|
||||||
|
...bundle,
|
||||||
|
profile: { ...bundle.profile, name: 'grok' },
|
||||||
|
},
|
||||||
|
{ force: true }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(nonForced.success).toBe(false);
|
||||||
|
expect(nonForced.error).toContain('reserved name');
|
||||||
|
expect(forced.success).toBe(true);
|
||||||
|
expect(absentForced.success).toBe(false);
|
||||||
|
expect(absentForced.error).toContain('reserved name');
|
||||||
|
expect(fs.existsSync(path.join(ccsDir, 'grok.settings.json'))).toBe(false);
|
||||||
|
|
||||||
|
const settings = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(ccsDir, 'xai.settings.json'), 'utf8')
|
||||||
|
) as {
|
||||||
|
env: Record<string, string>;
|
||||||
|
};
|
||||||
|
expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://repaired.example.com');
|
||||||
|
expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('repaired-token');
|
||||||
|
});
|
||||||
|
|
||||||
it('rolls back imported settings when local WebSearch tool setup fails', async () => {
|
it('rolls back imported settings when local WebSearch tool setup fails', async () => {
|
||||||
const ccsDir = path.join(tempHome, '.ccs');
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
fs.mkdirSync(ccsDir, { recursive: true });
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import * as childProcess from 'child_process';
|
||||||
import ProfileRegistry from '../../src/auth/profile-registry';
|
import ProfileRegistry from '../../src/auth/profile-registry';
|
||||||
import InstanceManager from '../../src/management/instance-manager';
|
import InstanceManager from '../../src/management/instance-manager';
|
||||||
import { handleResources } from '../../src/auth/commands/resources-command';
|
import { handleResources } from '../../src/auth/commands/resources-command';
|
||||||
@@ -227,6 +228,103 @@ describe('auth resources command', () => {
|
|||||||
expect(output).toContain('Unknown option(s): "--mode"');
|
expect(output).toContain('Unknown option(s): "--mode"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects reserved xai and grok account profile names on auth create', async () => {
|
||||||
|
for (const profileName of ['xai', 'GROK']) {
|
||||||
|
const registry = new ProfileRegistry();
|
||||||
|
const instanceMgr = new InstanceManager();
|
||||||
|
|
||||||
|
const output = await expectProfileExit(() =>
|
||||||
|
handleCreate(
|
||||||
|
{
|
||||||
|
registry,
|
||||||
|
instanceMgr,
|
||||||
|
version: 'test',
|
||||||
|
},
|
||||||
|
[profileName]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(output).toContain('Invalid profile name');
|
||||||
|
expect(registry.hasAccountUnified(profileName)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects --force for an absent reserved account profile', async () => {
|
||||||
|
const registry = new ProfileRegistry();
|
||||||
|
const instanceMgr = new InstanceManager();
|
||||||
|
|
||||||
|
const output = await expectProfileExit(() =>
|
||||||
|
handleCreate(
|
||||||
|
{
|
||||||
|
registry,
|
||||||
|
instanceMgr,
|
||||||
|
version: 'test',
|
||||||
|
},
|
||||||
|
['xai', '--force']
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(output).toContain('Invalid profile name');
|
||||||
|
expect(registry.hasAccountUnified('xai')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows --force to repair an exact existing xai account profile', async () => {
|
||||||
|
const ccsDir = path.join(tempRoot, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'config.yaml'),
|
||||||
|
[
|
||||||
|
'version: 12',
|
||||||
|
'accounts:',
|
||||||
|
' xai:',
|
||||||
|
' created: "2026-02-01T00:00:00.000Z"',
|
||||||
|
' last_used: null',
|
||||||
|
'profiles: {}',
|
||||||
|
'cliproxy:',
|
||||||
|
' oauth_accounts: {}',
|
||||||
|
' providers: {}',
|
||||||
|
' variants: {}',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const claudeDetector = await import('../../src/utils/claude-detector');
|
||||||
|
const cliSpy = spyOn(claudeDetector, 'getClaudeCliInfo').mockReturnValue({
|
||||||
|
path: '/usr/bin/claude',
|
||||||
|
isWindows: false,
|
||||||
|
needsShell: false,
|
||||||
|
});
|
||||||
|
const child = {
|
||||||
|
on: () => child,
|
||||||
|
} as unknown as ReturnType<typeof childProcess.spawn>;
|
||||||
|
const spawnSpy = spyOn(childProcess, 'spawn').mockReturnValue(child);
|
||||||
|
const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue(
|
||||||
|
path.join(ccsDir, 'instances', 'xai')
|
||||||
|
);
|
||||||
|
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const registry = new ProfileRegistry();
|
||||||
|
const instanceMgr = new InstanceManager();
|
||||||
|
await handleCreate(
|
||||||
|
{
|
||||||
|
registry,
|
||||||
|
instanceMgr,
|
||||||
|
version: 'test',
|
||||||
|
},
|
||||||
|
['xai', '--force']
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(spawnSpy).toHaveBeenCalled();
|
||||||
|
expect(registry.hasAccountUnified('xai')).toBe(true);
|
||||||
|
} finally {
|
||||||
|
logSpy.mockRestore();
|
||||||
|
ensureSpy.mockRestore();
|
||||||
|
spawnSpy.mockRestore();
|
||||||
|
cliSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects --mode on non-resources auth commands instead of silently ignoring it', async () => {
|
it('rejects --mode on non-resources auth commands instead of silently ignoring it', async () => {
|
||||||
const registry = new ProfileRegistry();
|
const registry = new ProfileRegistry();
|
||||||
const instanceMgr = new InstanceManager();
|
const instanceMgr = new InstanceManager();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as path from 'path';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import ProfileDetector, { loadSettingsFromFile } from '../../../src/auth/profile-detector';
|
import ProfileDetector, { loadSettingsFromFile } from '../../../src/auth/profile-detector';
|
||||||
import * as unifiedConfigLoader from '../../../src/config/unified-config-loader';
|
import * as unifiedConfigLoader from '../../../src/config/unified-config-loader';
|
||||||
|
import { ConfigError } from '../../../src/errors/error-types';
|
||||||
|
|
||||||
describe('ProfileDetector', () => {
|
describe('ProfileDetector', () => {
|
||||||
const tempDir = path.join(os.tmpdir(), `ccs-test-profile-detector-${process.pid}`);
|
const tempDir = path.join(os.tmpdir(), `ccs-test-profile-detector-${process.pid}`);
|
||||||
@@ -101,11 +102,186 @@ describe('ProfileDetector', () => {
|
|||||||
expect(detector.detectProfileType('qoder').provider).toBe('qoder');
|
expect(detector.detectProfileType('qoder').provider).toBe('qoder');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should resolve the grok CLI alias to canonical xai routing', () => {
|
it('should resolve xai and grok to built-in xai routing when no configured profile exists', () => {
|
||||||
const result = detector.detectProfileType('grok');
|
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false);
|
||||||
expect(result.type).toBe('cliproxy');
|
const existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||||
expect(result.name).toBe('xai');
|
|
||||||
expect(result.provider).toBe('xai');
|
try {
|
||||||
|
for (const shortcut of ['xai', 'grok']) {
|
||||||
|
const result = detector.detectProfileType(shortcut);
|
||||||
|
expect(result.type).toBe('cliproxy');
|
||||||
|
expect(result.name).toBe('xai');
|
||||||
|
expect(result.provider).toBe('xai');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isUnifiedModeSpy.mockRestore();
|
||||||
|
existsSyncSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve configured xai and grok profiles from unified config', () => {
|
||||||
|
const xaiSettingsPath = path.join(tempDir, 'xai.settings.json');
|
||||||
|
const grokSettingsPath = path.join(tempDir, 'grok.settings.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
xaiSettingsPath,
|
||||||
|
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://legacy-xai.example' } })
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
grokSettingsPath,
|
||||||
|
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://legacy-grok.example' } })
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockUnifiedConfig = {
|
||||||
|
version: 2,
|
||||||
|
profiles: {
|
||||||
|
xai: { settings: xaiSettingsPath, type: 'api' },
|
||||||
|
grok: { settings: grokSettingsPath, type: 'api' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true);
|
||||||
|
const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue(
|
||||||
|
mockUnifiedConfig as any
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const profileName of ['xai', 'grok']) {
|
||||||
|
const result = detector.detectProfileType(profileName);
|
||||||
|
expect(result.type).toBe('settings');
|
||||||
|
expect(result.name).toBe(profileName);
|
||||||
|
expect(result.settingsPath).toBe(
|
||||||
|
profileName === 'xai' ? xaiSettingsPath : grokSettingsPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isUnifiedModeSpy.mockRestore();
|
||||||
|
loadUnifiedConfigSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject a malformed unified xai composite instead of using the built-in shortcut', () => {
|
||||||
|
const mockUnifiedConfig = {
|
||||||
|
version: 12,
|
||||||
|
profiles: {},
|
||||||
|
accounts: {},
|
||||||
|
cliproxy: {
|
||||||
|
variants: {
|
||||||
|
xai: {
|
||||||
|
type: 'composite',
|
||||||
|
default_tier: 'opus',
|
||||||
|
tiers: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true);
|
||||||
|
const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue(
|
||||||
|
mockUnifiedConfig as any
|
||||||
|
);
|
||||||
|
const warningSpy = spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(() => detector.detectProfileType('xai')).toThrow(ConfigError);
|
||||||
|
expect(() => detector.detectProfileType('xai')).toThrow(
|
||||||
|
/Configured profile "xai" in config.yaml is invalid/
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
warningSpy.mockRestore();
|
||||||
|
isUnifiedModeSpy.mockRestore();
|
||||||
|
loadUnifiedConfigSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject a malformed legacy grok variant instead of using the built-in shortcut', () => {
|
||||||
|
const originalCcsHome = process.env.CCS_HOME;
|
||||||
|
process.env.CCS_HOME = tempDir;
|
||||||
|
const ccsDir = path.join(tempDir, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'config.json'),
|
||||||
|
JSON.stringify({ profiles: {}, cliproxy: { grok: {} } }, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localDetector = new ProfileDetector();
|
||||||
|
expect(() => localDetector.detectProfileType('grok')).toThrow(ConfigError);
|
||||||
|
expect(() => localDetector.detectProfileType('grok')).toThrow(
|
||||||
|
/Configured profile "grok" in config.json is invalid/
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isUnifiedModeSpy.mockRestore();
|
||||||
|
if (originalCcsHome !== undefined) {
|
||||||
|
process.env.CCS_HOME = originalCcsHome;
|
||||||
|
} else {
|
||||||
|
delete process.env.CCS_HOME;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject a malformed unified xai default instead of using the native default', () => {
|
||||||
|
const mockUnifiedConfig = {
|
||||||
|
version: 12,
|
||||||
|
default: 'xai',
|
||||||
|
profiles: {},
|
||||||
|
accounts: {},
|
||||||
|
cliproxy: {
|
||||||
|
variants: {
|
||||||
|
xai: {
|
||||||
|
type: 'composite',
|
||||||
|
default_tier: 'opus',
|
||||||
|
tiers: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true);
|
||||||
|
const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue(
|
||||||
|
mockUnifiedConfig as any
|
||||||
|
);
|
||||||
|
const warningSpy = spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(() => detector.resolveDefaultProfileResult()).toThrow(ConfigError);
|
||||||
|
expect(() => detector.resolveDefaultProfileResult()).toThrow(
|
||||||
|
/Configured profile "xai" in config.yaml is invalid/
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
warningSpy.mockRestore();
|
||||||
|
isUnifiedModeSpy.mockRestore();
|
||||||
|
loadUnifiedConfigSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject a malformed legacy xai account default', () => {
|
||||||
|
const originalCcsHome = process.env.CCS_HOME;
|
||||||
|
process.env.CCS_HOME = tempDir;
|
||||||
|
const ccsDir = path.join(tempDir, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'profiles.json'),
|
||||||
|
JSON.stringify({ default: 'xai', profiles: { xai: {} } }, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const localDetector = new ProfileDetector();
|
||||||
|
expect(() => localDetector.resolveDefaultProfileResult()).toThrow(ConfigError);
|
||||||
|
expect(() => localDetector.resolveDefaultProfileResult()).toThrow(
|
||||||
|
/Configured profile "xai" in profiles.json is invalid/
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isUnifiedModeSpy.mockRestore();
|
||||||
|
if (originalCcsHome !== undefined) {
|
||||||
|
process.env.CCS_HOME = originalCcsHome;
|
||||||
|
} else {
|
||||||
|
delete process.env.CCS_HOME;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should detect settings-based profile from unified config', () => {
|
it('should detect settings-based profile from unified config', () => {
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { handleApiCreateCommand } from '../../../src/commands/api-command/create-command';
|
||||||
|
|
||||||
|
describe('api create grandfathered force policy', () => {
|
||||||
|
let tempHome = '';
|
||||||
|
let originalCcsHome: string | undefined;
|
||||||
|
let originalUnifiedMode: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-api-create-grandfathered-'));
|
||||||
|
originalCcsHome = process.env.CCS_HOME;
|
||||||
|
originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG;
|
||||||
|
process.env.CCS_HOME = tempHome;
|
||||||
|
delete process.env.CCS_UNIFIED_CONFIG;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalCcsHome === undefined) {
|
||||||
|
delete process.env.CCS_HOME;
|
||||||
|
} else {
|
||||||
|
process.env.CCS_HOME = originalCcsHome;
|
||||||
|
}
|
||||||
|
if (originalUnifiedMode === undefined) {
|
||||||
|
delete process.env.CCS_UNIFIED_CONFIG;
|
||||||
|
} else {
|
||||||
|
process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode;
|
||||||
|
}
|
||||||
|
if (tempHome && fs.existsSync(tempHome)) {
|
||||||
|
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows ccs api create --force to repair an exact existing xai profile', async () => {
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'config.json'),
|
||||||
|
JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n'
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'xai.settings.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{ env: { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_AUTH_TOKEN: 'old' } },
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
) + '\n'
|
||||||
|
);
|
||||||
|
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleApiCreateCommand([
|
||||||
|
'xai',
|
||||||
|
'--force',
|
||||||
|
'--yes',
|
||||||
|
'--base-url',
|
||||||
|
'https://new.example.com',
|
||||||
|
'--api-key',
|
||||||
|
'new-token',
|
||||||
|
'--model',
|
||||||
|
'new-model',
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
logSpy.mockRestore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(ccsDir, 'xai.settings.json'), 'utf8')
|
||||||
|
) as {
|
||||||
|
env: Record<string, string>;
|
||||||
|
};
|
||||||
|
expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://new.example.com');
|
||||||
|
expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('new-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects ccs api create --force when the reserved profile is absent', 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'
|
||||||
|
);
|
||||||
|
|
||||||
|
const originalExit = process.exit;
|
||||||
|
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
process.exit = ((code?: number) => {
|
||||||
|
throw new Error(`process.exit(${code ?? 0})`);
|
||||||
|
}) as typeof process.exit;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
handleApiCreateCommand([
|
||||||
|
'grok',
|
||||||
|
'--force',
|
||||||
|
'--yes',
|
||||||
|
'--base-url',
|
||||||
|
'https://new.example.com',
|
||||||
|
'--api-key',
|
||||||
|
'new-token',
|
||||||
|
'--model',
|
||||||
|
'new-model',
|
||||||
|
])
|
||||||
|
).rejects.toThrow('process.exit(1)');
|
||||||
|
} finally {
|
||||||
|
process.exit = originalExit;
|
||||||
|
logSpy.mockRestore();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(ccsDir, 'grok.settings.json'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
import { parseProfileArgs } from '../../../src/commands/cliproxy/variant-subcommand';
|
import {
|
||||||
|
parseProfileArgs,
|
||||||
|
validateVariantCreateName,
|
||||||
|
} from '../../../src/commands/cliproxy/variant-subcommand';
|
||||||
|
|
||||||
describe('cliproxy variant arg parser', () => {
|
describe('cliproxy variant arg parser', () => {
|
||||||
test('parses --target value form', () => {
|
test('parses --target value form', () => {
|
||||||
@@ -56,3 +59,16 @@ describe('cliproxy variant arg parser', () => {
|
|||||||
expect(parsed.errors).toEqual([]);
|
expect(parsed.errors).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('cliproxy variant create name policy', () => {
|
||||||
|
test('allows force repair of an exact existing grandfathered variant', () => {
|
||||||
|
expect(validateVariantCreateName('xai', true, true)).toBeNull();
|
||||||
|
expect(validateVariantCreateName('GROK', true, true)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps absent, non-force, and other reserved variant names blocked', () => {
|
||||||
|
expect(validateVariantCreateName('xai', true, false)).toContain('reserved name');
|
||||||
|
expect(validateVariantCreateName('xai', false, true)).toContain('reserved name');
|
||||||
|
expect(validateVariantCreateName('gemini', true, true)).toContain('reserved name');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,6 +44,8 @@ describe('reserved-names', () => {
|
|||||||
expect(RESERVED_PROFILE_NAMES).toContain('agy');
|
expect(RESERVED_PROFILE_NAMES).toContain('agy');
|
||||||
expect(RESERVED_PROFILE_NAMES).toContain('qwen');
|
expect(RESERVED_PROFILE_NAMES).toContain('qwen');
|
||||||
expect(RESERVED_PROFILE_NAMES).toContain('iflow');
|
expect(RESERVED_PROFILE_NAMES).toContain('iflow');
|
||||||
|
expect(RESERVED_PROFILE_NAMES).toContain('xai');
|
||||||
|
expect(RESERVED_PROFILE_NAMES).toContain('grok');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include CLI commands', () => {
|
it('should include CLI commands', () => {
|
||||||
@@ -57,12 +59,16 @@ describe('reserved-names', () => {
|
|||||||
it('should return true for reserved names', () => {
|
it('should return true for reserved names', () => {
|
||||||
expect(isReservedName('gemini')).toBe(true);
|
expect(isReservedName('gemini')).toBe(true);
|
||||||
expect(isReservedName('codex')).toBe(true);
|
expect(isReservedName('codex')).toBe(true);
|
||||||
|
expect(isReservedName('xai')).toBe(true);
|
||||||
|
expect(isReservedName('grok')).toBe(true);
|
||||||
expect(isReservedName('default')).toBe(true);
|
expect(isReservedName('default')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be case-insensitive', () => {
|
it('should be case-insensitive', () => {
|
||||||
expect(isReservedName('GEMINI')).toBe(true);
|
expect(isReservedName('GEMINI')).toBe(true);
|
||||||
expect(isReservedName('Codex')).toBe(true);
|
expect(isReservedName('Codex')).toBe(true);
|
||||||
|
expect(isReservedName('XAI')).toBe(true);
|
||||||
|
expect(isReservedName('GROK')).toBe(true);
|
||||||
expect(isReservedName('DEFAULT')).toBe(true);
|
expect(isReservedName('DEFAULT')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,6 +82,8 @@ describe('reserved-names', () => {
|
|||||||
describe('validateProfileName', () => {
|
describe('validateProfileName', () => {
|
||||||
it('should throw for reserved names', () => {
|
it('should throw for reserved names', () => {
|
||||||
expect(() => validateProfileName('gemini')).toThrow(/reserved/i);
|
expect(() => validateProfileName('gemini')).toThrow(/reserved/i);
|
||||||
|
expect(() => validateProfileName('xai')).toThrow(/reserved/i);
|
||||||
|
expect(() => validateProfileName('grok')).toThrow(/reserved/i);
|
||||||
expect(() => validateProfileName('default')).toThrow(/reserved/i);
|
expect(() => validateProfileName('default')).toThrow(/reserved/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -76,11 +76,17 @@ describe('profile-routes lifecycle endpoints', () => {
|
|||||||
it('does not register all orphans when names=[] is explicitly passed', async () => {
|
it('does not register all orphans when names=[] is explicitly passed', async () => {
|
||||||
const ccsDir = path.join(tempHome, '.ccs');
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
fs.mkdirSync(ccsDir, { recursive: true });
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n');
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'config.json'),
|
||||||
|
JSON.stringify({ profiles: {} }, null, 2) + '\n'
|
||||||
|
);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(ccsDir, 'lonely.settings.json'),
|
path.join(ccsDir, 'lonely.settings.json'),
|
||||||
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) +
|
JSON.stringify(
|
||||||
'\n'
|
{ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } },
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
) + '\n'
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await fetch(`${baseUrl}/api/profiles/orphans/register`, {
|
const response = await fetch(`${baseUrl}/api/profiles/orphans/register`, {
|
||||||
@@ -142,4 +148,85 @@ describe('profile-routes lifecycle endpoints', () => {
|
|||||||
const body = (await response.json()) as { error: string };
|
const body = (await response.json()) as { error: string };
|
||||||
expect(body.error).toContain('API name must start with letter');
|
expect(body.error).toContain('API name must start with letter');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps grandfathered profiles exportable, copyable, and removable', async () => {
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'config.json'),
|
||||||
|
JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n'
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'xai.settings.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } },
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
) + '\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
const exportResponse = await fetch(`${baseUrl}/api/profiles/xai/export`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
expect(exportResponse.status).toBe(200);
|
||||||
|
|
||||||
|
const copyResponse = await fetch(`${baseUrl}/api/profiles/xai/copy`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ destination: 'xai-backup' }),
|
||||||
|
});
|
||||||
|
expect(copyResponse.status).toBe(201);
|
||||||
|
|
||||||
|
const reservedDestinationResponse = await fetch(`${baseUrl}/api/profiles/xai/copy`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ destination: 'GROK' }),
|
||||||
|
});
|
||||||
|
expect(reservedDestinationResponse.status).toBe(400);
|
||||||
|
const reservedDestinationBody = (await reservedDestinationResponse.json()) as {
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
expect(reservedDestinationBody.error).toContain('reserved name');
|
||||||
|
|
||||||
|
const deleteResponse = await fetch(`${baseUrl}/api/profiles/xai`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
expect(deleteResponse.status).toBe(200);
|
||||||
|
expect(fs.existsSync(path.join(ccsDir, 'xai.settings.json'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers a grandfathered xai orphan through the route', 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, 'xai.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: ['xai'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const body = (await response.json()) as { registered: string[]; skipped: unknown[] };
|
||||||
|
expect(body.registered).toEqual(['xai']);
|
||||||
|
expect(body.skipped).toEqual([]);
|
||||||
|
|
||||||
|
const config = JSON.parse(fs.readFileSync(path.join(ccsDir, 'config.json'), 'utf8')) as {
|
||||||
|
profiles: Record<string, string>;
|
||||||
|
};
|
||||||
|
expect(config.profiles.xai).toBe('~/.ccs/xai.settings.json');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user