import chalk from "chalk"; import { Command, Option } from "clipanion"; import { ReadonlySet } from "complete-common"; import { deleteFileOrDirectory, diff, fatalError, isDirectory, isFile, readFile, writeFile, } from "complete-node"; import klawSync from "klaw-sync"; import os from "node:os"; import path from "node:path"; import { ACTION_YML, ACTION_YML_TEMPLATE_PATH, CWD, TEMPLATES_DYNAMIC_DIR, TEMPLATES_STATIC_DIR, } from "../constants.js"; import { getTruncatedText } from "./check/getTruncatedText.js"; const URL_PREFIX = "https://raw.githubusercontent.com/complete-ts/complete/main/packages/complete-cli/file-templates"; export class CheckCommand extends Command { static override paths = [["check"], ["c"]]; static override usage = Command.Usage({ description: "Checks the current project for out-of-date files that came from the initial bootstrap.", }); ignore = Option.String("-i,--ignore", { description: "Comma separated list of file names to ignore.", }); verbose = Option.Boolean("-v,--verbose", false, { description: "Enable verbose output.", }); async execute(): Promise { let oneOrMoreErrors = false; const ignore = this.ignore ?? ""; const ignoreFileNames = ignore.split(","); const ignoreFileNamesSet = new ReadonlySet(ignoreFileNames); // First, check the static files. const staticTemplatesValid = await checkTemplateDirectory( TEMPLATES_STATIC_DIR, ignoreFileNamesSet, this.verbose, ); if (!staticTemplatesValid) { oneOrMoreErrors = true; } // Second, check dynamic files that require specific logic. const dynamicFilesValid = await checkDynamicFiles( ignoreFileNamesSet, this.verbose, ); if (!dynamicFilesValid) { oneOrMoreErrors = true; } if (oneOrMoreErrors) { fatalError("The check command failed."); } } } /** @returns Whether the directory was valid. */ async function checkTemplateDirectory( templateDirectory: string, ignoreFileNamesSet: ReadonlySet, verbose: boolean, ): Promise { let oneOrMoreErrors = false; // We use `klawSync` instead of `klaw` so that the output will be deterministic. const klawItems = klawSync(templateDirectory); for (const klawItem of klawItems) { const templateFilePath = klawItem.path; // eslint-disable-next-line no-await-in-loop const templateExists = await isDirectory(templateFilePath); if (templateExists) { continue; } const originalFileName = path.basename(templateFilePath); if (originalFileName === "main.ts") { continue; } const relativeTemplateFilePath = path.relative( templateDirectory, templateFilePath, ); const projectFilePath = getProjectFilePath(relativeTemplateFilePath); const projectFileName = path.basename(projectFilePath); if (ignoreFileNamesSet.has(projectFileName)) { continue; } // eslint-disable-next-line no-await-in-loop const fileValid = await compareTextFiles( projectFilePath, templateFilePath, verbose, ); if (!fileValid) { oneOrMoreErrors = true; } } return !oneOrMoreErrors; } function getProjectFilePath(relativeTemplateFilePath: string) { const templateFileName = path.basename(relativeTemplateFilePath); const projectFilePath = path.join(CWD, relativeTemplateFilePath); switch (templateFileName) { case "_cspell.config.jsonc": { return path.resolve(projectFilePath, "..", "cspell.config.jsonc"); } case "_gitattributes": { return path.resolve(projectFilePath, "..", ".gitattributes"); } default: { return projectFilePath; } } } /** @returns Whether the dynamic files were valid. */ async function checkDynamicFiles( ignoreFileNamesSet: ReadonlySet, verbose: boolean, ) { let oneOrMoreErrors = false; if (!ignoreFileNamesSet.has(ACTION_YML)) { const templateFilePath = ACTION_YML_TEMPLATE_PATH; const relativeTemplateFilePath = path.relative( TEMPLATES_DYNAMIC_DIR, templateFilePath, ); const projectFilePath = path.join(CWD, relativeTemplateFilePath); const fileValid = await compareTextFiles( projectFilePath, templateFilePath, verbose, ); if (!fileValid) { oneOrMoreErrors = true; } } return !oneOrMoreErrors; } /** @returns Whether the project file is valid in reference to the template file. */ async function compareTextFiles( projectFilePath: string, templateFilePath: string, verbose: boolean, ): Promise { const fileExists = await isFile(projectFilePath); if (!fileExists) { console.log(`Failed to find the following file: ${projectFilePath}`); printTemplateLocation(templateFilePath); return false; } const projectFileObject = await getTruncatedFileText( projectFilePath, new Set(), new Set(), ); const templateFileObject = await getTruncatedFileText( templateFilePath, projectFileObject.ignoreLines, projectFileObject.linesBeforeIgnore, ); if (projectFileObject.text === templateFileObject.text) { return true; } console.log( `The contents of the following file do not match: ${chalk.red( projectFilePath, )}`, ); printTemplateLocation(templateFilePath); if (verbose) { const originalTemplateFile = await readFile(templateFilePath); const originalProjectFile = await readFile(projectFilePath); console.log("--- Original template file: ---\n"); console.log(originalTemplateFile); console.log(); console.log("--- Original project file: ---\n"); console.log(originalProjectFile); console.log(); console.log("--- Parsed template file: ---\n"); console.log(templateFileObject.text); console.log(); console.log("--- Parsed project file: ---\n"); console.log(projectFileObject.text); console.log(); } const tempDir = os.tmpdir(); const tempProjectFilePath = path.join(tempDir, "tempProjectFile.txt"); const tempTemplateFilePath = path.join(tempDir, "tempTemplateFile.txt"); await writeFile(tempProjectFilePath, projectFileObject.text); await writeFile(tempTemplateFilePath, templateFileObject.text); diff(projectFileObject.text, templateFileObject.text); await deleteFileOrDirectory(tempProjectFilePath); await deleteFileOrDirectory(tempTemplateFilePath); return false; } async function getTruncatedFileText( filePath: string, ignoreLines: ReadonlySet, linesBeforeIgnore: ReadonlySet, ) { const fileName = path.basename(filePath); const fileContents = await readFile(filePath); return getTruncatedText( fileName, fileContents, ignoreLines, linesBeforeIgnore, ); } function printTemplateLocation(templateFilePath: string) { const unixPath = templateFilePath.split(path.sep).join(path.posix.sep); const fileTemplatesSegment = "/file-templates/"; const segmentIndex = unixPath.indexOf(fileTemplatesSegment); if (segmentIndex === -1) { fatalError(`Failed to parse the template file path: ${templateFilePath}`); } const urlSuffix = unixPath.slice(segmentIndex + fileTemplatesSegment.length); console.log( `You can find the template at: ${chalk.green( `${URL_PREFIX}/${urlSuffix}`, )}\n`, ); }