fix(profiles): prevent GLM auth regression from first-time install detection

- Check legacy config.json/profiles.json in isFirstTimeInstall()
- Use expandPath() for cross-platform path handling in profile-detector
- Add pre-flight API key validation for better error messages
- Enhance 401 error handling with Z.AI refresh guidance

Fixes #195
This commit is contained in:
kaitranntt
2025-12-24 18:09:09 -05:00
parent a1e93eb940
commit cc2d62db38
6 changed files with 353 additions and 50 deletions
+4 -3
View File
@@ -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());
const expandedPath = expandPath(settingsPath);
try {
if (!fs.existsSync(expandedPath)) return {};
const content = fs.readFileSync(expandedPath, 'utf8');
+27
View File
@@ -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
+63 -24
View File
@@ -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;
}
/**
+136 -22
View File
@@ -17,7 +17,7 @@ import * as path from 'path';
import { getCcsDir } from '../utils/config-manager';
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 +44,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.
*/
@@ -326,6 +367,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 +377,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,
};
}
}
+8 -1
View File
@@ -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',
};
}
+115
View File
@@ -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();
});
}