mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
Issue #1161. Sweeps 127 files to import from src/config/config-loader-facade.ts instead of unified-config-loader or utils/config-manager directly. WRITE callers (32 files): replaced raw saveUnifiedConfig / mutateUnifiedConfig / updateUnifiedConfig calls with the facade's cache-coherent wrappers saveConfig / mutateConfig / updateConfig. This fixes a latent stale-cache window where direct writes through the underlying loader bypassed the facade's memoization. READ callers (95 files): mechanical import-path migration only — function names unchanged because the facade re-exports them. No behavior change. Also updated: - tests/unit/utils/browser/browser-setup.test.ts (DI interface rename) - src/management/checks/image-analysis-check.ts (dynamic import rename) - src/web-server/health-service.ts (dynamic require rename) - src/ccs.ts (path prefix fix from sweep script) After sweep: zero raw write callers remain outside src/config/. Direct imports of config-manager remain only for symbols not in the facade (getConfigPath, getCcsDirSource, etc). Behavior unchanged; full suite passes 1824/1824. Out of scope: switching loadOrCreateUnifiedConfig() callers to getCachedConfig() — needs per-callsite cache-safety analysis. Tracked as follow-up. Refs #1161
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
/**
|
|
* Create Command Handler
|
|
*
|
|
* Creates a new profile and prompts for login in an isolated Claude instance.
|
|
*/
|
|
|
|
import { spawn, ChildProcess } from 'child_process';
|
|
import { initUI, header, color, fail, warn, info, infoBox, warnBox } from '../../utils/ui';
|
|
import { getClaudeCliInfo } from '../../utils/claude-detector';
|
|
import {
|
|
escapeShellArg,
|
|
getWindowsEscapedCommandShell,
|
|
stripClaudeCodeEnv,
|
|
} from '../../utils/shell-executor';
|
|
|
|
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';
|
|
import { isUnifiedMode } from '../../config/config-loader-facade';
|
|
|
|
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, deeperContinuity, bare, 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] [--bare] [--share-context] [--context-group <name>] [--deeper-continuity]', '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'));
|
|
console.log('');
|
|
console.log(
|
|
`Usage: ${color('ccs auth create <profile> [--force] [--bare] [--share-context] [--context-group <name>] [--deeper-continuity]', 'command')}`
|
|
);
|
|
console.log('');
|
|
console.log('Example:');
|
|
console.log(` ${color('ccs auth create work', 'command')}`);
|
|
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);
|
|
if (!force && (existsLegacy || existsUnified)) {
|
|
console.log(fail(`Profile already exists: ${profileName}`));
|
|
console.log(` Use ${color('--force', 'command')} to overwrite`);
|
|
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,
|
|
deeperContinuity: !!deeperContinuity,
|
|
});
|
|
|
|
if (resolvedContext.error) {
|
|
console.log(fail(resolvedContext.error));
|
|
console.log('');
|
|
exitWithError(resolvedContext.error, ExitCode.PROFILE_ERROR);
|
|
}
|
|
|
|
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 previousBare =
|
|
previousLegacyProfile?.bare === true || previousUnifiedProfile?.bare === true;
|
|
const effectiveBare = bare === true || (profileExistedBeforeCreate && previousBare);
|
|
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 {
|
|
await ctx.instanceMgr.deleteInstance(profileName);
|
|
} catch {
|
|
// Best-effort cleanup.
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (previousContextPolicy) {
|
|
try {
|
|
await ctx.instanceMgr.ensureInstance(profileName, previousContextPolicy, {
|
|
bare: previousBare,
|
|
});
|
|
} catch {
|
|
// Best-effort rollback for context mode/group.
|
|
}
|
|
}
|
|
};
|
|
|
|
try {
|
|
// Create instance directory
|
|
console.log(info(`Creating profile: ${profileName}`));
|
|
const instancePath = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, {
|
|
bare: effectiveBare,
|
|
});
|
|
|
|
// Create/update profile entry based on config mode
|
|
if (useUnifiedConfig) {
|
|
// Use unified config (config.yaml)
|
|
if (existsUnified) {
|
|
ctx.registry.updateAccountUnified(profileName, {
|
|
context_mode: contextMetadata.context_mode,
|
|
context_group: contextMetadata.context_group,
|
|
...(effectiveBare ? { bare: true } : {}),
|
|
});
|
|
ctx.registry.touchAccountUnified(profileName);
|
|
} else {
|
|
ctx.registry.createAccountUnified(profileName, {
|
|
...contextMetadata,
|
|
...(effectiveBare ? { bare: true } : {}),
|
|
});
|
|
}
|
|
} else {
|
|
// Use legacy profiles.json
|
|
if (existsLegacy) {
|
|
ctx.registry.updateProfile(profileName, {
|
|
type: 'account',
|
|
context_mode: contextMetadata.context_mode,
|
|
context_group: contextMetadata.context_group,
|
|
...(effectiveBare ? { bare: true } : {}),
|
|
});
|
|
} else {
|
|
ctx.registry.createProfile(profileName, {
|
|
type: 'account',
|
|
context_mode: contextMetadata.context_mode,
|
|
context_group: contextMetadata.context_group,
|
|
...(effectiveBare ? { bare: true } : {}),
|
|
});
|
|
}
|
|
}
|
|
|
|
console.log(info(`Instance directory: ${instancePath}`));
|
|
console.log('');
|
|
const launchDescription =
|
|
contextPolicy.mode === 'shared'
|
|
? contextPolicy.continuityMode === 'deeper'
|
|
? `Starting Claude with shared context group "${contextPolicy.group || 'default'}" (deeper continuity)...`
|
|
: `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('');
|
|
|
|
const { path: claudeCli, needsShell } = claudeInfo;
|
|
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;
|
|
try {
|
|
if (needsShell) {
|
|
const cmdString = escapeShellArg(claudeCli);
|
|
child = spawn(cmdString, {
|
|
stdio: 'inherit',
|
|
windowsHide: true,
|
|
shell: getWindowsEscapedCommandShell(),
|
|
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', async (code: number | null) => {
|
|
if (code === 0) {
|
|
console.log('');
|
|
console.log(
|
|
infoBox(
|
|
`Profile: ${profileName}\n` +
|
|
`Instance: ${instancePath}\n` +
|
|
`Type: account\n` +
|
|
`Context: ${formatAccountContextPolicy(contextPolicy)}` +
|
|
(effectiveBare ? '\nMode: bare (no shared symlinks)' : ''),
|
|
'Profile Created'
|
|
)
|
|
);
|
|
console.log('');
|
|
console.log(header('Usage'));
|
|
console.log(` ${color(`ccs ${profileName} "your prompt here"`, 'command')}`);
|
|
console.log('');
|
|
console.log(
|
|
warnBox(
|
|
`Running the command below will SWITCH your default\n` +
|
|
`CCS account to "${profileName}". After this, running\n` +
|
|
`"ccs" without a profile name will use this account.\n\n` +
|
|
` ${color(`ccs auth default ${profileName}`, 'command')}\n\n` +
|
|
`To restore the original default, run:\n` +
|
|
` ${color('ccs auth reset-default', 'command')}`,
|
|
'Set as Default?'
|
|
)
|
|
);
|
|
console.log('');
|
|
process.exit(0);
|
|
} else {
|
|
await rollbackFailedCreate();
|
|
|
|
console.log('');
|
|
console.log(fail('Login failed or cancelled'));
|
|
console.log('');
|
|
console.log('To retry:');
|
|
console.log(` ${color(`ccs auth create ${profileName} --force`, 'command')}`);
|
|
console.log('');
|
|
exitWithError('Login failed or cancelled', ExitCode.AUTH_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);
|
|
}
|
|
}
|