Merge pull request #204 from kaitranntt/kai/fix/203-dashboard-accounts-unified-config

fix(dashboard): read accounts from unified config
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2025-12-26 09:20:02 -08:00
commit 90134de28b
8 files changed
+197 -40

No files matched your search

+36
View File
@@ -332,6 +332,42 @@ export class ProfileRegistry {
config.accounts[name].last_used = new Date().toISOString();
saveUnifiedConfig(config);
}
// ==========================================
// DRY Helper Methods (consolidated logic)
// ==========================================
/**
* Get all profiles merged from both legacy and unified config.
* Unified config takes precedence for duplicate names.
* DRY helper to consolidate merge logic used in multiple places.
*/
getAllProfilesMerged(): Record<string, ProfileMetadata> {
const legacyProfiles = this.getAllProfiles();
const unifiedAccounts = this.getAllAccountsUnified();
// Start with legacy profiles
const merged: Record<string, ProfileMetadata> = { ...legacyProfiles };
// Override with unified config accounts (takes precedence)
for (const [name, account] of Object.entries(unifiedAccounts)) {
merged[name] = {
type: 'account',
created: account.created,
last_used: account.last_used,
};
}
return merged;
}
/**
* Get resolved default profile from unified config first, fallback to legacy.
* DRY helper to consolidate default resolution logic.
*/
getDefaultResolved(): string | null {
return this.getDefaultUnified() ?? this.getDefaultProfile();
}
}
export default ProfileRegistry;
+3 -2
View File
@@ -22,7 +22,8 @@ export function createFileWatcher(onChange: FileChangeCallback): FSWatcher {
const watcher = chokidar.watch(
[
path.join(ccsDir, 'config.json'),
path.join(ccsDir, 'config.json'), // Legacy config
path.join(ccsDir, 'config.yaml'), // Unified config
path.join(ccsDir, '*.settings.json'),
path.join(ccsDir, 'profiles.json'),
path.join(ccsDir, 'cliproxy', 'sessions.json'), // Proxy session tracking
@@ -41,7 +42,7 @@ export function createFileWatcher(onChange: FileChangeCallback): FSWatcher {
const basename = path.basename(filePath);
let type: FileChangeEvent['type'];
if (basename === 'config.json') {
if (basename === 'config.json' || basename === 'config.yaml') {
type = 'config-changed';
} else if (basename === 'profiles.json') {
type = 'profiles-changed';
+17
View File
@@ -142,6 +142,17 @@ export function fixHealthIssue(checkId: string): { success: boolean; message: st
return { success: true, message: 'Created ~/.ccs directory' };
case 'config-file': {
// Use appropriate config based on unified mode
const { isUnifiedMode } = require('../config/unified-config-loader');
if (isUnifiedMode()) {
const {
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
} = require('../config/unified-config-loader');
const config = loadOrCreateUnifiedConfig();
saveUnifiedConfig(config);
return { success: true, message: 'Created/updated config.yaml' };
}
const configPath = getConfigPath();
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({ profiles: {} }, null, 2) + '\n');
@@ -149,6 +160,12 @@ export function fixHealthIssue(checkId: string): { success: boolean; message: st
}
case 'profiles-file': {
// Use appropriate storage based on unified mode
const { isUnifiedMode: isUnified } = require('../config/unified-config-loader');
if (isUnified()) {
// In unified mode, accounts are stored in config.yaml
return { success: true, message: 'Accounts stored in config.yaml (unified mode)' };
}
const profilesPath = path.join(ccsDir, 'profiles.json');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(profilesPath, JSON.stringify({ profiles: {} }, null, 2) + '\n');
+41 -2
View File
@@ -1,18 +1,57 @@
/**
* Configuration Health Checks
*
* Check config.json, settings files, and Claude settings.
* Check config.json, config.yaml, settings files, and Claude settings.
* Supports both legacy (config.json) and unified (config.yaml) modes.
*/
import * as fs from 'fs';
import * as path from 'path';
import { getConfigPath } from '../../utils/config-manager';
import { isUnifiedMode, hasUnifiedConfig } from '../../config/unified-config-loader';
import type { HealthCheck } from './types';
/**
* Check config.json file
* Check config file (config.json or config.yaml based on mode)
*/
export function checkConfigFile(): HealthCheck {
// In unified mode, check config.yaml
if (isUnifiedMode() || hasUnifiedConfig()) {
const ccsDir = path.join(process.env.HOME || '', '.ccs');
const yamlPath = path.join(ccsDir, 'config.yaml');
if (!fs.existsSync(yamlPath)) {
return {
id: 'config-file',
name: 'config.yaml',
status: 'warning',
message: 'Not found (unified mode)',
details: yamlPath,
fixable: true,
};
}
try {
fs.readFileSync(yamlPath, 'utf8');
return {
id: 'config-file',
name: 'config.yaml',
status: 'ok',
message: 'Valid (unified mode)',
details: yamlPath,
};
} catch {
return {
id: 'config-file',
name: 'config.yaml',
status: 'error',
message: 'Cannot read file',
details: yamlPath,
};
}
}
// Legacy mode: check config.json
const configPath = getConfigPath();
if (!fs.existsSync(configPath)) {
+37 -1
View File
@@ -2,16 +2,52 @@
* Profile Health Checks
*
* Check profiles, instances, and delegation.
* Supports both legacy (config.json) and unified (config.yaml) modes.
*/
import * as fs from 'fs';
import * as path from 'path';
import { isUnifiedMode, loadUnifiedConfig } from '../../config/unified-config-loader';
import type { HealthCheck } from './types';
/**
* Check profiles configuration
* Check profiles configuration (API profiles from config.json or config.yaml)
*/
export function checkProfiles(ccsDir: string): HealthCheck {
// Check unified config first
if (isUnifiedMode()) {
const config = loadUnifiedConfig();
if (!config) {
return {
id: 'profiles',
name: 'Profiles',
status: 'info',
message: 'config.yaml not loaded',
};
}
const profileCount = Object.keys(config.profiles || {}).length;
const profileNames = Object.keys(config.profiles || {}).join(', ');
if (profileCount === 0) {
return {
id: 'profiles',
name: 'Profiles',
status: 'ok',
message: 'No API profiles (unified mode)',
};
}
return {
id: 'profiles',
name: 'Profiles',
status: 'ok',
message: `${profileCount} configured (unified)`,
details: profileNames.length > 40 ? profileNames.substring(0, 37) + '...' : profileNames,
};
}
// Legacy mode: check config.json
const configPath = path.join(ccsDir, 'config.json');
if (!fs.existsSync(configPath)) {
+10 -7
View File
@@ -5,12 +5,11 @@
*/
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir, loadConfig } from '../utils/config-manager';
import { loadConfig } from '../utils/config-manager';
import { runHealthChecks } from './health-service';
import { getAllAuthStatus, initializeAccounts } from '../cliproxy/auth-handler';
import { getVersion } from '../utils/version';
import ProfileRegistry from '../auth/profile-registry';
export const overviewRoutes = Router();
@@ -64,12 +63,16 @@ overviewRoutes.get('/', async (_req: Request, res: Response) => {
function getAccountCount(): number {
try {
const profilesPath = path.join(getCcsDir(), 'profiles.json');
const registry = new ProfileRegistry();
if (!fs.existsSync(profilesPath)) return 0;
// Merge legacy (profiles.json) and unified (config.yaml) accounts
const legacyProfiles = registry.getAllProfiles();
const unifiedAccounts = registry.getAllAccountsUnified();
const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8'));
return Object.keys(data.profiles || {}).length;
// Count unique accounts (unified takes precedence for duplicates)
const allNames = new Set([...Object.keys(legacyProfiles), ...Object.keys(unifiedAccounts)]);
return allNames.size;
} catch {
return 0;
}
+43 -26
View File
@@ -1,38 +1,57 @@
/**
* Account Routes - CRUD operations for Claude accounts (profiles.json)
* Account Routes - CRUD operations for Claude accounts
*
* Separated from profile-routes.ts to avoid dual-mounting conflicts.
* Uses ProfileRegistry to read from both legacy (profiles.json)
* and unified config (config.yaml) for consistent data with CLI.
*/
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
import ProfileRegistry from '../../auth/profile-registry';
import { isUnifiedMode } from '../../config/unified-config-loader';
const router = Router();
const registry = new ProfileRegistry();
/**
* GET /api/accounts - List accounts from profiles.json
* GET /api/accounts - List accounts from both profiles.json and config.yaml
*/
router.get('/', (_req: Request, res: Response): void => {
try {
const profilesPath = path.join(getCcsDir(), 'profiles.json');
// Get profiles from both legacy and unified config (same logic as CLI)
const legacyProfiles = registry.getAllProfiles();
const unifiedAccounts = registry.getAllAccountsUnified();
if (!fs.existsSync(profilesPath)) {
res.json({ accounts: [], default: null });
return;
// Merge profiles: unified config takes precedence
const merged: Record<string, { type: string; created: string; last_used: string | null }> = {};
// Add legacy profiles first
for (const [name, meta] of Object.entries(legacyProfiles)) {
merged[name] = {
type: meta.type || 'account',
created: meta.created,
last_used: meta.last_used || null,
};
}
const data = JSON.parse(fs.readFileSync(profilesPath, 'utf8'));
const accounts = Object.entries(data.profiles || {}).map(([name, meta]) => {
const metadata = meta as Record<string, unknown>;
return {
name,
...metadata,
// Override with unified config accounts (takes precedence)
for (const [name, account] of Object.entries(unifiedAccounts)) {
merged[name] = {
type: 'account',
created: account.created,
last_used: account.last_used,
};
});
}
res.json({ accounts, default: data.default || null });
// Convert to array format
const accounts = Object.entries(merged).map(([name, meta]) => ({
name,
...meta,
}));
// Get default from unified config first, fallback to legacy
const defaultProfile = registry.getDefaultUnified() ?? registry.getDefaultProfile() ?? null;
res.json({ accounts, default: defaultProfile });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
@@ -50,14 +69,12 @@ router.post('/default', (req: Request, res: Response): void => {
return;
}
const profilesPath = path.join(getCcsDir(), 'profiles.json');
const data = fs.existsSync(profilesPath)
? JSON.parse(fs.readFileSync(profilesPath, 'utf8'))
: { profiles: {} };
data.default = name;
fs.writeFileSync(profilesPath, JSON.stringify(data, null, 2) + '\n');
// Use unified config if in unified mode, otherwise use legacy
if (isUnifiedMode()) {
registry.setDefaultUnified(name);
} else {
registry.setDefaultProfile(name);
}
res.json({ default: name });
} catch (error) {
+10 -2
View File
@@ -222,7 +222,11 @@ router.post('/:profile/presets', (req: Request, res: Response): void => {
};
settings.presets.push(preset);
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
// Atomic write: temp file + rename
const tempPath = settingsPath + '.tmp';
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
fs.renameSync(tempPath, settingsPath);
res.status(201).json({ preset });
} catch (error) {
@@ -250,7 +254,11 @@ router.delete('/:profile/presets/:name', (req: Request, res: Response): void =>
}
settings.presets = settings.presets.filter((p) => p.name !== name);
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
// Atomic write: temp file + rename
const tempPath = settingsPath + '.tmp';
fs.writeFileSync(tempPath, JSON.stringify(settings, null, 2) + '\n');
fs.renameSync(tempPath, settingsPath);
res.json({ success: true });
} catch (error) {