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';
5import open from 'open';
6
7import { ensureDirSync } from './fs';
8import { loadPackageJson } from './tsConfig';
9
10type IJobError = {
11 error: Error;
12 jobIndex: number;
13};
14
15/**
16 * Represents a single configuration error.
17 *
18 * Used for reporting issues with a specific config instance.
19 */
20export class ConfigError extends Error {
21 constructor(message: string) {
22 super(message);
23 this.name = 'ConfigError';
24 }
25}
26
27/**
28 * Aggregates multiple config errors with their job indices for reporting.
29 */
30export class ConfigValidationError extends Error {
31 readonly errors: ReadonlyArray<IJobError>;
32
33 constructor(errors: Array<IJobError>) {
34 super(`Found ${errors.length} configuration ${errors.length === 1 ? 'error' : 'errors'}.`);
35 this.name = 'ConfigValidationError';
36 this.errors = errors;
37 }
38}
39
40/**
41 * Represents an error caused by invalid or inaccessible input.
42 *
43 * Used for errors like file not found, URL not reachable, etc.
44 */
45export class InputError extends Error {
46 readonly originalError: Error & { source?: string };
47
48 constructor(message: string, originalError: Error & { source?: string }) {
49 super(message);
50 this.name = 'InputError';
51 this.originalError = originalError;
52 }
53}
54
55/**
56 * Represents a runtime error originating from a specific job.
57 *
58 * Used for reporting job-level failures that are not config validation errors.
59 */
60export class JobError extends Error {
61 readonly originalError: IJobError;
62
63 constructor(message: string, error: IJobError) {
64 super(message);
65 this.name = 'JobError';
66 this.originalError = error;
67 }
68}
69
70export class HeyApiError extends Error {
71 args: ReadonlyArray<unknown>;
72 event: string;
73 pluginName: string;
74
75 constructor({
76 args,
77 error,
78 event,
79 name,
80 pluginName,
81 }: {
82 args: unknown[];
83 error: Error;
84 event: string;
85 name: string;
86 pluginName: string;
87 }) {
88 const message = error instanceof Error ? error.message : 'Unknown error';
89 super(message);
90
91 this.args = args;
92 this.cause = error.cause;
93 this.event = event;
94 this.name = name || error.name;
95 this.pluginName = pluginName;
96 this.stack = error.stack;
97 }
98}
99
100export function logCrashReport(error: unknown, logsDir: string): string | undefined {
101 if (
102 error instanceof ConfigError ||
103 error instanceof ConfigValidationError ||
104 error instanceof InputError
105 ) {
106 return;
107 }
108
109 if (error instanceof JobError) {
110 error = error.originalError.error;
111 }
112
113 const logName = `openapi-ts-error-${Date.now()}.log`;
114 const fullDir = path.resolve(process.cwd(), logsDir);
115 ensureDirSync(fullDir);
116 const logPath = path.resolve(fullDir, logName);
117
118 let logContent = `[${new Date().toISOString()}] `;
119
120 if (error instanceof HeyApiError) {
121 logContent += `${error.name} during event "${error.event}"\n`;
122 if (error.pluginName) {
123 logContent += `Plugin: ${error.pluginName}\n`;
124 }
125 logContent += `Arguments: ${JSON.stringify(error.args, null, 2)}\n\n`;
126 }
127
128 const message = error instanceof Error ? error.message : String(error);
129 const stack = error instanceof Error ? error.stack : undefined;
130
131 logContent += `Error: ${message}\n`;
132 if (stack) {
133 logContent += `Stack:\n${stack}\n`;
134 }
135
136 fs.writeFileSync(logPath, logContent);
137
138 return logPath;
139}
140
141export async function openGitHubIssueWithCrashReport(
142 error: unknown,
143 initialDir: string,
144): Promise<void> {
145 const packageJson = loadPackageJson(initialDir);
146 if (!packageJson?.bugs.url) return;
147
148 if (error instanceof JobError) {
149 error = error.originalError.error;
150 }
151
152 let body = '';
153
154 if (error instanceof HeyApiError) {
155 if (error.pluginName) {
156 body += `**Plugin**: \`${error.pluginName}\`\n`;
157 }
158 body += `**Event**: \`${error.event}\`\n`;
159 body += `**Arguments**:\n\`\`\`ts\n${JSON.stringify(error.args, null, 2)}\n\`\`\`\n\n`;
160 }
161
162 const message = error instanceof Error ? error.message : String(error);
163 const stack = error instanceof Error ? error.stack : undefined;
164
165 body += `**Error**: \`${message}\`\n`;
166 if (stack) {
167 body += `\n**Stack Trace**:\n\`\`\`\n${stack}\n\`\`\``;
168 }
169
170 const search = new URLSearchParams({
171 body,
172 labels: 'bug 🔥',
173 title: 'Crash Report',
174 });
175 const url = `${packageJson.bugs.url}new?${search.toString()}`;
176 await open(url);
177}
178
179export function getInputError(error: unknown): InputError | undefined {
180 if (error instanceof InputError) {
181 return error;
182 }
183 if (error instanceof JobError && error.originalError.error instanceof InputError) {
184 return error.originalError.error;
185 }
186}
187
188export function printCrashReport({
189 error,
190 logPath,
191}: {
192 error: unknown;
193 logPath: string | undefined;
194}): void {
195 if (error instanceof ConfigValidationError && error.errors.length) {
196 const groupByJob = new Map<number, Array<Error>>();
197 for (const { error: err, jobIndex } of error.errors) {
198 if (!groupByJob.has(jobIndex)) {
199 groupByJob.set(jobIndex, []);
200 }
201 groupByJob.get(jobIndex)!.push(err);
202 }
203
204 for (const [jobIndex, errors] of groupByJob.entries()) {
205 const jobPrefix = colors.gray(`[Job ${jobIndex + 1}] `);
206 const count = errors.length;
207 const baseString = colors.red(
208 `Found ${count} configuration ${count === 1 ? 'error' : 'errors'}:`,
209 );
210 console.error(`${jobPrefix}❗️ ${baseString}`);
211 errors.forEach((err, index) => {
212 const itemPrefixStr = ` [${index + 1}] `;
213 const itemPrefix = colors.red(itemPrefixStr);
214 console.error(`${jobPrefix}${itemPrefix}${colors.white(err.message)}`);
215 });
216 }
217 } else {
218 let jobPrefix = colors.gray('[root] ');
219 if (error instanceof JobError) {
220 jobPrefix = colors.gray(`[Job ${error.originalError.jobIndex + 1}] `);
221 error = error.originalError.error;
222 }
223
224 if (error instanceof InputError) {
225 const source = (error.originalError as { source?: string }).source;
226 const itemPrefixStr = ` `;
227
228 const isNetworkError = error.message.startsWith('Input request failed');
229 if (isNetworkError) {
230 console.error(`${jobPrefix}${colors.red(`❌ ${error.message}`)}`);
231 if (source) console.error(colors.gray(source));
232 console.error(colors.gray('\nPlease verify that:'));
233 console.error(colors.gray(`${itemPrefixStr}• The URL is correct`));
234 console.error(colors.gray(`${itemPrefixStr}• Your API key is valid`));
235 console.error(colors.gray(`${itemPrefixStr}• You have network access`));
236 return;
237 }
238
239 console.error(`${jobPrefix}${colors.red('❌ Input file not found:')}`);
240 if (source) console.error(colors.gray(source));
241 console.error(colors.gray('\nPlease verify that:'));
242 console.error(colors.gray(`${itemPrefixStr}• The file exists`));
243 console.error(colors.gray(`${itemPrefixStr}• The path is correct`));
244 console.error(colors.gray(`${itemPrefixStr}• You have read permissions`));
245 return;
246 }
247
248 const baseString = colors.red('Failed with the message:');
249 console.error(`${jobPrefix}❌ ${baseString}`);
250 const itemPrefixStr = ` `;
251 const itemPrefix = colors.red(itemPrefixStr);
252 console.error(
253 `${jobPrefix}${itemPrefix}${typeof error === 'string' ? error : error instanceof Error ? error.message : 'Unknown error'}`,
254 );
255 }
256
257 if (logPath) {
258 const jobPrefix = colors.gray('[root] ');
259 console.error(`${jobPrefix}${colors.cyan('📄 Crash log saved to:')} ${colors.gray(logPath)}`);
260 }
261}
262
263export async function shouldReportCrash({
264 error,
265 isInteractive,
266}: {
267 error: unknown;
268 isInteractive: boolean | undefined;
269}): Promise<boolean> {
270 if (
271 !isInteractive ||
272 error instanceof ConfigError ||
273 error instanceof ConfigValidationError ||
274 error instanceof InputError
275 ) {
276 return false;
277 }
278
279 return new Promise((resolve) => {
280 const jobPrefix = colors.gray('[root] ');
281 console.log(
282 `${jobPrefix}${colors.yellow('📢 Open a GitHub issue with crash details? (y/N):')}`,
283 );
284 process.stdin.setEncoding('utf8');
285 process.stdin.once('data', (data: string) => {
286 resolve(data.trim().toLowerCase() === 'y');
287 });
288 });
289}