globalConfig.ts と projectConfig.ts に重複していた normalizeProviderProfiles / denormalizeProviderProfiles / normalizePieceOverrides / denormalizePieceOverrides を configNormalizers.ts に集約した。
269 lines
9.1 KiB
TypeScript
269 lines
9.1 KiB
TypeScript
/**
|
|
* Project-level configuration management
|
|
*
|
|
* Manages .takt/config.yaml for project-specific settings.
|
|
*/
|
|
|
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
import { join, resolve } from 'node:path';
|
|
import { parse, stringify } from 'yaml';
|
|
import { ProjectConfigSchema } from '../../../core/models/index.js';
|
|
import { copyProjectResourcesToDir } from '../../resources/index.js';
|
|
import type { ProjectLocalConfig } from '../types.js';
|
|
import type { AnalyticsConfig, SubmoduleSelection } from '../../../core/models/persisted-global-config.js';
|
|
import { applyProjectConfigEnvOverrides } from '../env/config-env-overrides.js';
|
|
import {
|
|
normalizeConfigProviderReference,
|
|
type ConfigProviderReference,
|
|
} from '../providerReference.js';
|
|
import {
|
|
normalizeProviderProfiles,
|
|
denormalizeProviderProfiles,
|
|
normalizePieceOverrides,
|
|
denormalizePieceOverrides,
|
|
} from '../configNormalizers.js';
|
|
import { invalidateResolvedConfigCache } from '../resolutionCache.js';
|
|
|
|
export type { ProjectLocalConfig } from '../types.js';
|
|
|
|
/** Default project configuration */
|
|
const DEFAULT_PROJECT_CONFIG: ProjectLocalConfig = {};
|
|
|
|
const SUBMODULES_ALL = 'all';
|
|
type ProviderType = NonNullable<ProjectLocalConfig['provider']>;
|
|
type RawProviderReference = ConfigProviderReference<ProviderType>;
|
|
|
|
function normalizeSubmodules(raw: unknown): SubmoduleSelection | undefined {
|
|
if (raw === undefined) return undefined;
|
|
|
|
if (typeof raw === 'string') {
|
|
const normalized = raw.trim().toLowerCase();
|
|
if (normalized === SUBMODULES_ALL) {
|
|
return SUBMODULES_ALL;
|
|
}
|
|
throw new Error('Invalid submodules: string value must be "all"');
|
|
}
|
|
|
|
if (Array.isArray(raw)) {
|
|
if (raw.length === 0) {
|
|
throw new Error('Invalid submodules: explicit path list must not be empty');
|
|
}
|
|
|
|
const normalizedPaths = raw.map((entry) => {
|
|
if (typeof entry !== 'string') {
|
|
throw new Error('Invalid submodules: path entries must be strings');
|
|
}
|
|
const trimmed = entry.trim();
|
|
if (trimmed.length === 0) {
|
|
throw new Error('Invalid submodules: path entries must not be empty');
|
|
}
|
|
if (trimmed.includes('*')) {
|
|
throw new Error(`Invalid submodules: wildcard is not supported (${trimmed})`);
|
|
}
|
|
return trimmed;
|
|
});
|
|
|
|
return normalizedPaths;
|
|
}
|
|
|
|
throw new Error('Invalid submodules: must be "all" or an explicit path list');
|
|
}
|
|
|
|
function normalizeWithSubmodules(raw: unknown): boolean | undefined {
|
|
if (raw === undefined) return undefined;
|
|
if (typeof raw === 'boolean') return raw;
|
|
throw new Error('Invalid with_submodules: value must be boolean');
|
|
}
|
|
|
|
/**
|
|
* Get project takt config directory (.takt in project)
|
|
* Note: Defined locally to avoid circular dependency with paths.ts
|
|
*/
|
|
function getConfigDir(projectDir: string): string {
|
|
return join(resolve(projectDir), '.takt');
|
|
}
|
|
|
|
/**
|
|
* Get project config file path
|
|
* Note: Defined locally to avoid circular dependency with paths.ts
|
|
*/
|
|
function getConfigPath(projectDir: string): string {
|
|
return join(getConfigDir(projectDir), 'config.yaml');
|
|
}
|
|
|
|
function normalizeAnalytics(raw: Record<string, unknown> | undefined): AnalyticsConfig | undefined {
|
|
if (!raw) return undefined;
|
|
const enabled = typeof raw.enabled === 'boolean' ? raw.enabled : undefined;
|
|
const eventsPath = typeof raw.events_path === 'string'
|
|
? raw.events_path
|
|
: (typeof raw.eventsPath === 'string' ? raw.eventsPath : undefined);
|
|
const retentionDays = typeof raw.retention_days === 'number'
|
|
? raw.retention_days
|
|
: (typeof raw.retentionDays === 'number' ? raw.retentionDays : undefined);
|
|
|
|
if (enabled === undefined && eventsPath === undefined && retentionDays === undefined) {
|
|
return undefined;
|
|
}
|
|
return { enabled, eventsPath, retentionDays };
|
|
}
|
|
|
|
function denormalizeAnalytics(config: AnalyticsConfig | undefined): Record<string, unknown> | undefined {
|
|
if (!config) return undefined;
|
|
const raw: Record<string, unknown> = {};
|
|
if (config.enabled !== undefined) raw.enabled = config.enabled;
|
|
if (config.eventsPath) raw.events_path = config.eventsPath;
|
|
if (config.retentionDays !== undefined) raw.retention_days = config.retentionDays;
|
|
return Object.keys(raw).length > 0 ? raw : undefined;
|
|
}
|
|
|
|
/**
|
|
* Load project configuration from .takt/config.yaml
|
|
*/
|
|
export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
|
const configPath = getConfigPath(projectDir);
|
|
|
|
const rawConfig: Record<string, unknown> = {};
|
|
if (existsSync(configPath)) {
|
|
try {
|
|
const content = readFileSync(configPath, 'utf-8');
|
|
const parsed = (parse(content) as Record<string, unknown> | null) ?? {};
|
|
Object.assign(rawConfig, parsed);
|
|
} catch {
|
|
return { ...DEFAULT_PROJECT_CONFIG };
|
|
}
|
|
}
|
|
|
|
applyProjectConfigEnvOverrides(rawConfig);
|
|
const parsedConfig = ProjectConfigSchema.parse(rawConfig);
|
|
|
|
const {
|
|
provider,
|
|
model,
|
|
auto_pr,
|
|
draft_pr,
|
|
base_branch,
|
|
submodules,
|
|
with_submodules,
|
|
provider_options,
|
|
provider_profiles,
|
|
analytics,
|
|
piece_overrides,
|
|
claude_cli_path,
|
|
codex_cli_path,
|
|
cursor_cli_path,
|
|
copilot_cli_path,
|
|
...rest
|
|
} = parsedConfig;
|
|
const normalizedProvider = normalizeConfigProviderReference(
|
|
provider as RawProviderReference,
|
|
model as string | undefined,
|
|
provider_options as Record<string, unknown> | undefined,
|
|
);
|
|
|
|
const normalizedSubmodules = normalizeSubmodules(submodules);
|
|
const normalizedWithSubmodules = normalizeWithSubmodules(with_submodules);
|
|
const effectiveWithSubmodules = normalizedSubmodules === undefined ? normalizedWithSubmodules : undefined;
|
|
|
|
return {
|
|
...DEFAULT_PROJECT_CONFIG,
|
|
...(rest as ProjectLocalConfig),
|
|
autoPr: auto_pr as boolean | undefined,
|
|
draftPr: draft_pr as boolean | undefined,
|
|
baseBranch: base_branch as string | undefined,
|
|
submodules: normalizedSubmodules,
|
|
withSubmodules: effectiveWithSubmodules,
|
|
analytics: normalizeAnalytics(analytics as Record<string, unknown> | undefined),
|
|
provider: normalizedProvider.provider,
|
|
model: normalizedProvider.model,
|
|
providerOptions: normalizedProvider.providerOptions,
|
|
providerProfiles: normalizeProviderProfiles(provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
|
|
pieceOverrides: normalizePieceOverrides(piece_overrides as { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined),
|
|
claudeCliPath: claude_cli_path as string | undefined,
|
|
codexCliPath: codex_cli_path as string | undefined,
|
|
cursorCliPath: cursor_cli_path as string | undefined,
|
|
copilotCliPath: copilot_cli_path as string | undefined,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Save project configuration to .takt/config.yaml
|
|
*/
|
|
export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig): void {
|
|
const configDir = getConfigDir(projectDir);
|
|
const configPath = getConfigPath(projectDir);
|
|
|
|
if (!existsSync(configDir)) {
|
|
mkdirSync(configDir, { recursive: true });
|
|
}
|
|
|
|
copyProjectResourcesToDir(configDir);
|
|
|
|
const savePayload: Record<string, unknown> = { ...config };
|
|
const normalizedSubmodules = normalizeSubmodules(config.submodules);
|
|
|
|
const rawAnalytics = denormalizeAnalytics(config.analytics);
|
|
if (rawAnalytics) {
|
|
savePayload.analytics = rawAnalytics;
|
|
} else {
|
|
delete savePayload.analytics;
|
|
}
|
|
|
|
const rawProfiles = denormalizeProviderProfiles(config.providerProfiles);
|
|
if (rawProfiles && Object.keys(rawProfiles).length > 0) {
|
|
savePayload.provider_profiles = rawProfiles;
|
|
} else {
|
|
delete savePayload.provider_profiles;
|
|
}
|
|
delete savePayload.providerProfiles;
|
|
delete savePayload.providerOptions;
|
|
|
|
if (config.autoPr !== undefined) savePayload.auto_pr = config.autoPr;
|
|
if (config.draftPr !== undefined) savePayload.draft_pr = config.draftPr;
|
|
if (config.baseBranch !== undefined) savePayload.base_branch = config.baseBranch;
|
|
if (normalizedSubmodules !== undefined) {
|
|
savePayload.submodules = normalizedSubmodules;
|
|
delete savePayload.with_submodules;
|
|
} else {
|
|
delete savePayload.submodules;
|
|
if (config.withSubmodules !== undefined) {
|
|
savePayload.with_submodules = config.withSubmodules;
|
|
} else {
|
|
delete savePayload.with_submodules;
|
|
}
|
|
}
|
|
delete savePayload.autoPr;
|
|
delete savePayload.draftPr;
|
|
delete savePayload.baseBranch;
|
|
delete savePayload.withSubmodules;
|
|
|
|
const rawPieceOverrides = denormalizePieceOverrides(config.pieceOverrides);
|
|
if (rawPieceOverrides) {
|
|
savePayload.piece_overrides = rawPieceOverrides;
|
|
}
|
|
delete savePayload.pieceOverrides;
|
|
|
|
const content = stringify(savePayload, { indent: 2 });
|
|
writeFileSync(configPath, content, 'utf-8');
|
|
invalidateResolvedConfigCache(projectDir);
|
|
}
|
|
|
|
/**
|
|
* Update a single field in project configuration
|
|
*/
|
|
export function updateProjectConfig<K extends keyof ProjectLocalConfig>(
|
|
projectDir: string,
|
|
key: K,
|
|
value: ProjectLocalConfig[K]
|
|
): void {
|
|
const config = loadProjectConfig(projectDir);
|
|
config[key] = value;
|
|
saveProjectConfig(projectDir, config);
|
|
}
|
|
|
|
/**
|
|
* Set current piece in project config
|
|
*/
|
|
export function setCurrentPiece(projectDir: string, piece: string): void {
|
|
updateProjectConfig(projectDir, 'piece', piece);
|
|
}
|