435 lines
12 KiB
TypeScript
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("");
|
|
}
|