611 lines
19 KiB
TypeScript
611 lines
19 KiB
TypeScript
|
|
/**
|
||
|
|
* 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;
|
||
|
|
}
|