fork of hey-api/openapi-ts because I need some additional things
at feat/skip-token 232 lines 6.4 kB view raw
1import fs from 'node:fs'; 2import path from 'node:path'; 3 4import colors from 'ansi-colors'; 5 6import { ensureDirSync } from './fs'; 7import { loadPackageJson } from './tsConfig'; 8 9type IJobError = { 10 error: Error; 11 jobIndex: number; 12}; 13 14/** 15 * Represents a single configuration error. 16 * 17 * Used for reporting issues with a specific config instance. 18 */ 19export class ConfigError extends Error { 20 constructor(message: string) { 21 super(message); 22 this.name = 'ConfigError'; 23 } 24} 25 26/** 27 * Aggregates multiple config errors with their job indices for reporting. 28 */ 29export class ConfigValidationError extends Error { 30 readonly errors: ReadonlyArray<IJobError>; 31 32 constructor(errors: Array<IJobError>) { 33 super(`Found ${errors.length} configuration ${errors.length === 1 ? 'error' : 'errors'}.`); 34 this.name = 'ConfigValidationError'; 35 this.errors = errors; 36 } 37} 38 39/** 40 * Represents a runtime error originating from a specific job. 41 * 42 * Used for reporting job-level failures that are not config validation errors. 43 */ 44export class JobError extends Error { 45 readonly originalError: IJobError; 46 47 constructor(message: string, error: IJobError) { 48 super(message); 49 this.name = 'JobError'; 50 this.originalError = error; 51 } 52} 53 54export class HeyApiError extends Error { 55 args: ReadonlyArray<unknown>; 56 event: string; 57 pluginName: string; 58 59 constructor({ 60 args, 61 error, 62 event, 63 name, 64 pluginName, 65 }: { 66 args: unknown[]; 67 error: Error; 68 event: string; 69 name: string; 70 pluginName: string; 71 }) { 72 const message = error instanceof Error ? error.message : 'Unknown error'; 73 super(message); 74 75 this.args = args; 76 this.cause = error.cause; 77 this.event = event; 78 this.name = name || error.name; 79 this.pluginName = pluginName; 80 this.stack = error.stack; 81 } 82} 83 84export function logCrashReport(error: unknown, logsDir: string): string | undefined { 85 if (error instanceof ConfigError || error instanceof ConfigValidationError) { 86 return; 87 } 88 89 if (error instanceof JobError) { 90 error = error.originalError.error; 91 } 92 93 const logName = `openapi-ts-error-${Date.now()}.log`; 94 const fullDir = path.resolve(process.cwd(), logsDir); 95 ensureDirSync(fullDir); 96 const logPath = path.resolve(fullDir, logName); 97 98 let logContent = `[${new Date().toISOString()}] `; 99 100 if (error instanceof HeyApiError) { 101 logContent += `${error.name} during event "${error.event}"\n`; 102 if (error.pluginName) { 103 logContent += `Plugin: ${error.pluginName}\n`; 104 } 105 logContent += `Arguments: ${JSON.stringify(error.args, null, 2)}\n\n`; 106 } 107 108 const message = error instanceof Error ? error.message : String(error); 109 const stack = error instanceof Error ? error.stack : undefined; 110 111 logContent += `Error: ${message}\n`; 112 if (stack) { 113 logContent += `Stack:\n${stack}\n`; 114 } 115 116 fs.writeFileSync(logPath, logContent); 117 118 return logPath; 119} 120 121export async function openGitHubIssueWithCrashReport( 122 error: unknown, 123 initialDir: string, 124): Promise<void> { 125 const packageJson = loadPackageJson(initialDir); 126 if (!packageJson?.bugs.url) return; 127 128 if (error instanceof JobError) { 129 error = error.originalError.error; 130 } 131 132 let body = ''; 133 134 if (error instanceof HeyApiError) { 135 if (error.pluginName) { 136 body += `**Plugin**: \`${error.pluginName}\`\n`; 137 } 138 body += `**Event**: \`${error.event}\`\n`; 139 body += `**Arguments**:\n\`\`\`ts\n${JSON.stringify(error.args, null, 2)}\n\`\`\`\n\n`; 140 } 141 142 const message = error instanceof Error ? error.message : String(error); 143 const stack = error instanceof Error ? error.stack : undefined; 144 145 body += `**Error**: \`${message}\`\n`; 146 if (stack) { 147 body += `\n**Stack Trace**:\n\`\`\`\n${stack}\n\`\`\``; 148 } 149 150 const search = new URLSearchParams({ 151 body, 152 labels: 'bug 🔥', 153 title: 'Crash Report', 154 }); 155 const url = `${packageJson.bugs.url}new?${search.toString()}`; 156 const open = (await import('open')).default; 157 await open(url); 158} 159 160export function printCrashReport({ 161 error, 162 logPath, 163}: { 164 error: unknown; 165 logPath: string | undefined; 166}): void { 167 if (error instanceof ConfigValidationError && error.errors.length) { 168 const groupByJob = new Map<number, Array<Error>>(); 169 for (const { error: err, jobIndex } of error.errors) { 170 if (!groupByJob.has(jobIndex)) { 171 groupByJob.set(jobIndex, []); 172 } 173 groupByJob.get(jobIndex)!.push(err); 174 } 175 176 for (const [jobIndex, errors] of groupByJob.entries()) { 177 const jobPrefix = colors.gray(`[Job ${jobIndex + 1}] `); 178 const count = errors.length; 179 const baseString = colors.red( 180 `Found ${count} configuration ${count === 1 ? 'error' : 'errors'}:`, 181 ); 182 console.error(`${jobPrefix}❗️ ${baseString}`); 183 errors.forEach((err, index) => { 184 const itemPrefixStr = ` [${index + 1}] `; 185 const itemPrefix = colors.red(itemPrefixStr); 186 console.error(`${jobPrefix}${itemPrefix}${colors.white(err.message)}`); 187 }); 188 } 189 } else { 190 let jobPrefix = colors.gray('[root] '); 191 if (error instanceof JobError) { 192 jobPrefix = colors.gray(`[Job ${error.originalError.jobIndex + 1}] `); 193 error = error.originalError.error; 194 } 195 196 const baseString = colors.red('Failed with the message:'); 197 console.error(`${jobPrefix}${baseString}`); 198 const itemPrefixStr = ` `; 199 const itemPrefix = colors.red(itemPrefixStr); 200 console.error( 201 `${jobPrefix}${itemPrefix}${typeof error === 'string' ? error : error instanceof Error ? error.message : 'Unknown error'}`, 202 ); 203 } 204 205 if (logPath) { 206 const jobPrefix = colors.gray('[root] '); 207 console.error(`${jobPrefix}${colors.cyan('📄 Crash log saved to:')} ${colors.gray(logPath)}`); 208 } 209} 210 211export async function shouldReportCrash({ 212 error, 213 isInteractive, 214}: { 215 error: unknown; 216 isInteractive: boolean | undefined; 217}): Promise<boolean> { 218 if (!isInteractive || error instanceof ConfigError || error instanceof ConfigValidationError) { 219 return false; 220 } 221 222 return new Promise((resolve) => { 223 const jobPrefix = colors.gray('[root] '); 224 console.log( 225 `${jobPrefix}${colors.yellow('📢 Open a GitHub issue with crash details? (y/N):')}`, 226 ); 227 process.stdin.setEncoding('utf8'); 228 process.stdin.once('data', (data: string) => { 229 resolve(data.trim().toLowerCase() === 'y'); 230 }); 231 }); 232}