Files
ccs/src/api/services/profile-lifecycle-service.ts
T
Tam Nhu Tran 4f6e61739c refactor(config): adopt config-loader-facade across the codebase
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
2026-05-03 01:42:53 -04:00

429 lines
13 KiB
TypeScript

/**
* API profile lifecycle service.
*
* Discovery, registration, copy, export, and import for API profiles.
*/
import * as fs from 'fs';
import * as path from 'path';
import type { Config, Settings } from '../../types';
import type { TargetType } from '../../targets/target-adapter';
import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata';
import { getConfigPath } from '../../utils/config-manager';
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
import { isSensitiveKey } from '../../utils/sensitive-keys';
import { isReservedName } from '../../config/reserved-names';
import { validateApiName } from './validation-service';
import { listApiProfiles } from './profile-reader';
import { validateApiProfileSettingsPayload } from './profile-lifecycle-validation';
import type {
ApiProfileExportBundle,
CopyApiProfileResult,
DiscoverApiProfileOrphansResult,
ExportApiProfileResult,
ImportApiProfileResult,
RegisterApiProfileOrphansResult,
} from './profile-types';
import {
getCcsDir,
isUnifiedMode,
loadConfigSafe,
mutateConfig,
} from '../../config/config-loader-facade';
const SETTINGS_FILE_SUFFIX = '.settings.json';
const REDACTED_TOKEN_SENTINEL = '__CCS_REDACTED__';
function parseTargetValue(value: unknown): TargetType | null {
if (isPersistedTargetType(value)) {
return value;
}
return null;
}
function validateProfileNameForPath(name: string, label: string): string | null {
const validationError = validateApiName(name);
if (validationError) {
return `Invalid ${label} profile name "${name}": ${validationError}`;
}
return null;
}
function getProfileSettingsPath(name: string): string {
return path.join(getCcsDir(), `${name}${SETTINGS_FILE_SUFFIX}`);
}
function writeJsonObjectAtomically(filePath: string, value: unknown): void {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const tempPath = `${filePath}.tmp`;
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2) + '\n', 'utf8');
fs.renameSync(tempPath, filePath);
}
function registerApiProfileInConfig(name: string, target: TargetType, force = false): void {
if (isUnifiedMode()) {
mutateConfig((config) => {
if (config.profiles[name] && !force) {
throw new Error(`API profile already exists: ${name}`);
}
config.profiles[name] = {
type: 'api',
settings: `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`,
...(target !== 'claude' && { target }),
};
});
return;
}
const configPath = getConfigPath();
const config = loadConfigSafe() as Config;
if (config.profiles[name] && !force) {
throw new Error(`API profile already exists: ${name}`);
}
config.profiles[name] = `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`;
config.profile_targets = config.profile_targets || {};
if (target === 'claude') {
delete config.profile_targets[name];
} else {
config.profile_targets[name] = target;
}
writeJsonObjectAtomically(configPath, config);
}
function getRegisteredSettingsFileNames(): Set<string> {
const { profiles, variants } = listApiProfiles();
const names = new Set<string>();
for (const profile of profiles) {
names.add(`${profile.name}${SETTINGS_FILE_SUFFIX}`);
}
for (const variant of variants) {
if (!variant.settings || variant.settings === '-') continue;
names.add(path.basename(variant.settings.replace(/^~\/\.ccs\//, '')));
}
return names;
}
function getProfileTarget(name: string): TargetType {
const { profiles } = listApiProfiles();
return profiles.find((profile) => profile.name === name)?.target || 'claude';
}
function readJsonObject(filePath: string): Record<string, unknown> {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Settings file must contain a JSON object.');
}
return parsed as Record<string, unknown>;
}
function rollbackSettingsFile(
filePath: string,
previousContent: string | null,
existedBefore: boolean
): void {
if (existedBefore && previousContent !== null) {
fs.writeFileSync(filePath, previousContent, 'utf8');
return;
}
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
export function discoverApiProfileOrphans(): DiscoverApiProfileOrphansResult {
const ccsDir = getCcsDir();
if (!fs.existsSync(ccsDir)) {
return { orphans: [] };
}
const registeredSettings = getRegisteredSettingsFileNames();
const files = fs.readdirSync(ccsDir).filter((file) => file.endsWith(SETTINGS_FILE_SUFFIX));
const ignoredNames = new Set(['cursor.settings.json']);
const orphans = files
.filter((file) => !registeredSettings.has(file))
.filter((file) => !file.startsWith('base-'))
.filter((file) => !ignoredNames.has(file))
.filter((file) => !isReservedName(file.slice(0, -SETTINGS_FILE_SUFFIX.length)))
.map((file) => {
const name = file.slice(0, -SETTINGS_FILE_SUFFIX.length);
const settingsPath = path.join(ccsDir, file);
try {
const settings = readJsonObject(settingsPath);
return {
name,
settingsPath,
validation: validateApiProfileSettingsPayload(settings),
};
} catch (error) {
return {
name,
settingsPath,
validation: {
valid: false,
issues: [
{
level: 'error' as const,
code: 'invalid_json',
message: (error as Error).message,
field: 'settings',
hint: 'Fix JSON syntax before registration.',
},
],
},
};
}
});
return { orphans };
}
export function registerApiProfileOrphans(options?: {
names?: string[];
target?: TargetType;
force?: boolean;
}): RegisterApiProfileOrphansResult {
const discovered = discoverApiProfileOrphans();
const selected =
options?.names === undefined
? discovered.orphans
: discovered.orphans.filter((orphan) => options.names?.includes(orphan.name));
const result: RegisterApiProfileOrphansResult = { registered: [], skipped: [] };
for (const orphan of selected) {
const nameError = validateApiName(orphan.name);
if (nameError) {
result.skipped.push({
name: orphan.name,
reason: `Invalid profile name: ${nameError}`,
});
continue;
}
if (!options?.force && !orphan.validation.valid) {
result.skipped.push({
name: orphan.name,
reason: 'Validation failed. Use --force to register.',
});
continue;
}
try {
if (orphan.validation.valid) {
ensureWebSearchMcpOrThrow();
ensureImageAnalysisMcpOrThrow();
}
registerApiProfileInConfig(orphan.name, options?.target || 'claude', options?.force || false);
result.registered.push(orphan.name);
} catch (error) {
result.skipped.push({ name: orphan.name, reason: (error as Error).message });
}
}
return result;
}
export function copyApiProfile(
source: string,
destination: string,
options?: { target?: TargetType; force?: boolean }
): CopyApiProfileResult {
const sourceError = validateProfileNameForPath(source, 'source');
if (sourceError) return { success: false, error: sourceError };
const destinationError = validateApiName(destination);
if (destinationError) return { success: false, error: destinationError };
const sourceSettingsPath = getProfileSettingsPath(source);
if (!fs.existsSync(sourceSettingsPath)) {
return { success: false, error: `Source profile settings not found: ${source}` };
}
const destinationSettingsPath = getProfileSettingsPath(destination);
if (fs.existsSync(destinationSettingsPath) && !options?.force) {
return { success: false, error: `Destination settings already exist: ${destination}` };
}
try {
const sourceSettings = readJsonObject(sourceSettingsPath) as Settings;
const validation = validateApiProfileSettingsPayload(sourceSettings);
if (!validation.valid && !options?.force) {
return {
success: false,
error: 'Source profile has validation errors. Use --force to copy.',
};
}
const destinationExisted = fs.existsSync(destinationSettingsPath);
const previousDestinationContent = destinationExisted
? fs.readFileSync(destinationSettingsPath, 'utf8')
: null;
writeJsonObjectAtomically(destinationSettingsPath, sourceSettings);
try {
ensureWebSearchMcpOrThrow();
ensureImageAnalysisMcpOrThrow();
} catch (hookError) {
rollbackSettingsFile(destinationSettingsPath, previousDestinationContent, destinationExisted);
throw hookError;
}
try {
registerApiProfileInConfig(
destination,
options?.target || getProfileTarget(source),
options?.force
);
} catch (registrationError) {
rollbackSettingsFile(destinationSettingsPath, previousDestinationContent, destinationExisted);
throw registrationError;
}
return {
success: true,
name: destination,
settingsPath: destinationSettingsPath,
warnings: validation.issues
.filter((issue) => issue.level === 'warning')
.map((issue) => issue.message),
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
export function exportApiProfile(name: string, includeSecrets = false): ExportApiProfileResult {
const nameError = validateProfileNameForPath(name, 'profile');
if (nameError) return { success: false, error: nameError };
const settingsPath = getProfileSettingsPath(name);
if (!fs.existsSync(settingsPath)) {
return { success: false, error: `Profile settings not found: ${name}` };
}
try {
const settings = readJsonObject(settingsPath);
let redacted = false;
if (!includeSecrets) {
const env = settings.env;
if (typeof env === 'object' && env !== null) {
for (const [key, value] of Object.entries(env as Record<string, unknown>)) {
if (!isSensitiveKey(key) || typeof value !== 'string') continue;
(env as Record<string, unknown>)[key] = REDACTED_TOKEN_SENTINEL;
redacted = true;
}
}
}
const bundle: ApiProfileExportBundle = {
schemaVersion: 1,
exportedAt: new Date().toISOString(),
profile: {
name,
target: getProfileTarget(name),
},
settings,
};
return { success: true, bundle, redacted };
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
export function importApiProfileBundle(
bundle: unknown,
options?: { name?: string; target?: TargetType; force?: boolean }
): ImportApiProfileResult {
if (typeof bundle !== 'object' || bundle === null || Array.isArray(bundle)) {
return { success: false, error: 'Import bundle must be a JSON object.' };
}
const input = bundle as Partial<ApiProfileExportBundle>;
if (input.schemaVersion !== 1 || !input.profile || !input.settings) {
return {
success: false,
error: 'Invalid bundle schema. Expected schemaVersion=1 with profile and settings.',
};
}
const name = options?.name || input.profile.name;
const nameError = validateApiName(name);
if (nameError) return { success: false, error: nameError };
const bundleTarget = parseTargetValue(input.profile.target);
if (input.profile.target !== undefined && bundleTarget === null) {
return {
success: false,
error: `Invalid bundle profile target. Expected: ${getPersistedTargetChoices()}.`,
};
}
const settings = JSON.parse(JSON.stringify(input.settings)) as Record<string, unknown>;
const env = settings.env as Record<string, unknown> | undefined;
const warnings: string[] = [];
if (env) {
const redactedKeys = Object.entries(env)
.filter(([key, value]) => isSensitiveKey(key) && value === REDACTED_TOKEN_SENTINEL)
.map(([key]) => key);
if (redactedKeys.length > 0) {
for (const key of redactedKeys) {
env[key] = '';
}
warnings.push(
`Imported bundle had redacted values for ${redactedKeys.join(', ')}. Set secrets before use.`
);
}
}
const validation = validateApiProfileSettingsPayload(settings);
if (!validation.valid && !options?.force) {
return { success: false, error: 'Import validation failed.', validation };
}
const settingsPath = getProfileSettingsPath(name);
try {
const settingsExisted = fs.existsSync(settingsPath);
const previousSettingsContent = settingsExisted ? fs.readFileSync(settingsPath, 'utf8') : null;
writeJsonObjectAtomically(settingsPath, settings);
try {
ensureWebSearchMcpOrThrow();
ensureImageAnalysisMcpOrThrow();
} catch (hookError) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw hookError;
}
try {
registerApiProfileInConfig(name, options?.target || bundleTarget || 'claude', options?.force);
} catch (registrationError) {
rollbackSettingsFile(settingsPath, previousSettingsContent, settingsExisted);
throw registrationError;
}
warnings.push(
...validation.issues
.filter((issue) => issue.level === 'warning')
.map((issue) => issue.message)
);
return { success: true, name, warnings, validation };
} catch (error) {
return { success: false, error: (error as Error).message, validation };
}
}