mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 18:15:23 +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
@@ -21,8 +21,9 @@ describe('account context helpers', () => {
|
||||
});
|
||||
|
||||
it('rejects reserved account profile names', () => {
|
||||
expect(isValidAccountProfileName('default')).toBe(false);
|
||||
expect(isValidAccountProfileName('Default')).toBe(false);
|
||||
for (const name of ['default', 'Default', 'xai', 'XAI', 'grok', 'GROK']) {
|
||||
expect(isValidAccountProfileName(name)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to default shared group for invalid persisted metadata', () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
importApiProfileBundle,
|
||||
registerApiProfileOrphans,
|
||||
} from '../../../src/api/services/profile-lifecycle-service';
|
||||
import { createApiProfile } from '../../../src/api/services/profile-writer';
|
||||
import {
|
||||
loadConfigSafe,
|
||||
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');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
@@ -101,9 +102,59 @@ describe('profile lifecycle service', () => {
|
||||
2
|
||||
) + '\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());
|
||||
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 () => {
|
||||
@@ -140,7 +191,10 @@ describe('profile lifecycle service', () => {
|
||||
2
|
||||
) + '\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(() => {
|
||||
throw new Error('copy failed');
|
||||
@@ -168,8 +222,15 @@ describe('profile lifecycle service', () => {
|
||||
2
|
||||
) + '\n'
|
||||
);
|
||||
fs.writeFileSync(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');
|
||||
fs.writeFileSync(
|
||||
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 copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation((source, destination) => {
|
||||
@@ -195,7 +256,10 @@ describe('profile lifecycle service', () => {
|
||||
|
||||
const malformedPath = path.join(ccsDir, 'bad.settings.json');
|
||||
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(() =>
|
||||
registerApiProfileOrphans({ names: ['bad'], force: true })
|
||||
@@ -247,6 +311,173 @@ describe('profile lifecycle service', () => {
|
||||
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 () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
@@ -294,6 +525,62 @@ describe('profile lifecycle service', () => {
|
||||
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 () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
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 os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as childProcess from 'child_process';
|
||||
import ProfileRegistry from '../../src/auth/profile-registry';
|
||||
import InstanceManager from '../../src/management/instance-manager';
|
||||
import { handleResources } from '../../src/auth/commands/resources-command';
|
||||
@@ -227,6 +228,103 @@ describe('auth resources command', () => {
|
||||
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 () => {
|
||||
const registry = new ProfileRegistry();
|
||||
const instanceMgr = new InstanceManager();
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import ProfileDetector, { loadSettingsFromFile } from '../../../src/auth/profile-detector';
|
||||
import * as unifiedConfigLoader from '../../../src/config/unified-config-loader';
|
||||
import { ConfigError } from '../../../src/errors/error-types';
|
||||
|
||||
describe('ProfileDetector', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
it('should resolve the grok CLI alias to canonical xai routing', () => {
|
||||
const result = detector.detectProfileType('grok');
|
||||
expect(result.type).toBe('cliproxy');
|
||||
expect(result.name).toBe('xai');
|
||||
expect(result.provider).toBe('xai');
|
||||
it('should resolve xai and grok to built-in xai routing when no configured profile exists', () => {
|
||||
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false);
|
||||
const existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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 { parseProfileArgs } from '../../../src/commands/cliproxy/variant-subcommand';
|
||||
import {
|
||||
parseProfileArgs,
|
||||
validateVariantCreateName,
|
||||
} from '../../../src/commands/cliproxy/variant-subcommand';
|
||||
|
||||
describe('cliproxy variant arg parser', () => {
|
||||
test('parses --target value form', () => {
|
||||
@@ -56,3 +59,16 @@ describe('cliproxy variant arg parser', () => {
|
||||
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('qwen');
|
||||
expect(RESERVED_PROFILE_NAMES).toContain('iflow');
|
||||
expect(RESERVED_PROFILE_NAMES).toContain('xai');
|
||||
expect(RESERVED_PROFILE_NAMES).toContain('grok');
|
||||
});
|
||||
|
||||
it('should include CLI commands', () => {
|
||||
@@ -57,12 +59,16 @@ describe('reserved-names', () => {
|
||||
it('should return true for reserved names', () => {
|
||||
expect(isReservedName('gemini')).toBe(true);
|
||||
expect(isReservedName('codex')).toBe(true);
|
||||
expect(isReservedName('xai')).toBe(true);
|
||||
expect(isReservedName('grok')).toBe(true);
|
||||
expect(isReservedName('default')).toBe(true);
|
||||
});
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
expect(isReservedName('GEMINI')).toBe(true);
|
||||
expect(isReservedName('Codex')).toBe(true);
|
||||
expect(isReservedName('XAI')).toBe(true);
|
||||
expect(isReservedName('GROK')).toBe(true);
|
||||
expect(isReservedName('DEFAULT')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -76,6 +82,8 @@ describe('reserved-names', () => {
|
||||
describe('validateProfileName', () => {
|
||||
it('should throw for reserved names', () => {
|
||||
expect(() => validateProfileName('gemini')).toThrow(/reserved/i);
|
||||
expect(() => validateProfileName('xai')).toThrow(/reserved/i);
|
||||
expect(() => validateProfileName('grok')).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 () => {
|
||||
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, 'config.json'),
|
||||
JSON.stringify({ profiles: {} }, null, 2) + '\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'lonely.settings.json'),
|
||||
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) +
|
||||
'\n'
|
||||
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`, {
|
||||
@@ -142,4 +148,85 @@ describe('profile-routes lifecycle endpoints', () => {
|
||||
const body = (await response.json()) as { error: string };
|
||||
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