Files
apophis-fastify/src/cli/commands/migrate/index.ts
T

611 lines
19 KiB
TypeScript
Raw Normal View History

/**
* 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 { loadConfig, discoverConfig } from '../../core/config-loader.js';
import { SUCCESS, USAGE_ERROR, BEHAVIORAL_FAILURE } from '../../core/exit-codes.js';
import type { CommandResult } from '../../core/types.js';
import {
rewriteConfigFile,
detectLegacyConfigFields,
detectLegacyFieldsNoEquivalent,
detectMixedLegacyModernFields,
} from './rewriters/config-rewriter.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<MigrationItem[]> {
const items: MigrationItem[] = [];
// Detect config fields
if (configFile && existsSync(configFile)) {
const configContent = readFileSync(configFile, 'utf-8');
items.push(...detectLegacyConfigFields(configContent, configFile));
items.push(...detectLegacyFieldsNoEquivalent(configContent, configFile));
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<MigrationResult> {
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<string>();
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[] = [];
// Rewrite config file
if (configFile && existsSync(configFile)) {
const configItems = unambiguousItems.filter(
(item) => item.file === configFile && item.type === 'config-field',
);
if (configItems.length > 0) {
const result = rewriteConfigFile(configFile, configItems);
if (result.modified) {
writeFileSync(configFile, result.content, 'utf-8');
filesModified.push(configFile);
completed.push(...result.itemsRewritten);
remaining.push(...result.itemsRemaining);
} else {
remaining.push(...configItems);
}
}
// Route annotations in config file
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<string>();
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<number> {
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;
}