toolcraft-openapi 0.0.17 → 0.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/bin/generate.js +7 -0
  2. package/dist/define-client.js +2 -2
  3. package/dist/generate.js +2 -2
  4. package/dist/http.d.ts +21 -2
  5. package/dist/http.js +147 -22
  6. package/dist/index.d.ts +1 -1
  7. package/dist/lock.d.ts +1 -1
  8. package/dist/lock.js +109 -5
  9. package/dist/mock/fetch.js +1 -1
  10. package/dist/network-error.d.ts +2 -0
  11. package/dist/network-error.js +83 -0
  12. package/dist/spec-source.js +103 -3
  13. package/node_modules/@poe-code/design-system/dist/acp/components.js +15 -13
  14. package/node_modules/@poe-code/design-system/dist/components/color.d.ts +31 -0
  15. package/node_modules/@poe-code/design-system/dist/components/color.js +101 -0
  16. package/node_modules/@poe-code/design-system/dist/components/help-formatter-plain.d.ts +1 -0
  17. package/node_modules/@poe-code/design-system/dist/components/help-formatter-plain.js +1 -1
  18. package/node_modules/@poe-code/design-system/dist/components/index.d.ts +4 -0
  19. package/node_modules/@poe-code/design-system/dist/components/index.js +2 -0
  20. package/node_modules/@poe-code/design-system/dist/components/logger.js +2 -2
  21. package/node_modules/@poe-code/design-system/dist/components/symbols.js +3 -3
  22. package/node_modules/@poe-code/design-system/dist/components/table.js +191 -40
  23. package/node_modules/@poe-code/design-system/dist/components/template.d.ts +6 -0
  24. package/node_modules/@poe-code/design-system/dist/components/template.js +271 -0
  25. package/node_modules/@poe-code/design-system/dist/components/text.d.ts +1 -0
  26. package/node_modules/@poe-code/design-system/dist/components/text.js +11 -3
  27. package/node_modules/@poe-code/design-system/dist/dashboard/buffer.js +20 -13
  28. package/node_modules/@poe-code/design-system/dist/dashboard/keymap.d.ts +5 -0
  29. package/node_modules/@poe-code/design-system/dist/dashboard/keymap.js +146 -12
  30. package/node_modules/@poe-code/design-system/dist/dashboard/terminal.js +31 -0
  31. package/node_modules/@poe-code/design-system/dist/dashboard/types.d.ts +1 -0
  32. package/node_modules/@poe-code/design-system/dist/explorer/actions.d.ts +16 -0
  33. package/node_modules/@poe-code/design-system/dist/explorer/actions.js +39 -0
  34. package/node_modules/@poe-code/design-system/dist/explorer/demo.d.ts +13 -0
  35. package/node_modules/@poe-code/design-system/dist/explorer/demo.js +297 -0
  36. package/node_modules/@poe-code/design-system/dist/explorer/events.d.ts +61 -0
  37. package/node_modules/@poe-code/design-system/dist/explorer/events.js +1 -0
  38. package/node_modules/@poe-code/design-system/dist/explorer/filter.d.ts +10 -0
  39. package/node_modules/@poe-code/design-system/dist/explorer/filter.js +95 -0
  40. package/node_modules/@poe-code/design-system/dist/explorer/index.d.ts +8 -0
  41. package/node_modules/@poe-code/design-system/dist/explorer/index.js +8 -0
  42. package/node_modules/@poe-code/design-system/dist/explorer/jobs.d.ts +7 -0
  43. package/node_modules/@poe-code/design-system/dist/explorer/jobs.js +59 -0
  44. package/node_modules/@poe-code/design-system/dist/explorer/keymap.d.ts +21 -0
  45. package/node_modules/@poe-code/design-system/dist/explorer/keymap.js +363 -0
  46. package/node_modules/@poe-code/design-system/dist/explorer/layout.d.ts +20 -0
  47. package/node_modules/@poe-code/design-system/dist/explorer/layout.js +73 -0
  48. package/node_modules/@poe-code/design-system/dist/explorer/reducer.d.ts +9 -0
  49. package/node_modules/@poe-code/design-system/dist/explorer/reducer.js +704 -0
  50. package/node_modules/@poe-code/design-system/dist/explorer/render/detail.d.ts +4 -0
  51. package/node_modules/@poe-code/design-system/dist/explorer/render/detail.js +96 -0
  52. package/node_modules/@poe-code/design-system/dist/explorer/render/footer.d.ts +4 -0
  53. package/node_modules/@poe-code/design-system/dist/explorer/render/footer.js +49 -0
  54. package/node_modules/@poe-code/design-system/dist/explorer/render/header.d.ts +4 -0
  55. package/node_modules/@poe-code/design-system/dist/explorer/render/header.js +56 -0
  56. package/node_modules/@poe-code/design-system/dist/explorer/render/index.d.ts +8 -0
  57. package/node_modules/@poe-code/design-system/dist/explorer/render/index.js +61 -0
  58. package/node_modules/@poe-code/design-system/dist/explorer/render/list.d.ts +4 -0
  59. package/node_modules/@poe-code/design-system/dist/explorer/render/list.js +106 -0
  60. package/node_modules/@poe-code/design-system/dist/explorer/render/modal.d.ts +3 -0
  61. package/node_modules/@poe-code/design-system/dist/explorer/render/modal.js +91 -0
  62. package/node_modules/@poe-code/design-system/dist/explorer/render/test-fixtures.d.ts +8 -0
  63. package/node_modules/@poe-code/design-system/dist/explorer/render/test-fixtures.js +156 -0
  64. package/node_modules/@poe-code/design-system/dist/explorer/runtime.d.ts +2 -0
  65. package/node_modules/@poe-code/design-system/dist/explorer/runtime.js +282 -0
  66. package/node_modules/@poe-code/design-system/dist/explorer/runtime.test-helpers.d.ts +50 -0
  67. package/node_modules/@poe-code/design-system/dist/explorer/runtime.test-helpers.js +101 -0
  68. package/node_modules/@poe-code/design-system/dist/explorer/state.d.ts +130 -0
  69. package/node_modules/@poe-code/design-system/dist/explorer/state.js +87 -0
  70. package/node_modules/@poe-code/design-system/dist/explorer/theme.d.ts +27 -0
  71. package/node_modules/@poe-code/design-system/dist/explorer/theme.js +97 -0
  72. package/node_modules/@poe-code/design-system/dist/index.d.ts +7 -0
  73. package/node_modules/@poe-code/design-system/dist/index.js +5 -0
  74. package/node_modules/@poe-code/design-system/dist/internal/color-support.d.ts +9 -0
  75. package/node_modules/@poe-code/design-system/dist/internal/color-support.js +12 -0
  76. package/node_modules/@poe-code/design-system/dist/prompts/index.js +2 -2
  77. package/node_modules/@poe-code/design-system/dist/prompts/primitives/cancel.js +2 -2
  78. package/node_modules/@poe-code/design-system/dist/prompts/primitives/intro.js +2 -2
  79. package/node_modules/@poe-code/design-system/dist/prompts/primitives/log.js +4 -4
  80. package/node_modules/@poe-code/design-system/dist/prompts/primitives/note.js +5 -5
  81. package/node_modules/@poe-code/design-system/dist/prompts/primitives/outro.js +2 -2
  82. package/node_modules/@poe-code/design-system/dist/prompts/primitives/spinner.js +3 -3
  83. package/node_modules/@poe-code/design-system/dist/static/menu.js +5 -5
  84. package/node_modules/@poe-code/design-system/dist/static/spinner.js +8 -8
  85. package/node_modules/@poe-code/design-system/dist/tokens/colors.js +29 -29
  86. package/node_modules/@poe-code/design-system/dist/tokens/typography.js +6 -6
  87. package/node_modules/@poe-code/design-system/package.json +6 -3
  88. package/package.json +2 -4
