mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 12:14:19 +00:00
Merge pull request #627 from kaitranntt/kai/feat/624-share-context
fix(auth): harden shared-context isolation edge cases
This commit is contained in:
26 files changed
+1644
-106
No files matched your search
@@ -29,6 +29,8 @@ export interface ResolvedCreateAccountContext {
|
||||
|
||||
export const DEFAULT_ACCOUNT_CONTEXT_MODE: AccountContextMode = 'isolated';
|
||||
export const DEFAULT_ACCOUNT_CONTEXT_GROUP = 'default';
|
||||
export const MAX_CONTEXT_GROUP_LENGTH = 64;
|
||||
export const ACCOUNT_PROFILE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
||||
|
||||
const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
||||
|
||||
@@ -36,14 +38,21 @@ const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
||||
* Normalize context group names so paths and config stay consistent.
|
||||
*/
|
||||
export function normalizeContextGroupName(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
return value.trim().toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate context group naming constraints.
|
||||
*/
|
||||
export function isValidContextGroupName(value: string): boolean {
|
||||
return CONTEXT_GROUP_PATTERN.test(value);
|
||||
return value.length <= MAX_CONTEXT_GROUP_LENGTH && CONTEXT_GROUP_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate account profile naming constraints.
|
||||
*/
|
||||
export function isValidAccountProfileName(value: string): boolean {
|
||||
return ACCOUNT_PROFILE_NAME_PATTERN.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,8 +93,7 @@ export function resolveCreateAccountContext(
|
||||
if (!isValidContextGroupName(normalizedGroup)) {
|
||||
return {
|
||||
policy: { mode: 'isolated' },
|
||||
error:
|
||||
'Invalid context group. Use letters/numbers/dash/underscore and start with a letter.',
|
||||
error: `Invalid context group. Use letters/numbers/dash/underscore, start with a letter, max ${MAX_CONTEXT_GROUP_LENGTH} chars.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import ProfileRegistry from './profile-registry';
|
||||
import { InstanceManager } from '../management/instance-manager';
|
||||
import { initUI, header, subheader, color, dim, warn, fail } from '../utils/ui';
|
||||
import { MAX_CONTEXT_GROUP_LENGTH } from './account-context';
|
||||
import packageJson from '../../package.json';
|
||||
|
||||
// Import command handlers from modular structure
|
||||
@@ -127,6 +128,10 @@ class AuthCommands {
|
||||
console.log(
|
||||
` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.`
|
||||
);
|
||||
console.log(` Shared context groups are normalized (trim + lowercase) and spaces become "-".`);
|
||||
console.log(
|
||||
` ${color('context_group', 'path')} must be non-empty and <= ${MAX_CONTEXT_GROUP_LENGTH} chars in shared mode.`
|
||||
);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
const AMBIENT_PROVIDER_PREFIXES = [
|
||||
'ANTHROPIC_',
|
||||
'OPENAI_',
|
||||
'GOOGLE_',
|
||||
'GEMINI_',
|
||||
'MINIMAX_',
|
||||
'QWEN_',
|
||||
'DEEPSEEK_',
|
||||
'KIMI_',
|
||||
'AZURE_',
|
||||
'OLLAMA_',
|
||||
'OPENROUTER_',
|
||||
'XAI_',
|
||||
'MISTRAL_',
|
||||
'COHERE_',
|
||||
'PERPLEXITY_',
|
||||
'TOGETHER_',
|
||||
'FIREWORKS_',
|
||||
];
|
||||
const AMBIENT_PROVIDER_EXACT_KEYS = new Set([
|
||||
'OPENROUTER_API_KEY',
|
||||
'OPENROUTER_KEY',
|
||||
'XAI_API_KEY',
|
||||
'MISTRAL_API_KEY',
|
||||
'COHERE_API_KEY',
|
||||
]);
|
||||
const AMBIENT_PROVIDER_SUFFIXES = [
|
||||
'_API_KEY',
|
||||
'_AUTH_TOKEN',
|
||||
'_ACCESS_TOKEN',
|
||||
'_SECRET_KEY',
|
||||
'_API_TOKEN',
|
||||
'_BEARER_TOKEN',
|
||||
'_SESSION_TOKEN',
|
||||
];
|
||||
|
||||
export function stripAmbientProviderCredentials(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const sanitized: NodeJS.ProcessEnv = { ...env };
|
||||
|
||||
for (const envKey of Object.keys(sanitized)) {
|
||||
const normalizedKey = envKey.toUpperCase();
|
||||
|
||||
if (normalizedKey === 'CLAUDE_CONFIG_DIR') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
AMBIENT_PROVIDER_PREFIXES.some((prefix) => normalizedKey.startsWith(prefix)) ||
|
||||
AMBIENT_PROVIDER_EXACT_KEYS.has(normalizedKey) ||
|
||||
AMBIENT_PROVIDER_SUFFIXES.some((suffix) => normalizedKey.endsWith(suffix))
|
||||
) {
|
||||
delete sanitized[envKey];
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
@@ -9,21 +9,41 @@ import { initUI, header, color, fail, warn, info, infoBox, warnBox } from '../..
|
||||
import { getClaudeCliInfo } from '../../utils/claude-detector';
|
||||
import { escapeShellArg, stripClaudeCodeEnv } from '../../utils/shell-executor';
|
||||
import { isUnifiedMode } from '../../config/unified-config-loader';
|
||||
import { ProfileMetadata } from '../../types';
|
||||
import {
|
||||
resolveCreateAccountContext,
|
||||
policyToAccountContextMetadata,
|
||||
formatAccountContextPolicy,
|
||||
isValidAccountProfileName,
|
||||
resolveAccountContextPolicy,
|
||||
} from '../account-context';
|
||||
import { exitWithError } from '../../errors';
|
||||
import { ExitCode } from '../../errors/exit-codes';
|
||||
import { CommandContext, parseArgs } from './types';
|
||||
import { stripAmbientProviderCredentials } from './create-command-env';
|
||||
|
||||
function sanitizeProfileNameForInstance(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the create command
|
||||
*/
|
||||
export async function handleCreate(ctx: CommandContext, args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const { profileName, force, shareContext, contextGroup } = parseArgs(args);
|
||||
const { profileName, force, shareContext, contextGroup, unknownFlags } = parseArgs(args);
|
||||
|
||||
if (unknownFlags && unknownFlags.length > 0) {
|
||||
const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', ');
|
||||
console.log(fail(`Unknown option(s): ${unknownList}`));
|
||||
console.log('');
|
||||
console.log(
|
||||
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>]', 'command')}`
|
||||
);
|
||||
console.log(`Help: ${color('ccs auth --help', 'command')}`);
|
||||
console.log('');
|
||||
exitWithError(`Unknown option(s): ${unknownList}`, ExitCode.PROFILE_ERROR);
|
||||
}
|
||||
|
||||
if (!profileName) {
|
||||
console.log(fail('Profile name is required'));
|
||||
@@ -37,6 +57,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
|
||||
}
|
||||
|
||||
if (!isValidAccountProfileName(profileName)) {
|
||||
const error =
|
||||
'Invalid profile name. Use letters/numbers/dash/underscore and start with a letter.';
|
||||
console.log(fail(error));
|
||||
console.log('');
|
||||
exitWithError(error, ExitCode.PROFILE_ERROR);
|
||||
}
|
||||
|
||||
// Check if profile already exists (check both legacy and unified)
|
||||
const existsLegacy = ctx.registry.hasProfile(profileName);
|
||||
const existsUnified = ctx.registry.hasAccountUnified(profileName);
|
||||
@@ -46,6 +74,18 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
exitWithError(`Profile already exists: ${profileName}`, ExitCode.PROFILE_ERROR);
|
||||
}
|
||||
|
||||
const normalizedName = sanitizeProfileNameForInstance(profileName);
|
||||
const collidingName = Object.keys(ctx.registry.getAllProfilesMerged()).find(
|
||||
(name) => name !== profileName && sanitizeProfileNameForInstance(name) === normalizedName
|
||||
);
|
||||
|
||||
if (collidingName) {
|
||||
const error = `Profile "${profileName}" conflicts with existing profile "${collidingName}" on filesystem.`;
|
||||
console.log(fail(error));
|
||||
console.log('');
|
||||
exitWithError(error, ExitCode.PROFILE_ERROR);
|
||||
}
|
||||
|
||||
const resolvedContext = resolveCreateAccountContext({
|
||||
shareContext: !!shareContext,
|
||||
contextGroup,
|
||||
@@ -59,6 +99,80 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
|
||||
const contextPolicy = resolvedContext.policy;
|
||||
const contextMetadata = policyToAccountContextMetadata(contextPolicy);
|
||||
const useUnifiedConfig = isUnifiedMode();
|
||||
const profileExistedBeforeCreate = existsLegacy || existsUnified;
|
||||
const createdUnifiedProfile = useUnifiedConfig && !existsUnified;
|
||||
const createdLegacyProfile = !useUnifiedConfig && !existsLegacy;
|
||||
const previousLegacyProfile: ProfileMetadata | undefined = existsLegacy
|
||||
? ctx.registry.getProfile(profileName)
|
||||
: undefined;
|
||||
const previousUnifiedProfile = existsUnified
|
||||
? ctx.registry.getAllAccountsUnified()[profileName]
|
||||
: undefined;
|
||||
const previousContextPolicy =
|
||||
profileExistedBeforeCreate && (previousUnifiedProfile || previousLegacyProfile)
|
||||
? resolveAccountContextPolicy(previousUnifiedProfile || previousLegacyProfile)
|
||||
: undefined;
|
||||
|
||||
const claudeInfo = getClaudeCliInfo();
|
||||
if (!claudeInfo) {
|
||||
console.log(fail('Claude CLI not found'));
|
||||
console.log('');
|
||||
console.log('Please install Claude CLI first:');
|
||||
console.log(` ${color('https://claude.ai/download', 'path')}`);
|
||||
exitWithError('Claude CLI not found', ExitCode.BINARY_ERROR);
|
||||
}
|
||||
|
||||
let rollbackCompleted = false;
|
||||
const rollbackMetadata = (): void => {
|
||||
try {
|
||||
if (useUnifiedConfig) {
|
||||
if (createdUnifiedProfile) {
|
||||
if (ctx.registry.hasAccountUnified(profileName)) {
|
||||
ctx.registry.removeAccountUnified(profileName);
|
||||
}
|
||||
} else if (previousUnifiedProfile) {
|
||||
ctx.registry.updateAccountUnified(profileName, previousUnifiedProfile);
|
||||
}
|
||||
} else {
|
||||
if (createdLegacyProfile) {
|
||||
if (ctx.registry.hasProfile(profileName)) {
|
||||
ctx.registry.deleteProfile(profileName);
|
||||
}
|
||||
} else if (previousLegacyProfile) {
|
||||
ctx.registry.updateProfile(profileName, previousLegacyProfile);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best-effort rollback to avoid leaving stale accounts after failed login.
|
||||
}
|
||||
};
|
||||
|
||||
const rollbackFailedCreate = async (): Promise<void> => {
|
||||
if (rollbackCompleted) {
|
||||
return;
|
||||
}
|
||||
rollbackCompleted = true;
|
||||
|
||||
rollbackMetadata();
|
||||
|
||||
if (!profileExistedBeforeCreate) {
|
||||
try {
|
||||
ctx.instanceMgr.deleteInstance(profileName);
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousContextPolicy) {
|
||||
try {
|
||||
await ctx.instanceMgr.ensureInstance(profileName, previousContextPolicy);
|
||||
} catch {
|
||||
// Best-effort rollback for context mode/group.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
// Create instance directory
|
||||
@@ -66,7 +180,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
const instancePath = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy);
|
||||
|
||||
// Create/update profile entry based on config mode
|
||||
if (isUnifiedMode()) {
|
||||
if (useUnifiedConfig) {
|
||||
// Use unified config (config.yaml)
|
||||
if (existsUnified) {
|
||||
ctx.registry.updateAccountUnified(profileName, {
|
||||
@@ -96,43 +210,47 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
|
||||
console.log(info(`Instance directory: ${instancePath}`));
|
||||
console.log('');
|
||||
console.log(warn('Starting Claude in isolated instance...'));
|
||||
const launchDescription =
|
||||
contextPolicy.mode === 'shared'
|
||||
? `Starting Claude with shared context group "${contextPolicy.group || 'default'}"...`
|
||||
: 'Starting Claude in isolated instance...';
|
||||
console.log(warn(launchDescription));
|
||||
console.log(warn('You will be prompted to login with your account.'));
|
||||
console.log('');
|
||||
|
||||
// Detect Claude CLI
|
||||
const claudeInfo = getClaudeCliInfo();
|
||||
if (!claudeInfo) {
|
||||
console.log(fail('Claude CLI not found'));
|
||||
console.log('');
|
||||
console.log('Please install Claude CLI first:');
|
||||
console.log(` ${color('https://claude.ai/download', 'path')}`);
|
||||
exitWithError('Claude CLI not found', ExitCode.BINARY_ERROR);
|
||||
}
|
||||
|
||||
const { path: claudeCli, needsShell } = claudeInfo;
|
||||
const childEnv = stripClaudeCodeEnv({ ...process.env, CLAUDE_CONFIG_DIR: instancePath });
|
||||
const childEnv = stripAmbientProviderCredentials(
|
||||
stripClaudeCodeEnv({ ...process.env, CLAUDE_CONFIG_DIR: instancePath })
|
||||
);
|
||||
|
||||
// Execute Claude in isolated instance (will auto-prompt for login if no credentials)
|
||||
// On Windows, .cmd/.bat/.ps1 files need shell: true to execute properly
|
||||
let child: ChildProcess;
|
||||
if (needsShell) {
|
||||
const cmdString = escapeShellArg(claudeCli);
|
||||
child = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
shell: true,
|
||||
env: childEnv,
|
||||
});
|
||||
} else {
|
||||
child = spawn(claudeCli, [], {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env: childEnv,
|
||||
});
|
||||
try {
|
||||
if (needsShell) {
|
||||
const cmdString = escapeShellArg(claudeCli);
|
||||
child = spawn(cmdString, {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
shell: true,
|
||||
env: childEnv,
|
||||
});
|
||||
} else {
|
||||
child = spawn(claudeCli, [], {
|
||||
stdio: 'inherit',
|
||||
windowsHide: true,
|
||||
env: childEnv,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await rollbackFailedCreate();
|
||||
exitWithError(
|
||||
`Failed to execute Claude CLI: ${(error as Error).message}`,
|
||||
ExitCode.BINARY_ERROR
|
||||
);
|
||||
}
|
||||
|
||||
child.on('exit', (code: number | null) => {
|
||||
child.on('exit', async (code: number | null) => {
|
||||
if (code === 0) {
|
||||
console.log('');
|
||||
console.log(
|
||||
@@ -162,6 +280,8 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
console.log('');
|
||||
process.exit(0);
|
||||
} else {
|
||||
await rollbackFailedCreate();
|
||||
|
||||
console.log('');
|
||||
console.log(fail('Login failed or cancelled'));
|
||||
console.log('');
|
||||
@@ -172,10 +292,12 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err: Error) => {
|
||||
child.on('error', async (err: Error) => {
|
||||
await rollbackFailedCreate();
|
||||
exitWithError(`Failed to execute Claude CLI: ${err.message}`, ExitCode.BINARY_ERROR);
|
||||
});
|
||||
} catch (error) {
|
||||
await rollbackFailedCreate();
|
||||
exitWithError(`Failed to create profile: ${(error as Error).message}`, ExitCode.GENERAL_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
||||
type: 'account',
|
||||
created: account.created,
|
||||
last_used: account.last_used,
|
||||
context_mode: account.context_mode,
|
||||
context_group: account.context_group,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface AuthCommandArgs {
|
||||
yes?: boolean;
|
||||
shareContext?: boolean;
|
||||
contextGroup?: string;
|
||||
unknownFlags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,6 +62,16 @@ export interface CommandContext {
|
||||
export function parseArgs(args: string[]): AuthCommandArgs {
|
||||
let profileName: string | undefined;
|
||||
let contextGroup: string | undefined;
|
||||
const unknownFlags = new Set<string>();
|
||||
const knownBooleanFlags = new Set([
|
||||
'--force',
|
||||
'--verbose',
|
||||
'--json',
|
||||
'--yes',
|
||||
'-y',
|
||||
'--share-context',
|
||||
]);
|
||||
const knownValueFlags = new Set(['--context-group']);
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
@@ -83,6 +94,18 @@ export function parseArgs(args: string[]): AuthCommandArgs {
|
||||
}
|
||||
|
||||
if (arg.startsWith('-')) {
|
||||
const normalizedFlag = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg;
|
||||
const isKnownFlag =
|
||||
knownBooleanFlags.has(normalizedFlag) || knownValueFlags.has(normalizedFlag);
|
||||
if (!isKnownFlag) {
|
||||
unknownFlags.add(normalizedFlag);
|
||||
// Best effort: unknown flags often take a value token.
|
||||
// Skip one following non-flag token to avoid mis-parsing profile name.
|
||||
const next = args[i + 1];
|
||||
if (!arg.includes('=') && next && !next.startsWith('-')) {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -99,5 +122,6 @@ export function parseArgs(args: string[]): AuthCommandArgs {
|
||||
yes: args.includes('--yes') || args.includes('-y'),
|
||||
shareContext: args.includes('--share-context'),
|
||||
contextGroup,
|
||||
unknownFlags: [...unknownFlags],
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} 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)
|
||||
@@ -51,13 +52,31 @@ export class ProfileRegistry {
|
||||
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;
|
||||
} else if (!normalized.context_group || normalized.context_group.trim().length === 0) {
|
||||
delete normalized.context_group;
|
||||
} else {
|
||||
const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
|
||||
if (normalizedGroup) {
|
||||
normalized.context_group = normalizedGroup;
|
||||
} else {
|
||||
delete normalized.context_group;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
@@ -68,8 +87,13 @@ export class ProfileRegistry {
|
||||
|
||||
if (normalized.context_mode !== 'shared') {
|
||||
delete normalized.context_group;
|
||||
} else if (!normalized.context_group || normalized.context_group.trim().length === 0) {
|
||||
delete normalized.context_group;
|
||||
} else {
|
||||
const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
|
||||
if (normalizedGroup) {
|
||||
normalized.context_group = normalizedGroup;
|
||||
} else {
|
||||
delete normalized.context_group;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
@@ -159,7 +183,7 @@ export class ProfileRegistry {
|
||||
throw new Error(`Profile not found: ${name}`);
|
||||
}
|
||||
|
||||
return data.profiles[name];
|
||||
return this.normalizeLegacyProfileMetadata(data.profiles[name]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,7 +239,11 @@ export class ProfileRegistry {
|
||||
*/
|
||||
getAllProfiles(): Record<string, ProfileMetadata> {
|
||||
const data = this._read();
|
||||
return data.profiles;
|
||||
const normalized: Record<string, ProfileMetadata> = {};
|
||||
for (const [name, profile] of Object.entries(data.profiles)) {
|
||||
normalized[name] = this.normalizeLegacyProfileMetadata(profile);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -357,7 +385,11 @@ export class ProfileRegistry {
|
||||
getAllAccountsUnified(): Record<string, AccountConfig> {
|
||||
if (!isUnifiedMode()) return {};
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return config.accounts;
|
||||
const normalized: Record<string, AccountConfig> = {};
|
||||
for (const [name, account] of Object.entries(config.accounts)) {
|
||||
normalized[name] = this.normalizeUnifiedAccountConfig(account);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user