Files
ccs/src/auth/profile-registry.ts
T

468 lines
13 KiB
TypeScript

import * as fs from 'fs';
import * as path from 'path';
import { ProfileMetadata } from '../types';
import {
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
isUnifiedMode,
} from '../config/unified-config-loader';
import type { AccountConfig } from '../config/unified-config-types';
import { getCcsDir } from '../utils/config-manager';
import { isValidContextGroupName, normalizeContextGroupName } from './account-context';
/**
* Profile Registry (Simplified)
*
* Manages account profile metadata in ~/.ccs/profiles.json
* Each profile represents an isolated Claude instance with login credentials.
*
* Profile Schema (v3.0 - Minimal):
* {
* type: 'account', // Profile type
* created: <ISO timestamp>, // Creation time
* last_used: <ISO timestamp or null> // Last usage time
* context_mode?: 'isolated' | 'shared' // Workspace context policy
* context_group?: <string> // Shared context group when mode=shared
* continuity_mode?: 'standard' | 'deeper' // Shared continuity depth
* }
*
* Removed fields from v2.x:
* - vault: No encrypted vault (credentials in instance)
* - subscription: No credential reading
* - email: No credential reading
*/
interface ProfileData {
version: string;
profiles: Record<string, ProfileMetadata>;
default: string | null;
}
interface CreateMetadata {
type?: string;
created?: string;
last_used?: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string;
continuity_mode?: 'standard' | 'deeper';
}
export class ProfileRegistry {
private profilesPath: string;
constructor() {
this.profilesPath = path.join(getCcsDir(), 'profiles.json');
}
private normalizeContextGroupValue(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = normalizeContextGroupName(value);
if (normalized.length === 0 || !isValidContextGroupName(normalized)) {
return undefined;
}
return normalized;
}
private normalizeLegacyProfileMetadata(metadata: ProfileMetadata): ProfileMetadata {
const normalized: ProfileMetadata = { ...metadata };
if (normalized.context_mode !== 'shared') {
delete normalized.context_group;
delete normalized.continuity_mode;
} else {
const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
if (normalizedGroup) {
normalized.context_group = normalizedGroup;
} else {
delete normalized.context_group;
}
normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard';
}
return normalized;
}
private normalizeUnifiedAccountConfig(account: AccountConfig): AccountConfig {
const normalized: AccountConfig = { ...account };
if (normalized.context_mode !== 'shared') {
delete normalized.context_group;
delete normalized.continuity_mode;
} else {
const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
if (normalizedGroup) {
normalized.context_group = normalizedGroup;
} else {
delete normalized.context_group;
}
normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard';
}
return normalized;
}
/**
* Read profiles from disk
*/
private _read(): ProfileData {
if (!fs.existsSync(this.profilesPath)) {
return {
version: '2.0.0',
profiles: {},
default: null,
};
}
try {
const data = fs.readFileSync(this.profilesPath, 'utf8');
return JSON.parse(data) as ProfileData;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to read profiles: ${message}`);
}
}
/**
* Write profiles to disk atomically
*/
private _write(data: ProfileData): void {
const dir = path.dirname(this.profilesPath);
// Ensure directory exists
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
// Atomic write: temp file + rename
const tempPath = `${this.profilesPath}.tmp`;
try {
fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), { mode: 0o600 });
fs.renameSync(tempPath, this.profilesPath);
} catch (error) {
// Cleanup temp file on error
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
const message = error instanceof Error ? error.message : 'Unknown error';
throw new Error(`Failed to write profiles: ${message}`);
}
}
/**
* Create a new profile
*/
createProfile(name: string, metadata: CreateMetadata = {}): void {
const data = this._read();
if (data.profiles[name]) {
throw new Error(`Profile already exists: ${name}`);
}
// v3.0 minimal schema: only essential fields
data.profiles[name] = this.normalizeLegacyProfileMetadata({
type: metadata.type || 'account',
created: metadata.created || new Date().toISOString(),
last_used: metadata.last_used || null,
context_mode: metadata.context_mode,
context_group: metadata.context_group,
continuity_mode: metadata.continuity_mode,
});
// Note: No longer auto-set as default
// Users must explicitly run: ccs auth default <profile>
// Default always stays on implicit 'default' profile (uses ~/.claude/)
this._write(data);
}
/**
* Get profile metadata
*/
getProfile(name: string): ProfileMetadata {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
return this.normalizeLegacyProfileMetadata(data.profiles[name]);
}
/**
* Update profile metadata
*/
updateProfile(name: string, updates: Partial<ProfileMetadata>): void {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
data.profiles[name] = this.normalizeLegacyProfileMetadata({
...data.profiles[name],
...updates,
});
this._write(data);
}
/**
* Delete a profile
*/
deleteProfile(name: string): void {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
delete data.profiles[name];
// Clear default if it was the deleted profile
if (data.default === name) {
// Set to first remaining profile or null
const remaining = Object.keys(data.profiles);
data.default = remaining.length > 0 ? remaining[0] : null;
}
this._write(data);
}
/**
* List all profiles
*/
listProfiles(): string[] {
const data = this._read();
return Object.keys(data.profiles);
}
/**
* Get all profiles with metadata
*/
getAllProfiles(): Record<string, ProfileMetadata> {
const data = this._read();
const normalized: Record<string, ProfileMetadata> = {};
for (const [name, profile] of Object.entries(data.profiles)) {
normalized[name] = this.normalizeLegacyProfileMetadata(profile);
}
return normalized;
}
/**
* Get default profile name
*/
getDefaultProfile(): string | null {
const data = this._read();
return data.default;
}
/**
* Set default profile
*/
setDefaultProfile(name: string): void {
const data = this._read();
if (!data.profiles[name]) {
throw new Error(`Profile not found: ${name}`);
}
data.default = name;
this._write(data);
}
/**
* Clear default profile (restore original CCS behavior)
*/
clearDefaultProfile(): void {
const data = this._read();
data.default = null;
this._write(data);
}
/**
* Check if profile exists
*/
hasProfile(name: string): boolean {
const data = this._read();
return !!data.profiles[name];
}
/**
* Update last used timestamp
*/
touchProfile(name: string): void {
this.updateProfile(name, {
last_used: new Date().toISOString(),
});
}
// ==========================================
// Unified Config Methods
// ==========================================
/**
* Create account in unified config (config.yaml)
*/
createAccountUnified(name: string, metadata: CreateMetadata = {}): void {
const config = loadOrCreateUnifiedConfig();
if (config.accounts[name]) {
throw new Error(`Account already exists: ${name}`);
}
config.accounts[name] = this.normalizeUnifiedAccountConfig({
created: new Date().toISOString(),
last_used: null,
context_mode: metadata.context_mode,
context_group: metadata.context_group,
continuity_mode: metadata.continuity_mode,
});
saveUnifiedConfig(config);
}
/**
* Update account metadata in unified config
*/
updateAccountUnified(name: string, updates: Partial<AccountConfig>): void {
const config = loadOrCreateUnifiedConfig();
if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`);
}
config.accounts[name] = this.normalizeUnifiedAccountConfig({
...config.accounts[name],
...updates,
});
saveUnifiedConfig(config);
}
/**
* Remove account from unified config
*/
removeAccountUnified(name: string): void {
const config = loadOrCreateUnifiedConfig();
if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`);
}
delete config.accounts[name];
// Clear default if it was the deleted account
if (config.default === name) {
config.default = undefined;
}
saveUnifiedConfig(config);
}
/**
* Set default profile in unified config
*/
setDefaultUnified(name: string): void {
const config = loadOrCreateUnifiedConfig();
// Check if exists in accounts, profiles, or cliproxy variants
const exists =
config.accounts[name] || config.profiles[name] || config.cliproxy?.variants?.[name];
if (!exists) {
throw new Error(`Profile not found: ${name}`);
}
config.default = name;
saveUnifiedConfig(config);
}
/**
* Clear default profile in unified config (restore original CCS behavior)
*/
clearDefaultUnified(): void {
const config = loadOrCreateUnifiedConfig();
config.default = undefined;
saveUnifiedConfig(config);
}
/**
* Check if account exists in unified config
*/
hasAccountUnified(name: string): boolean {
if (!isUnifiedMode()) return false;
const config = loadOrCreateUnifiedConfig();
return !!config.accounts[name];
}
/**
* Get all accounts from unified config
*/
getAllAccountsUnified(): Record<string, AccountConfig> {
if (!isUnifiedMode()) return {};
const config = loadOrCreateUnifiedConfig();
const normalized: Record<string, AccountConfig> = {};
for (const [name, account] of Object.entries(config.accounts)) {
normalized[name] = this.normalizeUnifiedAccountConfig(account);
}
return normalized;
}
/**
* Get default from unified config
*/
getDefaultUnified(): string | undefined {
if (!isUnifiedMode()) return undefined;
const config = loadOrCreateUnifiedConfig();
return config.default;
}
/**
* Update account last_used in unified config
*/
touchAccountUnified(name: string): void {
const config = loadOrCreateUnifiedConfig();
if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`);
}
config.accounts[name].last_used = new Date().toISOString();
config.accounts[name] = this.normalizeUnifiedAccountConfig(config.accounts[name]);
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,
context_mode: account.context_mode,
context_group: account.context_group,
continuity_mode: account.continuity_mode,
};
}
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;