import * as plugins from "./mod.plugins.js"; import { Project } from "../classes.project.js"; import { FormatContext } from "./classes.formatcontext.js"; import { FormatPlanner } from "./classes.formatplanner.js"; import { BaseFormatter } from "./classes.baseformatter.js"; import { logger, setVerboseMode } from "../gitzone.logging.js"; import type { ICliMode } from "../helpers.climode.js"; import { getCliMode, printJson, runWithSuppressedOutput, } from "../helpers.climode.js"; import { getCliConfigValue } from "../helpers.smartconfig.js"; import { CleanupFormatter } from "./formatters/cleanup.formatter.js"; import { SmartconfigFormatter } from "./formatters/smartconfig.formatter.js"; import { LicenseFormatter } from "./formatters/license.formatter.js"; import { PackageJsonFormatter } from "./formatters/packagejson.formatter.js"; import { TemplatesFormatter } from "./formatters/templates.formatter.js"; import { GitignoreFormatter } from "./formatters/gitignore.formatter.js"; import { TsconfigFormatter } from "./formatters/tsconfig.formatter.js"; import { PrettierFormatter } from "./formatters/prettier.formatter.js"; import { ReadmeFormatter } from "./formatters/readme.formatter.js"; import { CopyFormatter } from "./formatters/copy.formatter.js"; /** * Rename npmextra.json or smartconfig.json to .smartconfig.json * before any formatter tries to read config. */ async function migrateConfigFile(allowWrite: boolean): Promise { const target = ".smartconfig.json"; const targetExists = await plugins.smartfs.file(target).exists(); if (targetExists) return; for (const oldName of ["smartconfig.json", "npmextra.json"]) { const exists = await plugins.smartfs.file(oldName).exists(); if (exists) { if (!allowWrite) { return; } const content = (await plugins.smartfs .file(oldName) .encoding("utf8") .read()) as string; await plugins.smartfs.file(`./${target}`).encoding("utf8").write(content); await plugins.smartfs.file(oldName).delete(); logger.log("info", `Migrated ${oldName} to ${target}`); return; } } } // Shared formatter class map used by both run() and runFormatter() const formatterMap: Record< string, new (ctx: FormatContext, proj: Project) => BaseFormatter > = { cleanup: CleanupFormatter, smartconfig: SmartconfigFormatter, license: LicenseFormatter, packagejson: PackageJsonFormatter, templates: TemplatesFormatter, gitignore: GitignoreFormatter, tsconfig: TsconfigFormatter, prettier: PrettierFormatter, readme: ReadmeFormatter, copy: CopyFormatter, }; // Formatters that don't require projectType to be set const formattersNotRequiringProjectType = [ "smartconfig", "prettier", "cleanup", "packagejson", ]; const getFormatConfig = async () => { const rawFormatConfig = await getCliConfigValue>( "format", {}, ); return { interactive: true, showDiffs: false, autoApprove: false, showStats: true, modules: { skip: [], only: [], ...(rawFormatConfig.modules || {}), }, ...rawFormatConfig, }; }; const createActiveFormatters = async (options: { interactive: boolean; jsonOutput: boolean; }) => { const project = await Project.fromCwd({ requireProjectType: false }); const context = new FormatContext(options); const planner = new FormatPlanner(); const formatConfig = await getFormatConfig(); const formatters = Object.entries(formatterMap).map( ([, FormatterClass]) => new FormatterClass(context, project), ); const activeFormatters = formatters.filter((formatter) => { if (formatConfig.modules.only.length > 0) { return formatConfig.modules.only.includes(formatter.name); } if (formatConfig.modules.skip.includes(formatter.name)) { return false; } return true; }); return { context, planner, formatConfig, activeFormatters, }; }; const buildFormatPlan = async (options: { fromPlan?: string; interactive: boolean; jsonOutput: boolean; }) => { const { context, planner, formatConfig, activeFormatters } = await createActiveFormatters({ interactive: options.interactive, jsonOutput: options.jsonOutput, }); const plan = options.fromPlan ? JSON.parse( (await plugins.smartfs .file(options.fromPlan) .encoding("utf8") .read()) as string, ) : await planner.planFormat(activeFormatters); return { context, planner, formatConfig, activeFormatters, plan, }; }; const serializePlan = (plan: any) => { return { summary: plan.summary, warnings: plan.warnings, changes: plan.changes.map((change: any) => ({ type: change.type, path: change.path, module: change.module, description: change.description, })), }; }; export let run = async ( options: { write?: boolean; dryRun?: boolean; // Deprecated, kept for compatibility yes?: boolean; planOnly?: boolean; savePlan?: string; fromPlan?: string; detailed?: boolean; interactive?: boolean; verbose?: boolean; diff?: boolean; [key: string]: any; } = {}, ): Promise => { const mode = await getCliMode(options as any); const subcommand = (options as any)?._?.[1]; if (mode.help || subcommand === "help") { showHelp(mode); return; } if (options.verbose) { setVerboseMode(true); } const shouldWrite = options.write ?? options.dryRun === false; const treatAsPlan = subcommand === "plan"; if (mode.json && shouldWrite) { printJson({ ok: false, error: "JSON output is only supported for read-only format planning. Use `gitzone format plan --json` or omit `--json` when applying changes.", }); return; } // Migrate config file before anything reads it await migrateConfigFile(shouldWrite); const formatConfig = await getFormatConfig(); const interactive = options.interactive ?? (mode.interactive && formatConfig.interactive); const autoApprove = options.yes ?? formatConfig.autoApprove; try { const planBuilder = async () => { return await buildFormatPlan({ fromPlan: options.fromPlan, interactive, jsonOutput: mode.json, }); }; if (!mode.json) { logger.log("info", "Analyzing project for format operations..."); } const { context, planner, activeFormatters, plan } = mode.json ? await runWithSuppressedOutput(planBuilder) : await planBuilder(); if (mode.json) { printJson(serializePlan(plan)); return; } // Display plan await planner.displayPlan(plan, options.detailed); // Save plan if requested if (options.savePlan) { await plugins.smartfs .file(options.savePlan) .encoding("utf8") .write(JSON.stringify(plan, null, 2)); logger.log("info", `Plan saved to ${options.savePlan}`); } if (options.planOnly || treatAsPlan) { return; } // Show diffs if explicitly requested or before interactive write confirmation const showDiffs = options.diff || (shouldWrite && interactive && !autoApprove); if (showDiffs) { logger.log("info", "Showing file diffs:"); console.log(""); for (const formatter of activeFormatters) { const checkResult = await formatter.check(); if (checkResult.hasDiff) { logger.log("info", `[${formatter.name}]`); formatter.displayAllDiffs(checkResult); console.log(""); } } } // Dry-run mode (default behavior) if (!shouldWrite) { logger.log("info", "Dry-run mode - use --write (-w) to apply changes"); return; } // Interactive confirmation if (interactive && !autoApprove) { const interactInstance = new plugins.smartinteract.SmartInteract(); const response = await interactInstance.askQuestion({ type: "confirm", name: "proceed", message: "Proceed with formatting?", default: true, }); if (!(response as any).value) { logger.log("info", "Format operation cancelled by user"); return; } } // Execute phase logger.log("info", "Executing format operations..."); await planner.executePlan(plan, activeFormatters, context); context.getFormatStats().finish(); const showStats = formatConfig.showStats ?? true; if (showStats) { context.getFormatStats().displayStats(); } if (options.detailed) { const statsPath = `.nogit/format-stats-${Date.now()}.json`; await context.getFormatStats().saveReport(statsPath); } logger.log("success", "Format operations completed successfully!"); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.log("error", `Format operation failed: ${errorMessage}`); throw error; } }; import type { ICheckResult } from "./interfaces.format.js"; export type { ICheckResult }; /** * Run a single formatter by name (for use by other modules) */ export const runFormatter = async ( formatterName: string, options: { silent?: boolean; checkOnly?: boolean; showDiff?: boolean; } = {}, ): Promise => { const requireProjectType = !formattersNotRequiringProjectType.includes(formatterName); const project = await Project.fromCwd({ requireProjectType }); const context = new FormatContext({ interactive: true, jsonOutput: false }); const FormatterClass = formatterMap[formatterName]; if (!FormatterClass) { throw new Error(`Unknown formatter: ${formatterName}`); } const formatter = new FormatterClass(context, project); if (options.checkOnly) { const result = await formatter.check(); if (result.hasDiff && options.showDiff) { formatter.displayAllDiffs(result); } return result; } const changes = await formatter.analyze(); for (const change of changes) { await formatter.applyChange(change); } if (!options.silent) { logger.log("success", `Formatter '${formatterName}' completed`); } }; export function showHelp(mode?: ICliMode): void { if (mode?.json) { printJson({ command: "format", usage: "gitzone format [plan] [options]", description: "Plans formatting changes by default and applies them only with --write.", flags: [ { flag: "--write, -w", description: "Apply planned changes" }, { flag: "--yes", description: "Skip the interactive confirmation before writing", }, { flag: "--plan-only", description: "Show the plan without applying changes", }, { flag: "--save-plan ", description: "Write the format plan to a file", }, { flag: "--from-plan ", description: "Load a previously saved plan", }, { flag: "--detailed", description: "Show detailed diffs and save stats", }, { flag: "--verbose", description: "Enable verbose logging" }, { flag: "--diff", description: "Show per-file diffs before applying changes", }, { flag: "--json", description: "Emit a read-only format plan as JSON" }, ], examples: [ "gitzone format", "gitzone format plan --json", "gitzone format --write --yes", ], }); return; } console.log(""); console.log("Usage: gitzone format [plan] [options]"); console.log(""); console.log( "Plans formatting changes by default and applies them only with --write.", ); console.log(""); console.log("Flags:"); console.log(" --write, -w Apply planned changes"); console.log( " --yes Skip the interactive confirmation before writing", ); console.log(" --plan-only Show the plan without applying changes"); console.log(" --save-plan Write the format plan to a file"); console.log(" --from-plan Load a previously saved plan"); console.log(" --detailed Show detailed diffs and save stats"); console.log(" --verbose Enable verbose logging"); console.log( " --diff Show per-file diffs before applying changes", ); console.log(" --json Emit a read-only format plan as JSON"); console.log(""); console.log("Examples:"); console.log(" gitzone format"); console.log(" gitzone format plan --json"); console.log(" gitzone format --write --yes"); console.log(""); }