Files
cli/ts/mod_format/index.ts
T

435 lines
12 KiB
TypeScript

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<void> {
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<Record<string, any>>(
"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<any> => {
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<ICheckResult | void> => {
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 <file>",
description: "Write the format plan to a file",
},
{
flag: "--from-plan <file>",
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 <file> Write the format plan to a file");
console.log(" --from-plan <file> 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("");
}