/** * S9: Migrate thread - Config migration command * * Responsibilities: * - Detect legacy config patterns and deprecated API usage * - Support --check (detect only, don't write) * - Support --dry-run (show rewrites without writing) - DEFAULT * - Support --write (perform rewrites) * - Map legacy fields to new fields with exact replacements * - Preserve comments/formatting where feasible * - Handle ambiguous rewrites (stop, require manual choice) * - Report completed and remaining items separately * - Exit 0 if nothing to migrate, 2 if issues found, 1 if --write performed * - Mixed legacy/modern config detection with clear reporting * - Exact dry-run output with file path, line number, legacy text, replacement text * - Ambiguous rewrite handling with surrounding context and possible resolutions * - Safe by default: dry-run is the default mode * * Architecture: * - Dependency injection: all dependencies passed explicitly * - No optional imports — everything is required or injected * - Inline comments for documentation */ import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { resolve } from 'node:path'; import type { CliContext } from '../../core/context.js'; import { discoverConfig } from '../../core/config-loader.js'; import { SUCCESS, USAGE_ERROR, BEHAVIORAL_FAILURE } from '../../core/exit-codes.js'; import { rewriteRouteAnnotations, detectLegacyRouteAnnotations, detectAmbiguousRoutePatterns, } from './rewriters/route-rewriter.js'; import { rewriteCodePatterns, detectLegacyCodePatterns, detectAmbiguousCodePatterns, } from './rewriters/code-rewriter.js'; import { renderJson } from '../../renderers/json.js'; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface MigrateOptions { check?: boolean; dryRun?: boolean; write?: boolean; config?: string; cwd?: string; format?: 'human' | 'json' | 'ndjson'; quiet?: boolean; verbose?: boolean; } export interface MigrationItem { type: 'config-field' | 'route-annotation' | 'code-pattern'; file: string; line?: number; legacy: string; replacement: string; guidance?: string; ambiguous?: boolean; } export interface MigrationResult { exitCode: number; items: MigrationItem[]; completed: MigrationItem[]; remaining: MigrationItem[]; message?: string; filesModified?: string[]; filesWouldBeModified?: string[]; totalRewrites?: number; manualChoicesRequired?: number; } // --------------------------------------------------------------------------- // File discovery // --------------------------------------------------------------------------- /** * Discover files that may contain legacy patterns. * Scans the working directory for config files, route files, and code files. */ export async function discoverMigrationFiles( cwd: string, configPath?: string, ): Promise<{ configFile: string | null; appFiles: string[] }> { const configFile = configPath ? resolve(cwd, configPath) : discoverConfig(cwd); const appFiles: string[] = []; const candidates = [ 'app.js', 'app.ts', 'src/app.js', 'src/app.ts', 'routes.js', 'routes.ts', 'src/routes.js', 'src/routes.ts', ]; for (const candidate of candidates) { const fullPath = resolve(cwd, candidate); if (existsSync(fullPath)) { appFiles.push(fullPath); } } return { configFile, appFiles }; } // --------------------------------------------------------------------------- // Pattern detection // --------------------------------------------------------------------------- /** * Detect all legacy patterns in a set of files. * Includes: legacy fields, route annotations, code patterns, * ambiguous patterns, fields with no equivalent, and mixed legacy/modern fields. */ export async function detectAllLegacyPatterns( configFile: string | null, appFiles: string[], ): Promise { const items: MigrationItem[] = []; if (configFile && existsSync(configFile)) { const configContent = readFileSync(configFile, 'utf-8'); items.push(...detectLegacyRouteAnnotations(configContent, configFile)); items.push(...detectAmbiguousRoutePatterns(configContent, configFile)); items.push(...detectLegacyCodePatterns(configContent, configFile)); items.push(...detectAmbiguousCodePatterns(configContent, configFile)); } // Detect patterns in app files for (const appFile of appFiles) { const content = readFileSync(appFile, 'utf-8'); items.push(...detectLegacyRouteAnnotations(content, appFile)); items.push(...detectAmbiguousRoutePatterns(content, appFile)); items.push(...detectLegacyCodePatterns(content, appFile)); items.push(...detectAmbiguousCodePatterns(content, appFile)); } return items; } // --------------------------------------------------------------------------- // Migration execution // --------------------------------------------------------------------------- /** * Run the migration process. * * Flow: * 1. Discover config files in the working directory * 2. Detect legacy patterns in all relevant files * 3. If --check, report findings and exit * 4. If --dry-run (default), show exact rewrites without writing * 5. If --write, perform rewrites * 6. Report completed and remaining items separately * 7. Return appropriate exit code * * Safety: dry-run is the default mode. No files are modified unless --write is explicitly passed. */ export async function migrateCommand( options: MigrateOptions, ctx: CliContext, ): Promise { const { check, dryRun, write, config: configPath, cwd } = options; const workingDir = cwd || ctx.cwd; // Determine mode: check < dry-run < write // Default is dry-run (safe by default) const mode = write ? 'write' : check ? 'check' : 'dry-run'; try { // 1. Discover files const { configFile, appFiles } = await discoverMigrationFiles( workingDir, configPath, ); if (!configFile && appFiles.length === 0) { return { exitCode: USAGE_ERROR, items: [], completed: [], remaining: [], message: 'No config or app files found. Run "apophis init" to create one.', }; } // 2. Detect legacy patterns const allItems = await detectAllLegacyPatterns(configFile, appFiles); // 3. If no legacy patterns found, report success if (allItems.length === 0) { return { exitCode: SUCCESS, items: [], completed: [], remaining: [], message: 'No legacy patterns detected. Config is up to date.', }; } // Separate ambiguous items const ambiguousItems = allItems.filter((item) => item.ambiguous); const unambiguousItems = allItems.filter((item) => !item.ambiguous); // Calculate files that would be modified const filesWouldBeModified = new Set(); for (const item of allItems) { filesWouldBeModified.add(item.file); } // If ambiguous items exist and we're writing, stop and require manual choice if (ambiguousItems.length > 0 && mode === 'write') { return { exitCode: USAGE_ERROR, items: allItems, completed: [], remaining: ambiguousItems, message: formatAmbiguousOutput(ambiguousItems), filesWouldBeModified: Array.from(filesWouldBeModified), totalRewrites: allItems.length, manualChoicesRequired: ambiguousItems.length, }; } // 4. Check mode: detect only if (mode === 'check') { return { exitCode: BEHAVIORAL_FAILURE, items: allItems, completed: [], remaining: allItems, message: formatCheckOutput(allItems), filesWouldBeModified: Array.from(filesWouldBeModified), totalRewrites: allItems.length, manualChoicesRequired: ambiguousItems.length, }; } // 5. Dry-run mode: show exact rewrites without writing if (mode === 'dry-run') { return { exitCode: BEHAVIORAL_FAILURE, items: allItems, completed: [], remaining: allItems, message: formatDryRunOutput(allItems), filesWouldBeModified: Array.from(filesWouldBeModified), totalRewrites: allItems.length, manualChoicesRequired: ambiguousItems.length, }; } // 6. Write mode: perform rewrites const filesModified: string[] = []; const completed: MigrationItem[] = []; const remaining: MigrationItem[] = []; // Route annotations in config file if (configFile && existsSync(configFile)) { const routeItems = unambiguousItems.filter( (item) => item.file === configFile && item.type === 'route-annotation', ); if (routeItems.length > 0) { const result = rewriteRouteAnnotations(configFile, routeItems); if (result.modified) { writeFileSync(configFile, result.content, 'utf-8'); if (!filesModified.includes(configFile)) { filesModified.push(configFile); } completed.push(...result.itemsRewritten); remaining.push(...result.itemsRemaining); } else { remaining.push(...routeItems); } } // Code patterns in config file const codeItems = unambiguousItems.filter( (item) => item.file === configFile && item.type === 'code-pattern', ); if (codeItems.length > 0) { const result = rewriteCodePatterns(configFile, codeItems); if (result.modified) { writeFileSync(configFile, result.content, 'utf-8'); if (!filesModified.includes(configFile)) { filesModified.push(configFile); } completed.push(...result.itemsRewritten); remaining.push(...result.itemsRemaining); } else { remaining.push(...codeItems); } } } // Rewrite app files for (const appFile of appFiles) { const fileItems = unambiguousItems.filter((item) => item.file === appFile); const routeItems = fileItems.filter( (item) => item.type === 'route-annotation', ); const codeItems = fileItems.filter( (item) => item.type === 'code-pattern', ); let fileModified = false; let currentContent = readFileSync(appFile, 'utf-8'); if (routeItems.length > 0) { const result = rewriteRouteAnnotations(appFile, routeItems); if (result.modified) { currentContent = result.content; fileModified = true; completed.push(...result.itemsRewritten); remaining.push(...result.itemsRemaining); } else { remaining.push(...routeItems); } } if (codeItems.length > 0) { const result = rewriteCodePatterns(appFile, codeItems); if (result.modified) { currentContent = result.content; fileModified = true; completed.push(...result.itemsRewritten); remaining.push(...result.itemsRemaining); } else { remaining.push(...codeItems); } } if (fileModified) { writeFileSync(appFile, currentContent, 'utf-8'); filesModified.push(appFile); } } // Ambiguous items always remain remaining.push(...ambiguousItems); return { exitCode: completed.length > 0 ? BEHAVIORAL_FAILURE : SUCCESS, items: allItems, completed, remaining, message: formatWriteOutput(completed, remaining), filesModified, filesWouldBeModified: Array.from(filesWouldBeModified), totalRewrites: allItems.length, manualChoicesRequired: ambiguousItems.length, }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { exitCode: USAGE_ERROR, items: [], completed: [], remaining: [], message: `Migration failed: ${message}`, }; } } // --------------------------------------------------------------------------- // Output formatting // --------------------------------------------------------------------------- function formatCheckOutput(items: MigrationItem[]): string { const lines: string[] = []; lines.push('Legacy config patterns detected:'); lines.push(''); for (const item of items) { const location = item.line ? `${item.file}:${item.line}` : item.file; lines.push(` ${location}`); lines.push(` Legacy: ${item.legacy}`); lines.push(` Replace: ${item.replacement}`); if (item.guidance) { lines.push(` Guidance: ${item.guidance}`); } if (item.ambiguous) { lines.push(` ⚠ Ambiguous — requires manual choice`); } lines.push(''); } lines.push(`Found ${items.length} item(s) to migrate.`); lines.push(''); lines.push('Run "apophis migrate --dry-run" to preview rewrites.'); lines.push('Run "apophis migrate --write" to apply rewrites.'); return lines.join('\n'); } function formatDryRunOutput(items: MigrationItem[]): string { const lines: string[] = []; const files = new Set(); const ambiguousCount = items.filter((item) => item.ambiguous).length; lines.push('Dry run — the following rewrites would be applied:'); lines.push(''); for (const item of items) { const location = item.line ? `${item.file}:${item.line}` : item.file; files.add(item.file); lines.push(` ${location}`); lines.push(` - ${item.legacy}`); lines.push(` + ${item.replacement}`); if (item.guidance) { lines.push(` # ${item.guidance}`); } if (item.ambiguous) { lines.push(` ⚠ Skipped (ambiguous — requires manual choice)`); } lines.push(''); } lines.push(`Total: ${items.length} item(s) to migrate.`); lines.push(`Files that would be modified: ${files.size}`); if (ambiguousCount > 0) { lines.push(`Items requiring manual choice: ${ambiguousCount}`); } lines.push(''); lines.push('Run "apophis migrate --write" to apply these rewrites.'); return lines.join('\n'); } function formatWriteOutput( completed: MigrationItem[], remaining: MigrationItem[], ): string { const lines: string[] = []; lines.push('Migration complete:'); lines.push(''); if (completed.length > 0) { lines.push(` Completed (${completed.length}):`); for (const item of completed) { const location = item.line ? `${item.file}:${item.line}` : item.file; lines.push( ` ✓ ${location} — ${item.legacy} → ${item.replacement}`, ); } lines.push(''); } if (remaining.length > 0) { lines.push(` Remaining (${remaining.length}):`); for (const item of remaining) { const location = item.line ? `${item.file}:${item.line}` : item.file; lines.push(` - ${location} — ${item.legacy}`); if (item.ambiguous) { lines.push(` ⚠ Ambiguous — requires manual choice`); } else if (item.guidance) { lines.push(` # ${item.guidance}`); } } lines.push(''); } if (remaining.length === 0) { lines.push('All items migrated successfully.'); } else { lines.push(`Run "apophis migrate --check" to review remaining items.`); } return lines.join('\n'); } function formatAmbiguousOutput(items: MigrationItem[]): string { const lines: string[] = []; lines.push('Ambiguous rewrites detected — migration stopped:'); lines.push(''); for (const item of items) { const location = item.line ? `${item.file}:${item.line}` : item.file; lines.push(` ${location}`); lines.push(` ${item.legacy}`); lines.push(` ⚠ This pattern is ambiguous and requires manual choice.`); if (item.guidance) { lines.push(` Consider: ${item.guidance}`); } lines.push(''); } lines.push('Please resolve these items manually, then re-run migrate.'); return lines.join('\n'); } // --------------------------------------------------------------------------- // CLI adapter // --------------------------------------------------------------------------- /** * Adapter that bridges the CLI framework (cac) to the migrate command handler. * This function signature matches what the CLI core expects. * * Safety: dry-run is the default mode. No files are modified unless --write is explicitly passed. */ export async function handleMigrate( _args: string[], ctx: CliContext, ): Promise { const options: MigrateOptions = { config: ctx.options.config || undefined, cwd: ctx.cwd, format: ctx.options.format as MigrateOptions['format'], quiet: ctx.options.quiet, verbose: ctx.options.verbose, }; // Parse command-specific flags from process.argv const argv = process.argv.slice(2); if (argv.includes('--check')) { options.check = true; } if (argv.includes('--dry-run')) { options.dryRun = true; } if (argv.includes('--write')) { options.write = true; } const result = await migrateCommand(options, ctx); // Output result based on format if (!ctx.options.quiet && result.message) { const format = options.format || ctx.options.format || 'human'; if (format === 'json') { console.log(renderJson({ exitCode: result.exitCode, items: result.items, completed: result.completed, remaining: result.remaining, filesModified: result.filesModified, filesWouldBeModified: result.filesWouldBeModified, totalRewrites: result.totalRewrites, manualChoicesRequired: result.manualChoicesRequired, })); } else if (format === 'ndjson') { process.stdout.write(JSON.stringify({ type: 'run.completed', command: 'migrate', exitCode: result.exitCode, items: result.items, completed: result.completed, remaining: result.remaining, filesModified: result.filesModified, filesWouldBeModified: result.filesWouldBeModified, totalRewrites: result.totalRewrites, manualChoicesRequired: result.manualChoicesRequired, }) + '\n'); } else { console.log(result.message); } } return result.exitCode; }