import fs from 'node:fs/promises'; import path from 'node:path'; import { inferSourceChangeScreenTitle } from '../src/services/chat-room-service.js'; const APPLY_FLAG = '--apply'; const repoRootPath = path.resolve(process.cwd(), '../../..'); const codexLiveRootPath = path.join(repoRootPath, 'resource', 'Codex Live'); const genericScreenRootPath = path.join(codexLiveRootPath, 'Codex Live'); type FeaturePlan = { featureName: string; sourcePath: string; targetLabel: string; targetPath: string; filePaths: string[]; }; function normalizeWhitespace(value: string) { return String(value ?? '').replace(/\s+/g, ' ').trim(); } function getScreenLabelFromTitle(title: string) { const segments = String(title ?? '') .split('/') .map((segment) => segment.trim()) .filter(Boolean); return segments.at(-1) ?? ''; } function extractSourcePathsFromSpec(text: string) { return Array.from( new Set( Array.from(text.matchAll(/`((?:src|etc|docs|public|scripts)\/[^`]+)`/g), (match) => normalizeWhitespace(match[1])), ), ).filter(Boolean); } function containsPattern(values: string[], pattern: RegExp) { return values.some((value) => pattern.test(normalizeWhitespace(value))); } function inferScreenLabelFromFeatureMetadata(args: { featureName: string; filePaths: string[]; specTexts: string[]; }) { const featureName = normalizeWhitespace(args.featureName); const filePaths = args.filePaths.map((value) => normalizeWhitespace(value)); const specTexts = args.specTexts.map((value) => normalizeWhitespace(value)); if ( containsPattern(filePaths, /(?:resourceManagerApi|resource-manager-service|resource-manager|ResourceManagementPage)/iu) || containsPattern([featureName], /(?:resource manager|리소스 관리|리소스 경로|리소스 가이드|이미지 생성 CLI)/iu) ) { return '리소스 관리'; } if ( containsPattern(filePaths, /(?:ChatSourceChangesPage|chat-room-service)/iu) || containsPattern([featureName], /(?:변경 이력|source change|source-changes)/iu) ) { return '변경 이력'; } if ( containsPattern(filePaths, /(?:PreviewAppOverlay|PreviewAppWindow|previewRuntime|appUpdate)/iu) || containsPattern([featureName], /(?:모바일 앱 열기|Preview App)/iu) ) { return '모바일 앱 열기'; } if ( containsPattern(filePaths, /(?:MainHeader|HeaderMessageCenter|MainLayout\.css)/iu) || containsPattern([featureName], /(?:헤더)/iu) ) { return '헤더 표시'; } if ( containsPattern( filePaths, /(?:MainChatPanel|ChatConversationView|mainChatPanel|ChatActivityChecklist|chatUtils)/iu, ) || containsPattern( [featureName], /(?:채팅 말풍선|시스템 카드|말풍선|prompt|즉시전송|즉시 접수|답변 이동|활동 로그|첨부 파일|채팅방|MainChatPanel|ChatConversationView|mainChatPanel)/iu, ) ) { return '채팅 말풍선'; } return null; } function inferScreenLabelFromSpec(args: { featureName: string; filePaths: string[]; specTexts: string[]; }) { const inferredTitle = inferSourceChangeScreenTitle(args.filePaths, 'Codex Live / Codex Live'); const inferredLabel = getScreenLabelFromTitle(inferredTitle); if (inferredLabel && inferredLabel !== 'Codex Live' && inferredLabel !== '새 대화') { return inferredLabel; } const metadataLabel = inferScreenLabelFromFeatureMetadata(args); if (metadataLabel) { return metadataLabel; } const normalizedFeatureName = normalizeWhitespace(args.featureName); const hasHeaderSpecificFile = args.filePaths.some((filePath) => /^(?:src\/app\/main\/MainHeader\.(?:ts|tsx)|src\/app\/main\/HeaderMessageCenter\.(?:ts|tsx|css))$/u.test(filePath), ); const hasOnlyHeaderLayoutFiles = args.filePaths.length > 0 && args.filePaths.every((filePath) => /^(?:src\/app\/main\/MainLayout\.css|src\/app\/main\/HeaderMessageCenter\.css)$/u.test(filePath), ); const hasPreviewSpecificFile = args.filePaths.some((filePath) => /^(?:src\/app\/main\/PreviewAppOverlay\.(?:ts|tsx)|src\/app\/main\/PreviewAppWindow\.(?:ts|tsx)|src\/app\/main\/previewRuntime\.(?:ts|tsx|js)|src\/app\/main\/appUpdate\.(?:ts|tsx|js))$/u.test( filePath, ), ); if (/^(?:preview\b|동영상 preview\b)/iu.test(normalizedFeatureName) || hasPreviewSpecificFile) { return '모바일 앱 열기'; } if ( /(?:헤더|테마|앱 설정|알림 뱃지|헤더 표시)/u.test(normalizedFeatureName) && (hasHeaderSpecificFile || hasOnlyHeaderLayoutFiles || args.filePaths.length === 0) ) { return '헤더 표시'; } return 'Codex Live'; } async function exists(targetPath: string) { try { await fs.access(targetPath); return true; } catch { return false; } } async function readFeaturePlans() { if (!(await exists(genericScreenRootPath))) { return [] as FeaturePlan[]; } const featureEntries = await fs.readdir(genericScreenRootPath, { withFileTypes: true }); const plans: FeaturePlan[] = []; for (const featureEntry of featureEntries) { if (!featureEntry.isDirectory()) { continue; } const featureName = featureEntry.name; const featurePath = path.join(genericScreenRootPath, featureName); const datedEntries = (await fs.readdir(featurePath, { withFileTypes: true })) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort(); const specTexts: string[] = []; const filePaths = new Set(); for (const datedEntry of datedEntries) { const specPath = path.join(featurePath, datedEntry, 'docs', 'feature-spec.md'); if (!(await exists(specPath))) { continue; } const specText = await fs.readFile(specPath, 'utf8'); specTexts.push(specText); extractSourcePathsFromSpec(specText).forEach((filePath) => { filePaths.add(filePath); }); } const targetLabel = inferScreenLabelFromSpec({ featureName, filePaths: Array.from(filePaths), specTexts, }); plans.push({ featureName, sourcePath: featurePath, targetLabel, targetPath: path.join(codexLiveRootPath, targetLabel, featureName), filePaths: Array.from(filePaths), }); } return plans.sort((left, right) => left.featureName.localeCompare(right.featureName, 'ko')); } async function moveDirectoryContents(sourcePath: string, targetPath: string): Promise { await fs.mkdir(path.dirname(targetPath), { recursive: true }); if (!(await exists(targetPath))) { await fs.rename(sourcePath, targetPath); return; } const sourceEntries = await fs.readdir(sourcePath, { withFileTypes: true }); for (const sourceEntry of sourceEntries) { const nextSourcePath = path.join(sourcePath, sourceEntry.name); const nextTargetPath = path.join(targetPath, sourceEntry.name); if (sourceEntry.isDirectory()) { await moveDirectoryContents(nextSourcePath, nextTargetPath); continue; } if (await exists(nextTargetPath)) { throw new Error(`대상 파일이 이미 존재합니다: ${path.relative(repoRootPath, nextTargetPath)}`); } await fs.mkdir(path.dirname(nextTargetPath), { recursive: true }); await fs.rename(nextSourcePath, nextTargetPath); } await fs.rm(sourcePath, { recursive: false }); } async function applyMoves(plans: FeaturePlan[]) { const applied: Array<{ featureName: string; from: string; to: string }> = []; for (const plan of plans) { if (plan.targetLabel === 'Codex Live') { continue; } await moveDirectoryContents(plan.sourcePath, plan.targetPath); applied.push({ featureName: plan.featureName, from: path.relative(repoRootPath, plan.sourcePath), to: path.relative(repoRootPath, plan.targetPath), }); } return applied; } try { const plans = await readFeaturePlans(); const movablePlans = plans.filter((plan) => plan.targetLabel !== 'Codex Live'); const summary = { mode: process.argv.includes(APPLY_FLAG) ? 'apply' : 'dry-run', totalFeatureCount: plans.length, movableFeatureCount: movablePlans.length, groupedTargets: movablePlans.reduce>((accumulator, plan) => { accumulator[plan.targetLabel] = (accumulator[plan.targetLabel] ?? 0) + 1; return accumulator; }, {}), moves: movablePlans.map((plan) => ({ featureName: plan.featureName, from: path.relative(repoRootPath, plan.sourcePath), to: path.relative(repoRootPath, plan.targetPath), filePaths: plan.filePaths, })), }; if (!process.argv.includes(APPLY_FLAG)) { console.log(JSON.stringify(summary, null, 2)); process.exit(0); } const applied = await applyMoves(plans); console.log(JSON.stringify({ ...summary, appliedCount: applied.length, applied, }, null, 2)); } catch (error) { console.error(error); process.exitCode = 1; }