mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 12:14:19 +00:00
Merge pull request #197 from kaitranntt/fix/glm-auth-persistence
fix(profiles): prevent GLM auth regression from first-time install detection
This commit is contained in:
14 files changed
+731
-66
No files matched your search
@@ -6,6 +6,7 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir, getConfigPath, loadConfig } from '../../utils/config-manager';
|
||||
import { expandPath } from '../../utils/helpers';
|
||||
import {
|
||||
loadOrCreateUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
@@ -144,9 +145,10 @@ function removeApiProfileUnified(name: string): void {
|
||||
throw new Error(`API profile not found: ${name}`);
|
||||
}
|
||||
|
||||
// Delete the settings file if it exists
|
||||
// Delete the settings file if it exists.
|
||||
// Uses expandPath() for cross-platform path handling.
|
||||
if (profile.settings) {
|
||||
const settingsPath = profile.settings.replace(/^~/, os.homedir());
|
||||
const settingsPath = expandPath(profile.settings);
|
||||
if (fs.existsSync(settingsPath)) {
|
||||
fs.unlinkSync(settingsPath);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { findSimilarStrings } from '../utils/helpers';
|
||||
import { findSimilarStrings, expandPath } from '../utils/helpers';
|
||||
import { Config, Settings, ProfileMetadata } from '../types';
|
||||
import { UnifiedConfig, CopilotConfig } from '../config/unified-config-types';
|
||||
import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader';
|
||||
@@ -62,10 +62,11 @@ export interface ProfileNotFoundError extends Error {
|
||||
*/
|
||||
/**
|
||||
* Load env vars from a settings file (*.settings.json).
|
||||
* Expands ~ to home directory. Returns empty object on error.
|
||||
* Uses expandPath() for consistent cross-platform path handling.
|
||||
* Returns empty object on error.
|
||||
*/
|
||||
function loadSettingsFromFile(settingsPath: string): Record<string, string> {
|
||||
const expandedPath = settingsPath.replace(/^~/, os.homedir());
|
||||
export function loadSettingsFromFile(settingsPath: string): Record<string, string> {
|
||||
const expandedPath = expandPath(settingsPath);
|
||||
try {
|
||||
if (!fs.existsSync(expandedPath)) return {};
|
||||
const content = fs.readFileSync(expandedPath, 'utf8');
|
||||
|
||||
+27
@@ -3,6 +3,7 @@ import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { detectClaudeCli } from './utils/claude-detector';
|
||||
import { getSettingsPath, loadSettings } from './utils/config-manager';
|
||||
import { validateGlmKey } from './utils/api-key-validator';
|
||||
import { ErrorManager } from './utils/error-manager';
|
||||
import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy';
|
||||
import {
|
||||
@@ -508,6 +509,32 @@ async function main(): Promise<void> {
|
||||
// Display WebSearch status (single line, equilibrium UX)
|
||||
displayWebSearchStatus();
|
||||
|
||||
// Pre-flight validation for GLM/GLMT profiles
|
||||
if (profileInfo.name === 'glm' || profileInfo.name === 'glmt') {
|
||||
const preflightSettingsPath = getSettingsPath(profileInfo.name);
|
||||
const preflightSettings = loadSettings(preflightSettingsPath);
|
||||
const apiKey = preflightSettings.env?.['ANTHROPIC_AUTH_TOKEN'];
|
||||
|
||||
if (apiKey) {
|
||||
const validation = await validateGlmKey(
|
||||
apiKey,
|
||||
preflightSettings.env?.['ANTHROPIC_BASE_URL']
|
||||
);
|
||||
|
||||
if (!validation.valid) {
|
||||
console.error('');
|
||||
console.error(fail(validation.error || 'API key validation failed'));
|
||||
if (validation.suggestion) {
|
||||
console.error('');
|
||||
console.error(validation.suggestion);
|
||||
}
|
||||
console.error('');
|
||||
console.error(info('To skip validation: CCS_SKIP_PREFLIGHT=1 ccs glm "prompt"'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is GLMT profile (requires proxy)
|
||||
if (profileInfo.name === 'glmt') {
|
||||
// GLMT FLOW: Settings-based with embedded proxy for thinking support
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { CLIProxyProfileName } from '../../auth/profile-detector';
|
||||
import { getCcsDir } from '../../utils/config-manager';
|
||||
import { expandPath } from '../../utils/helpers';
|
||||
import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator';
|
||||
import { CLIProxyProvider } from '../types';
|
||||
|
||||
@@ -124,10 +125,11 @@ export function createSettingsFileUnified(
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete settings file if it exists
|
||||
* Delete settings file if it exists.
|
||||
* Uses expandPath() for cross-platform path handling.
|
||||
*/
|
||||
export function deleteSettingsFile(settingsPath: string): boolean {
|
||||
const resolvedPath = settingsPath.replace(/^~/, os.homedir());
|
||||
const resolvedPath = expandPath(settingsPath);
|
||||
if (fs.existsSync(resolvedPath)) {
|
||||
fs.unlinkSync(resolvedPath);
|
||||
return true;
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
*/
|
||||
|
||||
import * as readline from 'readline';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { initUI, header, ok, info, warn } from '../utils/ui';
|
||||
import {
|
||||
loadOrCreateUnifiedConfig,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
hasUnifiedConfig,
|
||||
} from '../config/unified-config-loader';
|
||||
import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
|
||||
/** Custom error for user cancellation (Ctrl+C) */
|
||||
class UserCancelledError extends Error {
|
||||
@@ -112,39 +115,75 @@ async function selectOption(
|
||||
/**
|
||||
* Check if this is a first-time install (config exists but is empty/unconfigured)
|
||||
* Returns true if user should be prompted to run setup wizard
|
||||
*
|
||||
* IMPORTANT: Also checks legacy config.json for existing profiles to avoid
|
||||
* treating users with existing GLM/Kimi setups as "first-time installs"
|
||||
* (Fix for issue #195 - GLM auth persistence regression)
|
||||
*/
|
||||
export function isFirstTimeInstall(): boolean {
|
||||
// No config at all → definitely first time
|
||||
if (!hasUnifiedConfig()) {
|
||||
return true;
|
||||
}
|
||||
// Check unified config first (config.yaml)
|
||||
if (hasUnifiedConfig()) {
|
||||
const loaded = loadUnifiedConfig();
|
||||
|
||||
// Try loading config directly to detect corruption
|
||||
const loaded = loadUnifiedConfig();
|
||||
if (loaded === null) {
|
||||
// Config exists but is corrupted/invalid - don't treat as first-time
|
||||
// User should fix or delete the file, or use --force
|
||||
console.log(warn('Warning: ~/.ccs/config.yaml exists but appears corrupted'));
|
||||
console.log(info(' Run `ccs setup --force` to reset, or `ccs doctor` to diagnose'));
|
||||
return false;
|
||||
if (loaded === null) {
|
||||
console.log(warn('Warning: ~/.ccs/config.yaml exists but appears corrupted'));
|
||||
console.log(info(' Run `ccs setup --force` to reset, or `ccs doctor` to diagnose'));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for any meaningful configuration in unified config
|
||||
const hasProfiles = Object.keys(loaded.profiles || {}).length > 0;
|
||||
const hasAccounts = Object.keys(loaded.accounts || {}).length > 0;
|
||||
const hasVariants = Object.keys(loaded.cliproxy?.variants || {}).length > 0;
|
||||
const hasOAuthAccounts = Object.keys(loaded.cliproxy?.oauth_accounts || {}).length > 0;
|
||||
const hasRemoteProxy =
|
||||
loaded.cliproxy_server?.remote?.enabled && loaded.cliproxy_server?.remote?.host;
|
||||
|
||||
// If any of these exist in unified config, user has configured something
|
||||
if (hasProfiles || hasAccounts || hasVariants || hasOAuthAccounts || hasRemoteProxy) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Config exists and is valid - check if it's meaningfully configured
|
||||
const config = loaded;
|
||||
// Also check legacy config.json for existing profiles
|
||||
// This prevents treating users with GLM/Kimi in config.json as "first-time installs"
|
||||
const ccsDir = getCcsDir();
|
||||
const legacyConfigPath = path.join(ccsDir, 'config.json');
|
||||
|
||||
// Check for any meaningful configuration
|
||||
const hasProfiles = Object.keys(config.profiles || {}).length > 0;
|
||||
const hasAccounts = Object.keys(config.accounts || {}).length > 0;
|
||||
const hasVariants = Object.keys(config.cliproxy?.variants || {}).length > 0;
|
||||
const hasOAuthAccounts = Object.keys(config.cliproxy?.oauth_accounts || {}).length > 0;
|
||||
const hasRemoteProxy =
|
||||
config.cliproxy_server?.remote?.enabled && config.cliproxy_server?.remote?.host;
|
||||
if (fs.existsSync(legacyConfigPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(legacyConfigPath, 'utf8');
|
||||
const legacyConfig = JSON.parse(content) as { profiles?: Record<string, string> };
|
||||
|
||||
// If any of these exist, user has configured something
|
||||
const isConfigured =
|
||||
hasProfiles || hasAccounts || hasVariants || hasOAuthAccounts || hasRemoteProxy;
|
||||
if (legacyConfig.profiles && Object.keys(legacyConfig.profiles).length > 0) {
|
||||
// Has legacy profiles - NOT first time
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// Legacy config exists but is invalid - ignore and continue
|
||||
}
|
||||
}
|
||||
|
||||
return !isConfigured;
|
||||
// Also check profiles.json for existing accounts
|
||||
const legacyProfilesPath = path.join(ccsDir, 'profiles.json');
|
||||
|
||||
if (fs.existsSync(legacyProfilesPath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(legacyProfilesPath, 'utf8');
|
||||
const legacyProfiles = JSON.parse(content) as { profiles?: Record<string, unknown> };
|
||||
|
||||
if (legacyProfiles.profiles && Object.keys(legacyProfiles.profiles).length > 0) {
|
||||
// Has legacy accounts - NOT first time
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// Legacy profiles exists but is invalid - ignore and continue
|
||||
}
|
||||
}
|
||||
|
||||
// No meaningful configuration found anywhere
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+137
-29
@@ -15,9 +15,10 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
import { expandPath } from '../utils/helpers';
|
||||
import type { ProfileConfig, AccountConfig, CLIProxyVariantConfig } from './unified-config-types';
|
||||
import { createEmptyUnifiedConfig } from './unified-config-types';
|
||||
import { saveUnifiedConfig, hasUnifiedConfig } from './unified-config-loader';
|
||||
import { saveUnifiedConfig, hasUnifiedConfig, loadUnifiedConfig } from './unified-config-loader';
|
||||
import { infoBox, warn } from '../utils/ui';
|
||||
|
||||
const BACKUP_DIR_PREFIX = 'backup-v1-';
|
||||
@@ -44,6 +45,47 @@ export function needsMigration(): boolean {
|
||||
return hasOldConfig && !hasNewConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are legacy profiles that haven't been migrated to config.yaml.
|
||||
* This catches the case where config.yaml exists but is empty/missing profiles
|
||||
* that are still in config.json.
|
||||
*
|
||||
* Used by autoMigrate() to trigger inline migration when needed.
|
||||
* (Fix for issue #195 - GLM auth persistence regression)
|
||||
*/
|
||||
export function needsProfileMigration(): boolean {
|
||||
const ccsDir = getCcsDir();
|
||||
const configJsonPath = path.join(ccsDir, 'config.json');
|
||||
|
||||
// No legacy config → nothing to migrate
|
||||
if (!fs.existsSync(configJsonPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const legacyConfig = readJsonSafe(configJsonPath);
|
||||
const unifiedConfig = loadUnifiedConfig();
|
||||
|
||||
// No legacy profiles → nothing to migrate
|
||||
if (!legacyConfig?.profiles || typeof legacyConfig.profiles !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// No unified config → needs full migration (handled by needsMigration)
|
||||
if (!unifiedConfig) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if any legacy profile is missing from unified config
|
||||
const legacyProfiles = legacyConfig.profiles as Record<string, unknown>;
|
||||
for (const profileName of Object.keys(legacyProfiles)) {
|
||||
if (!unifiedConfig.profiles[profileName]) {
|
||||
return true; // Found unmigrated profile
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of backup directories.
|
||||
*/
|
||||
@@ -270,13 +312,6 @@ function readJsonSafe(filePath: string): Record<string, unknown> | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand ~ to home directory in path.
|
||||
*/
|
||||
function expandPath(p: string): string {
|
||||
return p.replace(/^~/, process.env.HOME || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Move file if it exists. Returns true if moved, false if source didn't exist.
|
||||
*/
|
||||
@@ -326,6 +361,9 @@ async function performBackup(srcDir: string, backupDir: string): Promise<void> {
|
||||
* Auto-migrate on first run after update.
|
||||
* Silent if already migrated or no config exists.
|
||||
* Shows friendly message with backup location on success.
|
||||
*
|
||||
* Also handles inline profile migration when config.yaml exists but is missing
|
||||
* profiles from config.json (Fix for issue #195).
|
||||
*/
|
||||
export async function autoMigrate(): Promise<void> {
|
||||
// Skip in test environment
|
||||
@@ -333,30 +371,100 @@ export async function autoMigrate(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if no migration needed
|
||||
if (!needsMigration()) {
|
||||
// Check if full migration is needed (no config.yaml exists)
|
||||
if (needsMigration()) {
|
||||
const result = await migrate(false);
|
||||
|
||||
if (result.success) {
|
||||
console.log('');
|
||||
console.log(infoBox('Migrated to unified config (config.yaml)', 'SUCCESS'));
|
||||
console.log(` Backup: ${result.backupPath}`);
|
||||
console.log(` Items: ${result.migratedFiles.length} migrated`);
|
||||
if (result.warnings.length > 0) {
|
||||
for (const warning of result.warnings) {
|
||||
console.log(warn(warning));
|
||||
}
|
||||
}
|
||||
console.log(` Rollback: ccs migrate --rollback ${result.backupPath}`);
|
||||
console.log('');
|
||||
} else {
|
||||
console.log('');
|
||||
console.log(infoBox('Migration failed - using legacy config', 'WARNING'));
|
||||
console.log(` Error: ${result.error}`);
|
||||
console.log(' Retry: ccs migrate');
|
||||
console.log('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await migrate(false);
|
||||
|
||||
if (result.success) {
|
||||
console.log('');
|
||||
console.log(infoBox('Migrated to unified config (config.yaml)', 'SUCCESS'));
|
||||
console.log(` Backup: ${result.backupPath}`);
|
||||
console.log(` Items: ${result.migratedFiles.length} migrated`);
|
||||
if (result.warnings.length > 0) {
|
||||
for (const warning of result.warnings) {
|
||||
console.log(warn(warning));
|
||||
}
|
||||
// Check if inline profile migration is needed (config.yaml exists but missing profiles)
|
||||
if (needsProfileMigration()) {
|
||||
const result = await migrateProfilesToUnified();
|
||||
if (result.success && result.migratedFiles.length > 0) {
|
||||
console.log('');
|
||||
console.log(infoBox('Migrated legacy profiles to config.yaml', 'SUCCESS'));
|
||||
console.log(` Profiles: ${result.migratedFiles.join(', ')}`);
|
||||
console.log('');
|
||||
}
|
||||
console.log(` Rollback: ccs migrate --rollback ${result.backupPath}`);
|
||||
console.log('');
|
||||
} else {
|
||||
console.log('');
|
||||
console.log(infoBox('Migration failed - using legacy config', 'WARNING'));
|
||||
console.log(` Error: ${result.error}`);
|
||||
console.log(' Retry: ccs migrate');
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate only profiles from config.json to existing config.yaml.
|
||||
* Used when config.yaml exists but is missing profiles.
|
||||
*/
|
||||
async function migrateProfilesToUnified(): Promise<MigrationResult> {
|
||||
const ccsDir = getCcsDir();
|
||||
const migratedFiles: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
const oldConfig = readJsonSafe(path.join(ccsDir, 'config.json'));
|
||||
const unifiedConfig = loadUnifiedConfig();
|
||||
|
||||
if (!oldConfig?.profiles || !unifiedConfig) {
|
||||
return { success: true, migratedFiles, warnings };
|
||||
}
|
||||
|
||||
let modified = false;
|
||||
|
||||
// Migrate API profiles from config.json
|
||||
for (const [name, settingsPath] of Object.entries(oldConfig.profiles)) {
|
||||
// Skip if already in unified config
|
||||
if (unifiedConfig.profiles[name]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pathStr = settingsPath as string;
|
||||
const expandedPath = expandPath(pathStr);
|
||||
|
||||
// Verify settings file exists
|
||||
if (!fs.existsSync(expandedPath)) {
|
||||
warnings.push(`Skipped ${name}: settings file not found at ${pathStr}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store reference to settings file
|
||||
unifiedConfig.profiles[name] = {
|
||||
type: 'api',
|
||||
settings: pathStr,
|
||||
};
|
||||
migratedFiles.push(name);
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// Save if modified
|
||||
if (modified) {
|
||||
saveUnifiedConfig(unifiedConfig);
|
||||
}
|
||||
|
||||
return { success: true, migratedFiles, warnings };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
migratedFiles,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -587,7 +587,14 @@ export class GlmtProxy {
|
||||
statusCode: 401,
|
||||
type: 'authentication_error',
|
||||
message:
|
||||
'Authorization token missing or invalid. Please check your ANTHROPIC_AUTH_TOKEN environment variable in ~/.ccs/config.json or settings.json.',
|
||||
'API key rejected by Z.AI. This can happen when:\n' +
|
||||
' 1. Key expired on Z.AI server (most common after idle periods)\n' +
|
||||
' 2. Key was revoked or regenerated\n' +
|
||||
' 3. Key is missing or malformed\n\n' +
|
||||
'To fix:\n' +
|
||||
' 1. Go to Z.AI dashboard and regenerate your API key\n' +
|
||||
' 2. Update ~/.ccs/glm.settings.json with the new key\n' +
|
||||
' 3. Or run: ccs config -> API Profiles -> GLM -> Update key',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -46,5 +46,5 @@ export type {
|
||||
} from './glmt';
|
||||
|
||||
// Utility types
|
||||
export { ErrorCode, LogLevel } from './utils';
|
||||
export type { ColorName, TerminalInfo, Result } from './utils';
|
||||
export { LogLevel } from './utils';
|
||||
export type { ErrorCode, ColorName, TerminalInfo, Result } from './utils';
|
||||
+2
-1
@@ -3,7 +3,8 @@
|
||||
*/
|
||||
|
||||
// Re-export from error-codes for consistency
|
||||
export { ERROR_CODES, ErrorCode, getErrorDocUrl, getErrorCategory } from '../utils/error-codes';
|
||||
export { ERROR_CODES, getErrorDocUrl, getErrorCategory } from '../utils/error-codes';
|
||||
export type { ErrorCode } from '../utils/error-codes';
|
||||
|
||||
/**
|
||||
* Log levels
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* API Key Pre-flight Validator
|
||||
*
|
||||
* Quick validation of API keys before Claude CLI launch.
|
||||
* Catches expired keys early with actionable error messages.
|
||||
*/
|
||||
|
||||
import * as https from 'https';
|
||||
import { URL } from 'url';
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
/** Default placeholders that indicate unconfigured keys */
|
||||
const DEFAULT_PLACEHOLDERS = [
|
||||
'YOUR_GLM_API_KEY_HERE',
|
||||
'YOUR_KIMI_API_KEY_HERE',
|
||||
'YOUR_API_KEY_HERE',
|
||||
'your-api-key-here',
|
||||
'PLACEHOLDER',
|
||||
'',
|
||||
];
|
||||
|
||||
/**
|
||||
* Validate GLM API key with quick health check
|
||||
*
|
||||
* @param apiKey - The ANTHROPIC_AUTH_TOKEN value
|
||||
* @param baseUrl - Optional base URL (defaults to Z.AI)
|
||||
* @param timeoutMs - Timeout in milliseconds (default 2000)
|
||||
*/
|
||||
export async function validateGlmKey(
|
||||
apiKey: string,
|
||||
baseUrl?: string,
|
||||
timeoutMs = 2000
|
||||
): Promise<ValidationResult> {
|
||||
// Skip if disabled
|
||||
if (process.env.CCS_SKIP_PREFLIGHT === '1') {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Basic format check - detect placeholders
|
||||
if (!apiKey || DEFAULT_PLACEHOLDERS.includes(apiKey.toUpperCase())) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'API key not configured',
|
||||
suggestion:
|
||||
'Set ANTHROPIC_AUTH_TOKEN in ~/.ccs/glm.settings.json\n' +
|
||||
'Or run: ccs config -> API Profiles -> GLM',
|
||||
};
|
||||
}
|
||||
|
||||
// Determine validation endpoint
|
||||
// Z.AI uses /api/anthropic path, we can test with a minimal request
|
||||
const targetBase = baseUrl || 'https://api.z.ai';
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL('/api/anthropic/v1/models', targetBase);
|
||||
} catch {
|
||||
// Invalid URL - fail-open
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
// Fail-open on timeout - let Claude CLI handle it
|
||||
resolve({ valid: true });
|
||||
}, timeoutMs);
|
||||
|
||||
const options: https.RequestOptions = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || 443,
|
||||
path: url.pathname,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'User-Agent': 'CCS-Preflight/1.0',
|
||||
},
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
resolve({ valid: true });
|
||||
} else if (res.statusCode === 401 || res.statusCode === 403) {
|
||||
resolve({
|
||||
valid: false,
|
||||
error: 'API key rejected by Z.AI',
|
||||
suggestion:
|
||||
'Your key may have expired. To fix:\n' +
|
||||
' 1. Go to Z.AI dashboard and regenerate your API key\n' +
|
||||
' 2. Update ~/.ccs/glm.settings.json with the new key\n' +
|
||||
' 3. Or run: ccs config -> API Profiles -> GLM',
|
||||
});
|
||||
} else {
|
||||
// Other errors (404, 500, etc.) - fail-open, let Claude CLI handle
|
||||
resolve({ valid: true });
|
||||
}
|
||||
|
||||
// Consume response body to free resources
|
||||
res.resume();
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
clearTimeout(timeout);
|
||||
// Network error - fail-open
|
||||
resolve({ valid: true });
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
@@ -14,9 +14,14 @@ export function error(message: string): never {
|
||||
* Path expansion (~ and env vars)
|
||||
*/
|
||||
export function expandPath(pathStr: string): string {
|
||||
// Normalize separators first to handle mixed paths
|
||||
pathStr = pathStr.replace(/\\/g, '/');
|
||||
|
||||
// Handle tilde expansion
|
||||
if (pathStr.startsWith('~/') || pathStr.startsWith('~\\')) {
|
||||
if (pathStr.startsWith('~/')) {
|
||||
pathStr = path.join(os.homedir(), pathStr.slice(2));
|
||||
} else if (pathStr === '~') {
|
||||
pathStr = os.homedir();
|
||||
}
|
||||
|
||||
// Expand environment variables (Windows and Unix)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Unit tests for Profile Detector
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
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';
|
||||
|
||||
describe('ProfileDetector', () => {
|
||||
const tempDir = path.join(os.tmpdir(), `ccs-test-profile-detector-${process.pid}`);
|
||||
|
||||
beforeEach(() => {
|
||||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(tempDir)) {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('loadSettingsFromFile', () => {
|
||||
it('should load settings from a valid JSON file', () => {
|
||||
const settingsPath = path.join(tempDir, 'valid.settings.json');
|
||||
const settings = { env: { KEY: 'VALUE' } };
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings));
|
||||
|
||||
const result = loadSettingsFromFile(settingsPath);
|
||||
expect(result).toEqual({ KEY: 'VALUE' });
|
||||
});
|
||||
|
||||
it('should return empty object for non-existent file', () => {
|
||||
const result = loadSettingsFromFile(path.join(tempDir, 'non-existent.json'));
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty object for invalid JSON', () => {
|
||||
const settingsPath = path.join(tempDir, 'invalid.json');
|
||||
fs.writeFileSync(settingsPath, 'invalid json');
|
||||
|
||||
const result = loadSettingsFromFile(settingsPath);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle tilde expansion correctly', () => {
|
||||
const mockHome = path.join(tempDir, 'home');
|
||||
fs.mkdirSync(mockHome, { recursive: true });
|
||||
const settingsPath = '~/test.settings.json';
|
||||
const actualPath = path.join(mockHome, 'test.settings.json');
|
||||
fs.writeFileSync(actualPath, JSON.stringify({ env: { HOME_VAR: 'TRUE' } }));
|
||||
|
||||
// Mock os.homedir
|
||||
const homedirSpy = spyOn(os, 'homedir').mockReturnValue(mockHome);
|
||||
|
||||
try {
|
||||
const result = loadSettingsFromFile(settingsPath);
|
||||
expect(result).toEqual({ HOME_VAR: 'TRUE' });
|
||||
} finally {
|
||||
homedirSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle env var expansion correctly', () => {
|
||||
const settingsPath = path.join(tempDir, '${TEST_VAR}.json');
|
||||
const actualPath = path.join(tempDir, 'actual.json');
|
||||
fs.writeFileSync(actualPath, JSON.stringify({ env: { ENV_VAR: 'EXPANDED' } }));
|
||||
|
||||
process.env.TEST_VAR = 'actual';
|
||||
try {
|
||||
const result = loadSettingsFromFile(settingsPath);
|
||||
expect(result).toEqual({ ENV_VAR: 'EXPANDED' });
|
||||
} finally {
|
||||
delete process.env.TEST_VAR;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectProfileType', () => {
|
||||
let detector: ProfileDetector;
|
||||
|
||||
beforeEach(() => {
|
||||
detector = new ProfileDetector();
|
||||
});
|
||||
|
||||
it('should detect CLIProxy profiles', () => {
|
||||
const result = detector.detectProfileType('gemini');
|
||||
expect(result.type).toBe('cliproxy');
|
||||
expect(result.name).toBe('gemini');
|
||||
expect(result.provider).toBe('gemini');
|
||||
});
|
||||
|
||||
it('should detect settings-based profile from unified config', () => {
|
||||
const settingsPath = path.join(tempDir, 'glm.settings.json');
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: { ANTHROPIC_MODEL: 'glm-4' } }));
|
||||
|
||||
const mockUnifiedConfig = {
|
||||
version: 2,
|
||||
profiles: {
|
||||
glm: { settings: settingsPath, type: 'api' }
|
||||
}
|
||||
};
|
||||
|
||||
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true);
|
||||
const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue(mockUnifiedConfig as any);
|
||||
|
||||
try {
|
||||
const result = detector.detectProfileType('glm');
|
||||
expect(result.type).toBe('settings');
|
||||
expect(result.name).toBe('glm');
|
||||
expect(result.env).toEqual({ ANTHROPIC_MODEL: 'glm-4' });
|
||||
} finally {
|
||||
isUnifiedModeSpy.mockRestore();
|
||||
loadUnifiedConfigSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should detect account-based profile from unified config', () => {
|
||||
const mockUnifiedConfig = {
|
||||
version: 2,
|
||||
accounts: {
|
||||
work: { created: '2025-01-01', last_used: '2025-01-02' }
|
||||
}
|
||||
};
|
||||
|
||||
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true);
|
||||
const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue(mockUnifiedConfig as any);
|
||||
|
||||
try {
|
||||
const result = detector.detectProfileType('work');
|
||||
expect(result.type).toBe('account');
|
||||
expect(result.name).toBe('work');
|
||||
expect(result.profile).toBeDefined();
|
||||
expect((result.profile as any).type).toBe('account');
|
||||
} finally {
|
||||
isUnifiedModeSpy.mockRestore();
|
||||
loadUnifiedConfigSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('should return null for unknown profile (throws error)', () => {
|
||||
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false);
|
||||
// Mock readConfig/readProfiles to return empty
|
||||
spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
|
||||
try {
|
||||
expect(() => detector.detectProfileType('unknown')).toThrow(/Profile not found/);
|
||||
} finally {
|
||||
isUnifiedModeSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Unit tests for setup-command.ts - isFirstTimeInstall() function
|
||||
*
|
||||
* Issue #195: GLM auth regression - isFirstTimeInstall() was ignoring legacy configs
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
// Create temp directory for test isolation
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Helper to create config files
|
||||
function createConfigYaml(content: string): void {
|
||||
fs.writeFileSync(path.join(testDir, 'config.yaml'), content, 'utf8');
|
||||
}
|
||||
|
||||
function createConfigJson(content: object): void {
|
||||
fs.writeFileSync(path.join(testDir, 'config.json'), JSON.stringify(content, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function createProfilesJson(content: object): void {
|
||||
fs.writeFileSync(path.join(testDir, 'profiles.json'), JSON.stringify(content, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
describe('isFirstTimeInstall logic', () => {
|
||||
describe('legacy config detection', () => {
|
||||
it('should detect legacy config.json with profiles', () => {
|
||||
createConfigYaml('version: 2\nprofiles: {}\naccounts: {}');
|
||||
createConfigJson({ profiles: { glm: '~/.ccs/glm.settings.json' } });
|
||||
|
||||
const legacyConfig = JSON.parse(fs.readFileSync(path.join(testDir, 'config.json'), 'utf8'));
|
||||
const hasLegacyProfiles = legacyConfig.profiles && Object.keys(legacyConfig.profiles).length > 0;
|
||||
|
||||
expect(hasLegacyProfiles).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect legacy profiles.json with accounts', () => {
|
||||
createConfigYaml('version: 2\nprofiles: {}\naccounts: {}');
|
||||
createProfilesJson({ profiles: { work: { path: '/some/path' } } });
|
||||
|
||||
const legacyProfiles = JSON.parse(fs.readFileSync(path.join(testDir, 'profiles.json'), 'utf8'));
|
||||
const hasLegacyAccounts = legacyProfiles.profiles && Object.keys(legacyProfiles.profiles).length > 0;
|
||||
|
||||
expect(hasLegacyAccounts).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when no configs exist', () => {
|
||||
const hasConfigYaml = fs.existsSync(path.join(testDir, 'config.yaml'));
|
||||
const hasConfigJson = fs.existsSync(path.join(testDir, 'config.json'));
|
||||
const hasProfilesJson = fs.existsSync(path.join(testDir, 'profiles.json'));
|
||||
|
||||
expect(hasConfigYaml).toBe(false);
|
||||
expect(hasConfigJson).toBe(false);
|
||||
expect(hasProfilesJson).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect unified config with profiles', () => {
|
||||
createConfigYaml(`
|
||||
version: 2
|
||||
profiles:
|
||||
glm:
|
||||
type: api
|
||||
settings: ~/.ccs/glm.settings.json
|
||||
accounts: {}
|
||||
`);
|
||||
|
||||
const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8');
|
||||
const hasProfiles = content.includes('glm:') && content.includes('type: api');
|
||||
|
||||
expect(hasProfiles).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect unified config with remote proxy', () => {
|
||||
createConfigYaml(`
|
||||
version: 2
|
||||
profiles: {}
|
||||
accounts: {}
|
||||
cliproxy_server:
|
||||
remote:
|
||||
enabled: true
|
||||
host: my-server.example.com
|
||||
`);
|
||||
|
||||
const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8');
|
||||
const hasRemoteProxy = content.includes('enabled: true') && content.includes('host:');
|
||||
|
||||
expect(hasRemoteProxy).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty config handling', () => {
|
||||
it('should treat empty configs as first-time', () => {
|
||||
createConfigYaml('version: 2\nprofiles: {}\naccounts: {}');
|
||||
createConfigJson({ profiles: {} });
|
||||
createProfilesJson({ profiles: {} });
|
||||
|
||||
const legacyConfig = JSON.parse(fs.readFileSync(path.join(testDir, 'config.json'), 'utf8'));
|
||||
const legacyProfiles = JSON.parse(fs.readFileSync(path.join(testDir, 'profiles.json'), 'utf8'));
|
||||
|
||||
const hasLegacyProfiles = Object.keys(legacyConfig.profiles || {}).length > 0;
|
||||
const hasLegacyAccounts = Object.keys(legacyProfiles.profiles || {}).length > 0;
|
||||
|
||||
expect(hasLegacyProfiles).toBe(false);
|
||||
expect(hasLegacyAccounts).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('corrupted config handling', () => {
|
||||
it('should handle corrupted config.json gracefully', () => {
|
||||
fs.writeFileSync(path.join(testDir, 'config.json'), 'not valid json{{{', 'utf8');
|
||||
|
||||
let hasLegacyProfiles = false;
|
||||
try {
|
||||
const legacyConfig = JSON.parse(fs.readFileSync(path.join(testDir, 'config.json'), 'utf8'));
|
||||
hasLegacyProfiles = legacyConfig.profiles && Object.keys(legacyConfig.profiles).length > 0;
|
||||
} catch {
|
||||
hasLegacyProfiles = false;
|
||||
}
|
||||
|
||||
expect(hasLegacyProfiles).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, test, describe, beforeEach, afterEach } from "bun:test";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
import { expandPath } from "../../../src/utils/helpers";
|
||||
|
||||
describe("expandPath", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
const HOME = os.homedir();
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.TEST_HOME = "/custom/home";
|
||||
process.env.TEST_VAR = "foo";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
test("1. Tilde expansion: ~/path -> /home/user/path", () => {
|
||||
expect(expandPath("~/test/file.txt")).toBe(path.join(HOME, "test/file.txt"));
|
||||
});
|
||||
|
||||
test("2. Windows tilde with backslash: ~\\path", () => {
|
||||
// expandPath handles both ~/ and ~\ regardless of platform
|
||||
expect(expandPath("~\\test\\file.txt")).toBe(path.join(HOME, "test/file.txt"));
|
||||
});
|
||||
|
||||
test("3. Environment variable expansion: ${VAR}/path", () => {
|
||||
expect(expandPath("${TEST_HOME}/file.txt")).toBe(path.normalize("/custom/home/file.txt"));
|
||||
});
|
||||
|
||||
test("4. Dollar sign env vars: $VAR/path", () => {
|
||||
expect(expandPath("$TEST_HOME/file.txt")).toBe(path.normalize("/custom/home/file.txt"));
|
||||
});
|
||||
|
||||
test("5. Windows %VAR% expansion: %VAR%\\path (simulated)", () => {
|
||||
// We can't easily mock process.platform if it's not win32,
|
||||
// but the function check process.platform === 'win32'
|
||||
if (process.platform === 'win32') {
|
||||
expect(expandPath("%TEST_HOME%\\file.txt")).toBe(path.normalize("/custom/home/file.txt"));
|
||||
} else {
|
||||
// Should remain unchanged on non-windows (but separators normalized)
|
||||
expect(expandPath("%TEST_HOME%\\file.txt")).toBe(path.normalize("%TEST_HOME%/file.txt"));
|
||||
}
|
||||
});
|
||||
|
||||
test("6. Mixed path separators normalization", () => {
|
||||
const result = expandPath("path/to\\some/file");
|
||||
expect(result).toBe(path.normalize("path/to/some/file"));
|
||||
});
|
||||
|
||||
test("7. Nested env vars: ${HOME}/${VAR}/path", () => {
|
||||
expect(expandPath("${TEST_HOME}/${TEST_VAR}/file.txt")).toBe(path.normalize("/custom/home/foo/file.txt"));
|
||||
});
|
||||
|
||||
test("8. Empty/null path handling", () => {
|
||||
expect(expandPath("")).toBe(".");
|
||||
});
|
||||
|
||||
test("9. Already absolute paths stay unchanged", () => {
|
||||
const absPath = "/absolute/path";
|
||||
expect(expandPath(absPath)).toBe(path.normalize(absPath));
|
||||
});
|
||||
|
||||
test("10. Undefined env vars -> empty string", () => {
|
||||
expect(expandPath("${UNDEFINED_VAR}/file.txt")).toBe(path.normalize("/file.txt"));
|
||||
expect(expandPath("$UNDEFINED_VAR/file.txt")).toBe(path.normalize("/file.txt"));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user