BREAKING(structure): modernize internal structure and support unpacking
This commit is contained in:
536
ts/mod_compiler/classes.tscompiler.ts
Normal file
536
ts/mod_compiler/classes.tscompiler.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
import type { CompilerOptions, Diagnostic, Program } from 'typescript';
|
||||
import typescript from 'typescript';
|
||||
import * as smartdelay from '@push.rocks/smartdelay';
|
||||
import * as smartpromise from '@push.rocks/smartpromise';
|
||||
import * as smartpath from '@push.rocks/smartpath';
|
||||
|
||||
import { TsConfig } from '../mod_config/index.js';
|
||||
import { FsHelpers } from '../mod_fs/index.js';
|
||||
import { performUnpack } from '../mod_unpack/index.js';
|
||||
|
||||
/**
|
||||
* Interface for error summary data
|
||||
*/
|
||||
export interface IErrorSummary {
|
||||
errorsByFile: Record<string, Diagnostic[]>;
|
||||
generalErrors: Diagnostic[];
|
||||
totalErrors: number;
|
||||
totalFiles: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for task information
|
||||
*/
|
||||
export interface ITaskInfo {
|
||||
taskNumber: number;
|
||||
totalTasks: number;
|
||||
sourcePattern: string;
|
||||
destDir: string;
|
||||
fileCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for compilation result
|
||||
*/
|
||||
export interface ICompileResult {
|
||||
emittedFiles: string[];
|
||||
errorSummary: IErrorSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* TsCompiler handles TypeScript compilation with error tracking,
|
||||
* configuration management, and output unpacking.
|
||||
*/
|
||||
export class TsCompiler {
|
||||
private config: TsConfig;
|
||||
private cwd: string;
|
||||
private argvArg?: any;
|
||||
|
||||
constructor(cwd: string = process.cwd(), argvArg?: any) {
|
||||
this.cwd = cwd;
|
||||
this.config = new TsConfig(cwd);
|
||||
this.argvArg = argvArg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current working directory
|
||||
*/
|
||||
public getCwd(): string {
|
||||
return this.cwd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the TsConfig instance
|
||||
*/
|
||||
public getConfig(): TsConfig {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create compiler options by merging defaults, tsconfig.json, and custom options
|
||||
*/
|
||||
public createOptions(customOptions: CompilerOptions = {}): CompilerOptions {
|
||||
return this.config.merge(customOptions, this.argvArg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a TypeScript program from file names and options
|
||||
*/
|
||||
private createProgram(fileNames: string[], options: CompilerOptions): Program {
|
||||
return typescript.createProgram(fileNames, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process TypeScript diagnostics and return error summary
|
||||
*/
|
||||
private processDiagnostics(diagnostics: readonly Diagnostic[]): IErrorSummary {
|
||||
const errorsByFile: Record<string, Diagnostic[]> = {};
|
||||
const generalErrors: Diagnostic[] = [];
|
||||
|
||||
diagnostics.forEach((diagnostic) => {
|
||||
if (diagnostic.file) {
|
||||
const fileName = diagnostic.file.fileName;
|
||||
if (!errorsByFile[fileName]) {
|
||||
errorsByFile[fileName] = [];
|
||||
}
|
||||
errorsByFile[fileName].push(diagnostic);
|
||||
} else {
|
||||
generalErrors.push(diagnostic);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
errorsByFile,
|
||||
generalErrors,
|
||||
totalErrors: diagnostics.length,
|
||||
totalFiles: Object.keys(errorsByFile).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Display error summary to console
|
||||
*/
|
||||
private displayErrorSummary(errorSummary: IErrorSummary): void {
|
||||
if (errorSummary.totalErrors === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { errorsByFile, generalErrors, totalErrors, totalFiles } = errorSummary;
|
||||
|
||||
// Print error summary header
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log(
|
||||
`❌ Found ${totalErrors} error${totalErrors !== 1 ? 's' : ''} in ${totalFiles} file${totalFiles !== 1 ? 's' : ''}:`
|
||||
);
|
||||
console.log('='.repeat(80));
|
||||
|
||||
// Color codes for error formatting
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
red: '\x1b[31m',
|
||||
yellow: '\x1b[33m',
|
||||
cyan: '\x1b[36m',
|
||||
white: '\x1b[37m',
|
||||
brightRed: '\x1b[91m',
|
||||
};
|
||||
|
||||
// Print file-specific errors
|
||||
Object.entries(errorsByFile).forEach(([fileName, fileErrors]) => {
|
||||
// Show relative path if possible for cleaner output
|
||||
const displayPath = fileName.replace(process.cwd(), '').replace(/^\//, '');
|
||||
|
||||
console.log(
|
||||
`\n${colors.cyan}File: ${displayPath} ${colors.yellow}(${fileErrors.length} error${fileErrors.length !== 1 ? 's' : ''})${colors.reset}`
|
||||
);
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
fileErrors.forEach((diagnostic) => {
|
||||
if (diagnostic.file && diagnostic.start !== undefined) {
|
||||
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
|
||||
const message = typescript.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
|
||||
const errorCode = diagnostic.code ? `TS${diagnostic.code}` : 'Error';
|
||||
|
||||
console.log(
|
||||
`${colors.white}Line ${line + 1}, Col ${character + 1}${colors.reset}: ${colors.brightRed}${errorCode}${colors.reset} - ${message}`
|
||||
);
|
||||
|
||||
// Try to show the code snippet if possible
|
||||
try {
|
||||
const lineContent = diagnostic.file.text.split('\n')[line];
|
||||
if (lineContent) {
|
||||
console.log(` ${lineContent.trimEnd()}`);
|
||||
const indicator = ' '.repeat(character) + `${colors.red}^${colors.reset}`;
|
||||
console.log(` ${indicator}`);
|
||||
}
|
||||
} catch {
|
||||
// Failed to get source text, skip showing the code snippet
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Print general errors
|
||||
if (generalErrors.length > 0) {
|
||||
console.log(`\n${colors.yellow}General Errors:${colors.reset}`);
|
||||
console.log('-'.repeat(80));
|
||||
|
||||
generalErrors.forEach((diagnostic) => {
|
||||
const message = typescript.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
|
||||
const errorCode = diagnostic.code ? `TS${diagnostic.code}` : 'Error';
|
||||
console.log(`${colors.brightRed}${errorCode}${colors.reset}: ${message}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n' + '='.repeat(80) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle skipLibCheck warning display
|
||||
*/
|
||||
private async handleSkipLibCheckWarning(): Promise<void> {
|
||||
if (this.argvArg?.confirmskiplibcheck) {
|
||||
console.log('\n⚠️ WARNING ⚠️');
|
||||
console.log('You are skipping libcheck... Is that really wanted?');
|
||||
console.log('Continuing in 5 seconds...\n');
|
||||
await smartdelay.delayFor(5000);
|
||||
} else if (!this.argvArg?.quiet && !this.argvArg?.json) {
|
||||
console.log('⚠️ skipLibCheck enabled; use --confirmskiplibcheck to pause with warning.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile files with error tracking (returns result instead of throwing)
|
||||
*/
|
||||
public async compileFiles(
|
||||
fileNames: string[],
|
||||
customOptions: CompilerOptions = {},
|
||||
taskInfo?: ITaskInfo
|
||||
): Promise<ICompileResult> {
|
||||
const options = this.createOptions(customOptions);
|
||||
|
||||
if (options.skipLibCheck) {
|
||||
await this.handleSkipLibCheckWarning();
|
||||
}
|
||||
|
||||
// Enhanced logging with task info
|
||||
const startTime = Date.now();
|
||||
if (taskInfo) {
|
||||
const { taskNumber, totalTasks, sourcePattern, fileCount } = taskInfo;
|
||||
const relativeDestDir = taskInfo.destDir.replace(process.cwd(), '').replace(/^\//, '');
|
||||
console.log(
|
||||
`\n🔨 [${taskNumber}/${totalTasks}] Compiling ${fileCount} file${fileCount !== 1 ? 's' : ''} from ${sourcePattern}`
|
||||
);
|
||||
console.log(` 📁 Output: ${relativeDestDir}`);
|
||||
} else {
|
||||
console.log(`🔨 Compiling ${fileNames.length} files...`);
|
||||
}
|
||||
|
||||
const done = smartpromise.defer<ICompileResult>();
|
||||
const program = this.createProgram(fileNames, options);
|
||||
|
||||
// Check for pre-emit diagnostics first
|
||||
const preEmitDiagnostics = typescript.getPreEmitDiagnostics(program);
|
||||
const preEmitErrorSummary = this.processDiagnostics(preEmitDiagnostics);
|
||||
|
||||
// Only continue to emit phase if no pre-emit errors
|
||||
if (preEmitErrorSummary.totalErrors > 0) {
|
||||
this.displayErrorSummary(preEmitErrorSummary);
|
||||
console.error('\n❌ TypeScript pre-emit checks failed. Please fix the issues listed above before proceeding.');
|
||||
console.error(' Type errors must be resolved before the compiler can emit output files.\n');
|
||||
done.resolve({ emittedFiles: [], errorSummary: preEmitErrorSummary });
|
||||
return done.promise;
|
||||
}
|
||||
|
||||
// If no pre-emit errors, proceed with emit
|
||||
const emitResult = program.emit();
|
||||
const emitErrorSummary = this.processDiagnostics(emitResult.diagnostics);
|
||||
|
||||
// Combine error summaries
|
||||
const combinedErrorSummary: IErrorSummary = {
|
||||
errorsByFile: { ...preEmitErrorSummary.errorsByFile, ...emitErrorSummary.errorsByFile },
|
||||
generalErrors: [...preEmitErrorSummary.generalErrors, ...emitErrorSummary.generalErrors],
|
||||
totalErrors: preEmitErrorSummary.totalErrors + emitErrorSummary.totalErrors,
|
||||
totalFiles: Object.keys({ ...preEmitErrorSummary.errorsByFile, ...emitErrorSummary.errorsByFile }).length,
|
||||
};
|
||||
|
||||
const exitCode = emitResult.emitSkipped ? 1 : 0;
|
||||
if (exitCode === 0) {
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
if (taskInfo) {
|
||||
const { taskNumber, totalTasks } = taskInfo;
|
||||
console.log(`✅ [${taskNumber}/${totalTasks}] Task completed in ${duration}ms`);
|
||||
} else {
|
||||
console.log(`✅ TypeScript emit succeeded! (${duration}ms)`);
|
||||
}
|
||||
|
||||
// Get count of emitted files by type
|
||||
const jsFiles = emitResult.emittedFiles?.filter((f) => f.endsWith('.js')).length || 0;
|
||||
const dtsFiles = emitResult.emittedFiles?.filter((f) => f.endsWith('.d.ts')).length || 0;
|
||||
const mapFiles = emitResult.emittedFiles?.filter((f) => f.endsWith('.map')).length || 0;
|
||||
|
||||
if (emitResult.emittedFiles && emitResult.emittedFiles.length > 0) {
|
||||
console.log(
|
||||
` 📄 Generated ${emitResult.emittedFiles.length} files: ${jsFiles} .js, ${dtsFiles} .d.ts, ${mapFiles} source maps`
|
||||
);
|
||||
}
|
||||
|
||||
done.resolve({ emittedFiles: emitResult.emittedFiles || [], errorSummary: combinedErrorSummary });
|
||||
} else {
|
||||
this.displayErrorSummary(combinedErrorSummary);
|
||||
console.error('\n❌ TypeScript emit failed. Please investigate the errors listed above!');
|
||||
console.error(' No output files have been generated.\n');
|
||||
done.resolve({ emittedFiles: [], errorSummary: combinedErrorSummary });
|
||||
}
|
||||
|
||||
return done.promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile files (throws on error)
|
||||
*/
|
||||
public async compileFilesOrThrow(fileNames: string[], customOptions: CompilerOptions = {}): Promise<string[]> {
|
||||
const result = await this.compileFiles(fileNames, customOptions);
|
||||
if (result.errorSummary.totalErrors > 0) {
|
||||
throw new Error('TypeScript compilation failed.');
|
||||
}
|
||||
return result.emittedFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile glob patterns with automatic unpacking
|
||||
*/
|
||||
public async compileGlob(
|
||||
globPatterns: Record<string, string>,
|
||||
customOptions: CompilerOptions = {}
|
||||
): Promise<ICompileResult> {
|
||||
const emittedFiles: string[] = [];
|
||||
const errorSummaries: IErrorSummary[] = [];
|
||||
|
||||
const totalTasks = Object.keys(globPatterns).length;
|
||||
let currentTask = 0;
|
||||
|
||||
const isQuiet = this.argvArg?.quiet === true;
|
||||
const isJson = this.argvArg?.json === true;
|
||||
|
||||
if (!isQuiet && !isJson) {
|
||||
console.log(`\n👷 TypeScript Compilation Tasks (${totalTasks} task${totalTasks !== 1 ? 's' : ''}):`);
|
||||
Object.entries(globPatterns).forEach(([source, dest]) => {
|
||||
console.log(` 📂 ${source} → ${dest}`);
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
|
||||
for (const pattern of Object.keys(globPatterns)) {
|
||||
const destPath = globPatterns[pattern];
|
||||
if (!pattern || !destPath) continue;
|
||||
|
||||
// Get files matching the glob pattern
|
||||
const files = await FsHelpers.listFilesWithGlob(this.cwd, pattern);
|
||||
|
||||
// Transform to absolute paths
|
||||
const absoluteFiles = smartpath.transform.toAbsolute(files, this.cwd) as string[];
|
||||
|
||||
// Get destination directory as absolute path
|
||||
const destDir = smartpath.transform.toAbsolute(destPath, this.cwd) as string;
|
||||
|
||||
// Update compiler options with the output directory
|
||||
const options: CompilerOptions = {
|
||||
...customOptions,
|
||||
outDir: destDir,
|
||||
};
|
||||
|
||||
currentTask++;
|
||||
const taskInfo: ITaskInfo = {
|
||||
taskNumber: currentTask,
|
||||
totalTasks,
|
||||
sourcePattern: pattern,
|
||||
destDir: destPath,
|
||||
fileCount: absoluteFiles.length,
|
||||
};
|
||||
|
||||
const result = await this.compileFiles(absoluteFiles, options, taskInfo);
|
||||
emittedFiles.push(...result.emittedFiles);
|
||||
errorSummaries.push(result.errorSummary);
|
||||
|
||||
// Perform unpack if compilation succeeded
|
||||
if (result.errorSummary.totalErrors === 0) {
|
||||
await performUnpack(pattern, destDir, this.cwd);
|
||||
}
|
||||
}
|
||||
|
||||
// Merge all error summaries
|
||||
const finalErrorSummary = this.mergeErrorSummaries(errorSummaries);
|
||||
|
||||
// Output summary based on mode
|
||||
if (isJson) {
|
||||
const result = {
|
||||
success: finalErrorSummary.totalErrors === 0,
|
||||
totals: {
|
||||
errors: finalErrorSummary.totalErrors,
|
||||
filesWithErrors: finalErrorSummary.totalFiles,
|
||||
tasks: totalTasks,
|
||||
},
|
||||
errorsByFile: Object.fromEntries(
|
||||
Object.entries(finalErrorSummary.errorsByFile).map(([file, diags]) => [
|
||||
file,
|
||||
diags.map((d) => ({
|
||||
code: d.code,
|
||||
message: typescript.flattenDiagnosticMessageText(d.messageText as any, '\n'),
|
||||
})),
|
||||
])
|
||||
),
|
||||
};
|
||||
console.log(JSON.stringify(result));
|
||||
} else if (!isQuiet) {
|
||||
this.displayFinalSummary(finalErrorSummary);
|
||||
}
|
||||
|
||||
// Attach summary to argvArg for CLI exit behavior
|
||||
if (this.argvArg && typeof this.argvArg === 'object') {
|
||||
(this.argvArg as any).__tsbuildFinalErrorSummary = finalErrorSummary;
|
||||
}
|
||||
|
||||
return {
|
||||
emittedFiles,
|
||||
errorSummary: finalErrorSummary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if files can be emitted without actually emitting
|
||||
*/
|
||||
public async checkEmit(fileNames: string[], customOptions: CompilerOptions = {}): Promise<boolean> {
|
||||
const options = { ...this.createOptions(customOptions), noEmit: true };
|
||||
const fileCount = fileNames.length;
|
||||
|
||||
console.log(`\n🔍 Checking if ${fileCount} file${fileCount !== 1 ? 's' : ''} can be emitted...`);
|
||||
|
||||
const program = this.createProgram(fileNames, options);
|
||||
|
||||
const preEmitDiagnostics = typescript.getPreEmitDiagnostics(program);
|
||||
const preEmitErrorSummary = this.processDiagnostics(preEmitDiagnostics);
|
||||
|
||||
const emitResult = program.emit(undefined, undefined, undefined, true);
|
||||
const emitErrorSummary = this.processDiagnostics(emitResult.diagnostics);
|
||||
|
||||
const combinedErrorSummary: IErrorSummary = {
|
||||
errorsByFile: { ...preEmitErrorSummary.errorsByFile, ...emitErrorSummary.errorsByFile },
|
||||
generalErrors: [...preEmitErrorSummary.generalErrors, ...emitErrorSummary.generalErrors],
|
||||
totalErrors: preEmitErrorSummary.totalErrors + emitErrorSummary.totalErrors,
|
||||
totalFiles: Object.keys({ ...preEmitErrorSummary.errorsByFile, ...emitErrorSummary.errorsByFile }).length,
|
||||
};
|
||||
|
||||
const success = combinedErrorSummary.totalErrors === 0 && !emitResult.emitSkipped;
|
||||
|
||||
if (success) {
|
||||
console.log('\n✅ TypeScript emit check passed! All files can be emitted successfully.');
|
||||
console.log(` ${fileCount} file${fileCount !== 1 ? 's' : ''} ${fileCount !== 1 ? 'are' : 'is'} ready to be compiled.\n`);
|
||||
} else {
|
||||
this.displayErrorSummary(combinedErrorSummary);
|
||||
console.error('\n❌ TypeScript emit check failed. Please fix the issues listed above.');
|
||||
console.error(' The compilation cannot proceed until these errors are resolved.\n');
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check TypeScript files for type errors without emission
|
||||
*/
|
||||
public async checkTypes(fileNames: string[], customOptions: CompilerOptions = {}): Promise<boolean> {
|
||||
const options = { ...this.createOptions(customOptions), noEmit: true };
|
||||
const fileCount = fileNames.length;
|
||||
|
||||
console.log(`\n🔍 Type checking ${fileCount} TypeScript file${fileCount !== 1 ? 's' : ''}...`);
|
||||
|
||||
const program = this.createProgram(fileNames, options);
|
||||
const diagnostics = typescript.getPreEmitDiagnostics(program);
|
||||
const errorSummary = this.processDiagnostics(diagnostics);
|
||||
|
||||
const success = errorSummary.totalErrors === 0;
|
||||
|
||||
if (success) {
|
||||
console.log('\n✅ TypeScript type check passed! No type errors found.');
|
||||
console.log(` All ${fileCount} file${fileCount !== 1 ? 's' : ''} passed type checking successfully.\n`);
|
||||
} else {
|
||||
this.displayErrorSummary(errorSummary);
|
||||
console.error('\n❌ TypeScript type check failed. Please fix the type errors listed above.');
|
||||
console.error(' The type checker found issues that need to be resolved.\n');
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge multiple error summaries into one
|
||||
*/
|
||||
private mergeErrorSummaries(summaries: IErrorSummary[]): IErrorSummary {
|
||||
const mergedErrorsByFile: Record<string, Diagnostic[]> = {};
|
||||
const mergedGeneralErrors: Diagnostic[] = [];
|
||||
let totalErrors = 0;
|
||||
|
||||
summaries.forEach((summary) => {
|
||||
Object.entries(summary.errorsByFile).forEach(([fileName, errors]) => {
|
||||
if (!mergedErrorsByFile[fileName]) {
|
||||
mergedErrorsByFile[fileName] = [];
|
||||
}
|
||||
mergedErrorsByFile[fileName] = mergedErrorsByFile[fileName].concat(errors);
|
||||
});
|
||||
|
||||
mergedGeneralErrors.push(...summary.generalErrors);
|
||||
totalErrors += summary.totalErrors;
|
||||
});
|
||||
|
||||
return {
|
||||
errorsByFile: mergedErrorsByFile,
|
||||
generalErrors: mergedGeneralErrors,
|
||||
totalErrors,
|
||||
totalFiles: Object.keys(mergedErrorsByFile).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Display final compilation summary
|
||||
*/
|
||||
private displayFinalSummary(errorSummary: IErrorSummary): void {
|
||||
if (errorSummary.totalErrors === 0) {
|
||||
console.log('\n📊 \x1b[32mCompilation Summary: All tasks completed successfully! ✅\x1b[0m\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
red: '\x1b[31m',
|
||||
yellow: '\x1b[33m',
|
||||
cyan: '\x1b[36m',
|
||||
brightRed: '\x1b[91m',
|
||||
brightYellow: '\x1b[93m',
|
||||
};
|
||||
|
||||
console.log('\n' + '='.repeat(80));
|
||||
console.log(`📊 ${colors.brightYellow}Final Compilation Summary${colors.reset}`);
|
||||
console.log('='.repeat(80));
|
||||
|
||||
if (errorSummary.totalFiles > 0) {
|
||||
console.log(`${colors.brightRed}❌ Files with errors (${errorSummary.totalFiles}):${colors.reset}`);
|
||||
|
||||
Object.entries(errorSummary.errorsByFile).forEach(([fileName, errors]) => {
|
||||
const displayPath = fileName.replace(process.cwd(), '').replace(/^\//, '');
|
||||
console.log(
|
||||
` ${colors.red}•${colors.reset} ${colors.cyan}${displayPath}${colors.reset} ${colors.yellow}(${errors.length} error${errors.length !== 1 ? 's' : ''})${colors.reset}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (errorSummary.generalErrors.length > 0) {
|
||||
console.log(`${colors.brightRed}❌ General errors: ${errorSummary.generalErrors.length}${colors.reset}`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n${colors.brightRed}Total: ${errorSummary.totalErrors} error${errorSummary.totalErrors !== 1 ? 's' : ''} across ${errorSummary.totalFiles} file${errorSummary.totalFiles !== 1 ? 's' : ''}${colors.reset}`
|
||||
);
|
||||
console.log('='.repeat(80) + '\n');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user