@@ -57,6 +57,13 @@ export async function runGenerateCli(argv = process.argv, services = {
57
57
  services.stderr.write(`${error.message}\n`);
58
58
  return 1;
59
59
  }
60
+ if (error instanceof Error && error.name === "ToolcraftBugError") {
61
+ services.stderr.write(`toolcraft hit an internal invariant: ${error.message}\n` +
62
+ `This is a bug in toolcraft or in the command definition; ` +
63
+ `it cannot be worked around by changing argv. ` +
64
+ `File an issue.\n`);
65
+ return 1;
66
+ }
60
67
  throw error;
61
68
  }
62
69
  }
@@ -1,4 +1,4 @@
1
- import { defineCommand, defineGroup, UserError } from "toolcraft";
1
+ import { ToolcraftBugError, defineCommand, defineGroup, UserError } from "toolcraft";
2
2
  import { toMcpPrefix } from "./naming.js";
3
3
  const CLI_SCOPE = ["cli"];
4
4
  export function defineClient(options) {
@@ -61,7 +61,7 @@ function registerSource(node, source, nodeSources) {
61
61
  function getRegisteredSource(nodeSources, node) {
62
62
  const source = nodeSources.get(node);
63
63
  if (source === undefined) {
64
- throw new Error("Bug: merged command node is missing source metadata.");
64
+ throw new ToolcraftBugError("merged command node is missing source metadata.");
65
65
  }
66
66
  return source;
67
67
  }
package/dist/generate.js CHANGED
@@ -1,4 +1,4 @@
1
- import { UserError } from "toolcraft";
1
+ import { ToolcraftBugError, UserError } from "toolcraft";
2
2
  import { METHOD_DEFAULTS, deriveNoun, deriveVerb, isIdentifierName, normalizeParamName, toCamelCase, toPascalCase } from "./naming.js";
3
3
  import { groupByNoun } from "./group-by-noun.js";
4
4
  import { renderPreflightBlock, renderRequestShape } from "./interpreter.js";
@@ -557,7 +557,7 @@ const FIELD_ASSEMBLERS = {
557
557
  };
558
558
  function expectQueryArraySerialization(querySerialization) {
559
559
  if (querySerialization === undefined) {
560
- throw new Error("Missing query array serialization for generated query array field.");
560
+ throw new ToolcraftBugError("Missing query array serialization for generated query array field.");
561
561
  }
562
562
  return querySerialization;
563
563
  }
package/dist/http.d.ts CHANGED
@@ -17,10 +17,29 @@ export interface HttpRequestOptions {
17
17
  writeStdout?: (chunk: string) => void;
18
18
  writeStderr?: (chunk: string) => void;
19
19
  }
20
+ export interface HttpErrorRequest {
21
+ method: string;
22
+ url: string;
23
+ headers: Record<string, string>;
24
+ body?: unknown;
25
+ }
26
+ export interface HttpErrorResponse {
27
+ status: number;
28
+ statusText: string;
29
+ headers: Record<string, string>;
30
+ body: unknown;
31
+ }
20
32
  export declare class HttpError extends Error {
21
33
  readonly status: number;
22
- readonly body: unknown;
23
- constructor(status: number, body: unknown, message?: string);
34
+ readonly statusText: string;
35
+ readonly request: HttpErrorRequest;
36
+ readonly response: HttpErrorResponse;
37
+ get body(): unknown;
38
+ constructor(args: {
39
+ request: HttpErrorRequest;
40
+ response: HttpErrorResponse;
41
+ message?: string;
42
+ });
24
43
  }
25
44
  export declare function requestJson<TResult = unknown>(options: HttpRequestOptions): Promise<TResult | undefined>;
26
45
  export {};
package/dist/http.js CHANGED
@@ -1,13 +1,23 @@
1
1
  import { text as designText } from "@poe-code/design-system";
2
2
  import { UserError } from "toolcraft";
3
+ import { classifyNetworkError } from "./network-error.js";
4
+ const TRANSCRIPT_BODY_BYTE_LIMIT = 4 * 1024;
3
5
  export class HttpError extends Error {
4
6
  status;
5
- body;
6
- constructor(status, body, message = `HTTP ${status}`) {
7
- super(message);
7
+ statusText;
8
+ request;
9
+ response;
10
+ get body() {
11
+ return this.response.body;
12
+ }
13
+ constructor(args) {
14
+ super(args.message ??
15
+ `${args.request.method} ${args.request.url} → ${args.response.status} ${args.response.statusText}`);
8
16
  this.name = "HttpError";
9
- this.status = status;
10
- this.body = body;
17
+ this.status = args.response.status;
18
+ this.statusText = args.response.statusText;
19
+ this.request = args.request;
20
+ this.response = args.response;
11
21
  }
12
22
  }
13
23
  export async function requestJson(options) {
@@ -20,34 +30,73 @@ export async function requestJson(options) {
20
30
  const writeStdout = options.writeStdout ?? process.stdout.write.bind(process.stdout);
21
31
  const writeStderr = options.writeStderr ?? process.stderr.write.bind(process.stderr);
22
32
  const requestLine = `${method} ${url}`;
23
- if (options.verbose) {
24
- writeStderr(`${designText.muted(requestLine)}\n`);
25
- }
26
33
  if (options.dryRun) {
27
34
  writeStdout(formatDryRunOutput(requestLine, headers, options.body));
28
35
  return undefined;
29
36
  }
30
- const response = await (options.fetch ?? globalThis.fetch)(url, {
31
- method,
32
- headers,
33
- body: serializedBody,
34
- signal: options.signal,
35
- });
37
+ if (options.verbose) {
38
+ writeStderr(formatTranscriptLines(formatVerboseRequestTranscript(method, url, headers, options.body)));
39
+ }
40
+ let response;
41
+ try {
42
+ response = await (options.fetch ?? globalThis.fetch)(url, {
43
+ method,
44
+ headers,
45
+ body: serializedBody,
46
+ signal: options.signal
47
+ });
48
+ }
49
+ catch (error) {
50
+ throw classifyNetworkError(error, url) ?? error;
51
+ }
36
52
  const text = await response.text();
37
53
  const contentType = response.headers.get("content-type");
54
+ const request = createHttpErrorRequest(method, url, headers, options.body);
55
+ const responseHeaders = serializeHeaders(response.headers);
38
56
  if (response.ok) {
39
57
  if (text.length === 0) {
58
+ if (options.verbose) {
59
+ writeStderr(formatTranscriptLines(formatVerboseResponseTranscript(response, responseHeaders)));
60
+ }
40
61
  return undefined;
41
62
  }
42
63
  if (!isJsonContentType(contentType)) {
43
- throw new HttpError(response.status, text, `Expected a JSON response body but received content-type ${JSON.stringify(contentType ?? "<missing>")}.`);
64
+ if (options.verbose) {
65
+ writeStderr(formatTranscriptLines(formatVerboseResponseTranscript(response, responseHeaders, text)));
66
+ }
67
+ throw new HttpError({
68
+ request,
69
+ response: {
70
+ status: response.status,
71
+ statusText: response.statusText,
72
+ headers: responseHeaders,
73
+ body: text
74
+ },
75
+ message: `Expected a JSON response body but received content-type ${JSON.stringify(contentType ?? "<missing>")}.`
76
+ });
44
77
  }
45
- return JSON.parse(text);
78
+ const body = JSON.parse(text);
79
+ if (options.verbose) {
80
+ writeStderr(formatTranscriptLines(formatVerboseResponseTranscript(response, responseHeaders, body)));
81
+ }
82
+ return body;
46
83
  }
47
84
  if (response.status === 401) {
48
85
  await options.tokenSource.invalidate?.();
49
86
  }
50
- throw new HttpError(response.status, parseResponseBody(text, contentType));
87
+ const body = parseResponseBody(text, contentType);
88
+ if (options.verbose) {
89
+ writeStderr(formatTranscriptLines(formatVerboseResponseTranscript(response, responseHeaders, body)));
90
+ }
91
+ throw new HttpError({
92
+ request,
93
+ response: {
94
+ status: response.status,
95
+ statusText: response.statusText,
96
+ headers: responseHeaders,
97
+ body
98
+ }
99
+ });
51
100
  }
52
101
  function buildRequestUrl(options) {
53
102
  const resolvedPath = substitutePathParams(options.path, options.pathParams);
@@ -87,25 +136,101 @@ function appendQueryValue(searchParams, key, value) {
87
136
  function createHeaders(token, hasBody) {
88
137
  return {
89
138
  ...(token === undefined ? {} : { Authorization: `Bearer ${token}` }),
90
- ...(hasBody ? { "Content-Type": "application/json" } : {}),
139
+ ...(hasBody ? { "Content-Type": "application/json" } : {})
140
+ };
141
+ }
142
+ function createHttpErrorRequest(method, url, headers, body) {
143
+ return {
144
+ method,
145
+ url,
146
+ headers: redactHeaders(headers),
147
+ ...(body === undefined ? {} : { body })
91
148
  };
92
149
  }
150
+ function serializeHeaders(headers) {
151
+ return Object.fromEntries(headers.entries());
152
+ }
153
+ function redactHeaders(headers) {
154
+ return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, redactHeaderValue(key, value)]));
155
+ }
156
+ function redactHeaderValue(key, value) {
157
+ if (key.toLowerCase() === "authorization" && value.startsWith("Bearer ")) {
158
+ return "Bearer ****";
159
+ }
160
+ return value;
161
+ }
93
162
  function formatDryRunOutput(requestLine, headers, body) {
94
163
  const lines = [
95
164
  requestLine,
96
165
  ...Object.entries(headers).map(([key, value]) => {
97
- const headerValue = key.toLowerCase() === "authorization" && value.startsWith("Bearer ")
98
- ? "Bearer ****"
99
- : value;
166
+ const headerValue = redactHeaderValue(key, value);
100
167
  return `${key}: ${headerValue}`;
101
168
  }),
102
- "",
169
+ ""
103
170
  ];
104
171
  if (body !== undefined) {
105
172
  lines.push(JSON.stringify(body));
106
173
  }
107
174
  return `${lines.join("\n")}\n`;
108
175
  }
176
+ function formatVerboseRequestTranscript(method, url, headers, body) {
177
+ const lines = [
178
+ `→ ${method} ${url}`,
179
+ ...Object.entries(headers).map(([key, value]) => {
180
+ const headerValue = redactHeaderValue(key, value);
181
+ return ` ${key}: ${headerValue}`;
182
+ })
183
+ ];
184
+ if (body !== undefined) {
185
+ lines.push(...indentTranscriptBlock(formatTranscriptBody(body)));
186
+ }
187
+ return lines;
188
+ }
189
+ function formatVerboseResponseTranscript(response, headers, body) {
190
+ const lines = [
191
+ `← ${response.status} ${response.statusText}`,
192
+ ...Object.entries(headers).map(([key, value]) => ` ${key}: ${value}`)
193
+ ];
194
+ if (body !== undefined) {
195
+ lines.push(...indentTranscriptBlock(formatTranscriptBody(body)));
196
+ }
197
+ return lines;
198
+ }
199
+ function formatTranscriptLines(lines) {
200
+ return `${lines.map((line) => formatTranscriptLine(line)).join("\n")}\n`;
201
+ }
202
+ function formatTranscriptLine(line) {
203
+ if (!process.stderr.isTTY) {
204
+ return line;
205
+ }
206
+ return designText.muted(line);
207
+ }
208
+ function formatTranscriptBody(body) {
209
+ const formatted = typeof body === "string" ? body : JSON.stringify(body, null, 2);
210
+ return truncateTranscriptBody(formatted ?? String(body));
211
+ }
212
+ function indentTranscriptBlock(value) {
213
+ return value.split("\n").map((line) => ` ${line}`);
214
+ }
215
+ function truncateTranscriptBody(value) {
216
+ const encoder = new TextEncoder();
217
+ const encoded = encoder.encode(value);
218
+ if (encoded.byteLength <= TRANSCRIPT_BODY_BYTE_LIMIT) {
219
+ return value;
220
+ }
221
+ const truncatedChars = [];
222
+ let truncatedByteLength = 0;
223
+ for (const character of value) {
224
+ const characterByteLength = encoder.encode(character).byteLength;
225
+ if (truncatedByteLength + characterByteLength > TRANSCRIPT_BODY_BYTE_LIMIT) {
226
+ break;
227
+ }
228
+ truncatedChars.push(character);
229
+ truncatedByteLength += characterByteLength;
230
+ }
231
+ const truncatedBytes = encoded.byteLength - truncatedByteLength;
232
+ return `${truncatedChars.join("")}\n… (${truncatedBytes} bytes truncated)`;
233
+ }
109
234
  function parseResponseBody(text, contentType) {
110
235
  if (text.length === 0) {
111
236
  return undefined;
package/dist/index.d.ts CHANGED
@@ -9,4 +9,4 @@ export type { AuthProvider, CommandContributor, TokenSource } from "./auth/types
9
9
  export { bearerTokenAuth } from "./auth/bearer-token-auth.js";
10
10
  export type { BearerTokenAuthOptions } from "./auth/bearer-token-auth.js";
11
11
  export { HttpError, requestJson } from "./http.js";
12
- export type { HttpRequestOptions, QueryValue } from "./http.js";
12
+ export type { HttpErrorRequest, HttpErrorResponse, HttpRequestOptions, QueryValue } from "./http.js";
package/dist/lock.d.ts CHANGED
@@ -8,7 +8,7 @@ export interface LockFileSystem {
8
8
  readFile(filePath: string, encoding: BufferEncoding): Promise<string>;
9
9
  writeFile(filePath: string, contents: string, encoding: BufferEncoding): Promise<void>;
10
10
  }
11
- export declare function parseOpenApiLock(contents: string): OpenApiLock | null;
11
+ export declare function parseOpenApiLock(contents: string, lockPath: string): OpenApiLock | null;
12
12
  export declare function stringifyOpenApiLock(lock: OpenApiLock): string;
13
13
  export declare function readOpenApiLock(fs: Pick<LockFileSystem, "readFile">, lockPath: string): Promise<OpenApiLock | null>;
14
14
  export declare function writeOpenApiLock(fs: LockFileSystem, lockPath: string, lock: OpenApiLock): Promise<void>;
package/dist/lock.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import path from "node:path";
2
- export function parseOpenApiLock(contents) {
2
+ import { UserError } from "toolcraft";
3
+ import { renderSourceSnippet } from "toolcraft/source-snippet";
4
+ export function parseOpenApiLock(contents, lockPath) {
3
5
  let parsed;
4
6
  try {
5
7
  parsed = JSON.parse(contents);
6
8
  }
7
- catch {
8
- return null;
9
+ catch (error) {
10
+ throw new UserError(formatJsonParseError(lockPath, contents, error), { cause: error });
9
11
  }
10
12
  if (typeof parsed !== "object" ||
11
13
  parsed === null ||
@@ -27,7 +29,7 @@ export function stringifyOpenApiLock(lock) {
27
29
  }
28
30
  export async function readOpenApiLock(fs, lockPath) {
29
31
  try {
30
- return parseOpenApiLock(await fs.readFile(lockPath, "utf8"));
32
+ return parseOpenApiLock(await fs.readFile(lockPath, "utf8"), lockPath);
31
33
  }
32
34
  catch (error) {
33
35
  if (isNotFoundError(error)) {
@@ -38,7 +40,13 @@ export async function readOpenApiLock(fs, lockPath) {
38
40
  }
39
41
  export async function writeOpenApiLock(fs, lockPath, lock) {
40
42
  await fs.mkdir(path.dirname(lockPath), { recursive: true });
41
- await fs.writeFile(lockPath, stringifyOpenApiLock(lock), "utf8");
43
+ try {
44
+ await fs.writeFile(lockPath, stringifyOpenApiLock(lock), "utf8");
45
+ }
46
+ catch (error) {
47
+ const code = getErrorCode(error);
48
+ throw new UserError(`Failed to write lock file "${lockPath}"${code === undefined ? "" : ` (${code})`}: ${getErrorMessage(error)}`, { cause: error });
49
+ }
42
50
  }
43
51
  function isNotFoundError(error) {
44
52
  return (typeof error === "object" &&
@@ -46,3 +54,99 @@ function isNotFoundError(error) {
46
54
  "code" in error &&
47
55
  error.code === "ENOENT");
48
56
  }
57
+ function getErrorMessage(error) {
58
+ return error instanceof Error ? error.message : String(error);
59
+ }
60
+ function formatJsonParseError(lockPath, source, error) {
61
+ const location = getJsonParseErrorLocation(error, source);
62
+ const message = getErrorMessage(error);
63
+ if (location === null) {
64
+ return `Lock file "${lockPath}" is not valid JSON: ${message}.`;
65
+ }
66
+ return (`Lock file "${lockPath}" is not valid JSON: ${message} ` +
67
+ `at line ${location.line} column ${location.column}.\n` +
68
+ renderSourceSnippet({
69
+ source,
70
+ line: location.line,
71
+ column: location.column,
72
+ filePath: lockPath
73
+ }));
74
+ }
75
+ function getJsonParseErrorLocation(error, source) {
76
+ const causeLocation = getJsonParseCauseLocation(error);
77
+ if (causeLocation !== null) {
78
+ return causeLocation;
79
+ }
80
+ const directPosition = getNumericProperty(error, "position");
81
+ if (directPosition !== null) {
82
+ return getSourceOffsetLocation(source, directPosition);
83
+ }
84
+ const messagePosition = getJsonParseMessagePosition(getErrorMessage(error));
85
+ if (messagePosition !== null) {
86
+ return getSourceOffsetLocation(source, messagePosition);
87
+ }
88
+ return null;
89
+ }
90
+ function getJsonParseCauseLocation(error) {
91
+ if (typeof error !== "object" || error === null || !("cause" in error)) {
92
+ return null;
93
+ }
94
+ const cause = error.cause;
95
+ const line = getNumericProperty(cause, "line");
96
+ const column = getNumericProperty(cause, "column") ?? getNumericProperty(cause, "col");
97
+ if (line === null || column === null) {
98
+ return null;
99
+ }
100
+ return { line, column };
101
+ }
102
+ function getNumericProperty(value, key) {
103
+ if (typeof value !== "object" || value === null || !(key in value)) {
104
+ return null;
105
+ }
106
+ const propertyValue = value[key];
107
+ return typeof propertyValue === "number" && Number.isFinite(propertyValue)
108
+ ? propertyValue
109
+ : null;
110
+ }
111
+ function getJsonParseMessagePosition(message) {
112
+ const marker = " at position ";
113
+ const markerIndex = message.indexOf(marker);
114
+ if (markerIndex === -1) {
115
+ return null;
116
+ }
117
+ const startIndex = markerIndex + marker.length;
118
+ let endIndex = startIndex;
119
+ while (endIndex < message.length && isAsciiDigit(message[endIndex] ?? "")) {
120
+ endIndex += 1;
121
+ }
122
+ if (endIndex === startIndex) {
123
+ return null;
124
+ }
125
+ return Number.parseInt(message.slice(startIndex, endIndex), 10);
126
+ }
127
+ function getSourceOffsetLocation(source, offset) {
128
+ let line = 1;
129
+ let column = 1;
130
+ const boundedOffset = Math.max(0, Math.floor(offset));
131
+ for (let index = 0; index < boundedOffset && index < source.length; index += 1) {
132
+ if (source[index] === "\n") {
133
+ line += 1;
134
+ column = 1;
135
+ continue;
136
+ }
137
+ column += 1;
138
+ }
139
+ return { line, column };
140
+ }
141
+ function isAsciiDigit(value) {
142
+ return value >= "0" && value <= "9";
143
+ }
144
+ function getErrorCode(error) {
145
+ if (typeof error === "object" &&
146
+ error !== null &&
147
+ "code" in error &&
148
+ typeof error.code === "string") {
149
+ return error.code;
150
+ }
151
+ return undefined;
152
+ }
@@ -72,7 +72,7 @@ export async function mockFetch(options) {
72
72
  }
73
73
  };
74
74
  }
75
- class MockFetchError extends Error {
75
+ class MockFetchError extends UserError {
76
76
  constructor(message) {
77
77
  super(message);
78
78
  this.name = "MockFetchError";
@@ -0,0 +1,2 @@
1
+ import { UserError } from "toolcraft";
2
+ export declare function classifyNetworkError(error: unknown, url: string): UserError | null;
@@ -0,0 +1,83 @@
1
+ import { UserError } from "toolcraft";
2
+ export function classifyNetworkError(error, url) {
3
+ const networkError = findNetworkError(error);
4
+ const urlParts = new URL(url);
5
+ const host = getHost(networkError, urlParts);
6
+ switch (networkError?.code) {
7
+ case "ECONNREFUSED":
8
+ return new UserError(`Connection refused: ${host}:${getPort(networkError, urlParts)}. Is the server running?`, { cause: error });
9
+ case "ETIMEDOUT":
10
+ return new UserError(`Request timed out after ${getTimeoutMs(networkError)}ms: ${url}.`, {
11
+ cause: error
12
+ });
13
+ case "ENOTFOUND":
14
+ return new UserError(`DNS lookup failed for ${host}. Check the URL or your network.`, {
15
+ cause: error
16
+ });
17
+ case "ECONNRESET":
18
+ return new UserError(`Connection reset by ${host}. Likely transient: try again.`, {
19
+ cause: error
20
+ });
21
+ case "EAI_AGAIN":
22
+ return new UserError(`Temporary DNS failure for ${host}. Network may be down.`, {
23
+ cause: error
24
+ });
25
+ }
26
+ if (findAbortError(error) !== null) {
27
+ return new UserError(`Request aborted: ${url}.`, { cause: error });
28
+ }
29
+ if (error instanceof TypeError && error.message === "fetch failed" && !hasCause(error)) {
30
+ return new UserError(`Network request failed: ${url}.`, { cause: error });
31
+ }
32
+ return null;
33
+ }
34
+ function findNetworkError(error) {
35
+ let current = error;
36
+ while (isErrorLikeObject(current)) {
37
+ if (typeof current.code === "string") {
38
+ return current;
39
+ }
40
+ current = current.cause;
41
+ }
42
+ return null;
43
+ }
44
+ function isAbortError(error) {
45
+ return isErrorLikeObject(error) && error.name === "AbortError";
46
+ }
47
+ function findAbortError(error) {
48
+ let current = error;
49
+ while (isErrorLikeObject(current)) {
50
+ if (isAbortError(current)) {
51
+ return current;
52
+ }
53
+ current = current.cause;
54
+ }
55
+ return null;
56
+ }
57
+ function hasCause(error) {
58
+ return "cause" in error && error.cause !== undefined;
59
+ }
60
+ function getHost(error, url) {
61
+ return typeof error?.address === "string" ? error.address : url.hostname;
62
+ }
63
+ function getPort(error, url) {
64
+ if (typeof error?.port === "number" || typeof error?.port === "string") {
65
+ return String(error.port);
66
+ }
67
+ if (url.port) {
68
+ return url.port;
69
+ }
70
+ return url.protocol === "https:" ? "443" : "80";
71
+ }
72
+ function getTimeoutMs(error) {
73
+ if (typeof error.ms === "number" || typeof error.ms === "string") {
74
+ return String(error.ms);
75
+ }
76
+ if (typeof error.timeout === "number" || typeof error.timeout === "string") {
77
+ return String(error.timeout);
78
+ }
79
+ return "unknown";
80
+ }
81
+ function isErrorLikeObject(value) {
82
+ return typeof value === "object" && value !== null;
83
+ }
@@ -2,6 +2,8 @@ import path from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { parse as parseYaml } from "yaml";
4
4
  import { UserError } from "toolcraft";
5
+ import { renderSourceSnippet } from "toolcraft/source-snippet";
6
+ import { classifyNetworkError } from "./network-error.js";
5
7
  export async function readOpenApiSourceText(input, services) {
6
8
  const inputUrl = input instanceof URL ? input : tryParseUrl(input);
7
9
  const sourceLabel = formatSourceLabel(input);
@@ -18,9 +20,21 @@ export async function readOpenApiSourceText(input, services) {
18
20
  if (inputUrl.protocol !== "http:" && inputUrl.protocol !== "https:") {
19
21
  throw new UserError(`Unsupported OpenAPI input URL protocol ${JSON.stringify(inputUrl.protocol)}.`);
20
22
  }
21
- const response = await services.fetch(inputUrl.toString());
23
+ let response;
24
+ try {
25
+ response = await services.fetch(inputUrl.toString());
26
+ }
27
+ catch (error) {
28
+ throw classifyNetworkError(error, inputUrl.toString()) ?? error;
29
+ }
22
30
  if (!response.ok) {
23
- throw new UserError(`Failed to fetch ${JSON.stringify(inputUrl.toString())}: ${response.status} ${response.statusText}`);
31
+ const contentType = response.headers.get("content-type") ?? "";
32
+ const text = await response.text().catch(() => "");
33
+ const snippet = text.length === 0 ? "" : `\n body: ${truncate(text, 500)}`;
34
+ throw new UserError(`Failed to fetch ${JSON.stringify(inputUrl.toString())}: ` +
35
+ `${response.status} ${response.statusText}` +
36
+ (contentType ? ` (content-type: ${contentType})` : "") +
37
+ snippet);
24
38
  }
25
39
  return await response.text();
26
40
  }
@@ -37,7 +51,7 @@ export function parseOpenApiDocument(sourceText, input) {
37
51
  parsed = parseYaml(sourceText);
38
52
  }
39
53
  catch (error) {
40
- throw new UserError(`Failed to parse OpenAPI document ${JSON.stringify(formatSourceLabel(input))}: ${getErrorMessage(error)}`);
54
+ throw new UserError(`Failed to parse OpenAPI document ${JSON.stringify(formatSourceLabel(input))}: ${formatParseErrorMessage(error, sourceText, formatSourceLabel(input))}`);
41
55
  }
42
56
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
43
57
  throw new UserError(`OpenAPI document ${JSON.stringify(formatSourceLabel(input))} must parse to an object.`);
@@ -61,3 +75,89 @@ function getErrorMessage(error) {
61
75
  }
62
76
  return String(error);
63
77
  }
78
+ function formatParseErrorMessage(error, sourceText, filePath) {
79
+ const message = getErrorMessage(error);
80
+ const linePosition = getYamlLinePosition(error) ?? getYamlOffsetPosition(error, sourceText);
81
+ if (linePosition === null) {
82
+ // yaml parse errors do not always expose positional metadata for every failure mode.
83
+ return message;
84
+ }
85
+ const positionText = `at line ${linePosition.line} column ${linePosition.column}`;
86
+ const messageWithPosition = message.includes(positionText)
87
+ ? message
88
+ : `${message} (${positionText})`;
89
+ return `${messageWithPosition}\n${renderSourceSnippet({
90
+ source: sourceText,
91
+ line: linePosition.line,
92
+ column: linePosition.column,
93
+ filePath
94
+ })}`;
95
+ }
96
+ function getYamlLinePosition(error) {
97
+ if (typeof error !== "object" || error === null || !("linePos" in error)) {
98
+ return null;
99
+ }
100
+ const linePos = error.linePos;
101
+ if (!Array.isArray(linePos) || linePos.length === 0) {
102
+ return null;
103
+ }
104
+ const firstPosition = linePos[0];
105
+ if (typeof firstPosition !== "object" || firstPosition === null) {
106
+ return null;
107
+ }
108
+ const line = "line" in firstPosition ? firstPosition.line : undefined;
109
+ const column = "col" in firstPosition ? firstPosition.col : undefined;
110
+ if (typeof line !== "number" || typeof column !== "number") {
111
+ return null;
112
+ }
113
+ return { line, column };
114
+ }
115
+ function getYamlOffsetPosition(error, sourceText) {
116
+ if (typeof error !== "object" || error === null || !("pos" in error)) {
117
+ return null;
118
+ }
119
+ const pos = error.pos;
120
+ if (!Array.isArray(pos) || typeof pos[0] !== "number" || pos[0] < 0) {
121
+ return null;
122
+ }
123
+ return getSourceTextPosition(sourceText, pos[0]);
124
+ }
125
+ function getSourceTextPosition(sourceText, offset) {
126
+ let line = 1;
127
+ let column = 1;
128
+ for (let index = 0; index < offset && index < sourceText.length; index += 1) {
129
+ if (sourceText[index] === "\n") {
130
+ line += 1;
131
+ column = 1;
132
+ continue;
133
+ }
134
+ column += 1;
135
+ }
136
+ return { line, column };
137
+ }
138
+ function truncate(value, maxLength) {
139
+ const collapsed = collapseToSingleLine(value);
140
+ if (collapsed.length <= maxLength) {
141
+ return collapsed;
142
+ }
143
+ return `${collapsed.slice(0, maxLength)}…`;
144
+ }
145
+ function collapseToSingleLine(value) {
146
+ const characters = [];
147
+ let previousWasWhitespace = false;
148
+ for (const character of value) {
149
+ if (isWhitespace(character)) {
150
+ if (!previousWasWhitespace) {
151
+ characters.push(" ");
152
+ previousWasWhitespace = true;
153
+ }
154
+ continue;
155
+ }
156
+ characters.push(character);
157
+ previousWasWhitespace = false;
158
+ }
159
+ return characters.join("").trim();
160
+ }
161
+ function isWhitespace(character) {
162
+ return character.trim().length === 0;
163
+ }