fork of hey-api/openapi-ts because I need some additional things
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}