poe-code 4.0.15 → 4.0.17

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poe-code",
3
- "version": "4.0.15",
3
+ "version": "4.0.17",
4
4
  "description": "CLI tool to configure Poe API for developer workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,7 +9,6 @@ export declare function runPlanBrowser(options: {
9
9
  fs: DiscoveryFs & Partial<ActionFs>;
10
10
  kind?: PlanKind;
11
11
  variables?: Record<string, string | undefined>;
12
- onCreatePlan?: () => Promise<void>;
13
12
  runExplorerImpl?: RunExplorerImpl;
14
13
  }): Promise<void>;
15
14
  export {};
@@ -28,8 +28,7 @@ export async function runPlanBrowser(options) {
28
28
  plans,
29
29
  fs: options.fs,
30
30
  variables: options.variables ?? process.env,
31
- onRefresh: discover,
32
- onCreatePlan: options.onCreatePlan
31
+ onRefresh: discover
33
32
  });
34
33
  await (options.runExplorerImpl ?? runExplorer)(config);
35
34
  }
@@ -5,7 +5,6 @@ export interface BuildPlanExplorerConfigOptions {
5
5
  fs: ActionFs & DiscoveryFs;
6
6
  variables: Record<string, string | undefined>;
7
7
  onRefresh: () => Promise<PlanEntry[]>;
8
- onCreatePlan?: () => Promise<void>;
9
8
  promptSaveReason?: (entry: PlanEntry) => Promise<string | null>;
10
9
  loadDetailMarkdown?: (entry: PlanEntry, fs: ActionFs & DiscoveryFs) => Promise<string>;
11
10
  }
@@ -98,19 +98,6 @@ export function buildPlanExplorerConfig(options) {
98
98
  }
99
99
  }
100
100
  ];
101
- if (options.onCreatePlan !== undefined) {
102
- actions.push({
103
- id: "new",
104
- key: "n",
105
- primary: true,
106
- label: "New plan",
107
- predicate: () => options.onCreatePlan != null,
108
- handler: async (ctx) => {
109
- await ctx.suspendAnd(() => options.onCreatePlan());
110
- await ctx.refresh();
111
- }
112
- });
113
- }
114
101
  return {
115
102
  title: "Plans",
116
103
  rows: async () => rows,
@@ -14,6 +14,7 @@ export type { InspectOpenApiSourceOptions, OpenApiInspectionSource } from "./ins
14
14
  export { renderOpenApiInspection } from "./render-inspection.js";
15
15
  export { commandsFromSpec, defineClientFromSpec, resolveOpenApiBaseUrl } from "./runtime.js";
16
16
  export type { CommandsFromSpecOptions, DefineClientFromSpecOptions, OpenApiDocumentSource } from "./runtime.js";
17
+ export type { OpenApiSpecCacheFileSystem, OpenApiSpecCacheOptions, OpenApiTimeoutContext } from "./spec-cache.js";
17
18
  export type { DefineClientOptions, DefinedClient, OpenApiClientServices } from "./define-client.js";
18
19
  export type { AuthProvider, CommandContributor, TokenSource } from "./auth/types.js";
19
20
  export { bearerTokenAuth } from "./auth/bearer-token-auth.js";
@@ -24,6 +24,12 @@ export function classifyNetworkError(error, url) {
24
24
  return new UserError(`Temporary DNS failure for ${host}. Network may be down.`, {
25
25
  cause: error
26
26
  });
27
+ case "UND_ERR_SOCKET":
28
+ return new UserError(`Network connection failed: ${redactedUrl}.`, { cause: error });
29
+ case "UND_ERR_CONNECT_TIMEOUT":
30
+ case "UND_ERR_HEADERS_TIMEOUT":
31
+ case "UND_ERR_BODY_TIMEOUT":
32
+ return new UserError(`Request timed out: ${redactedUrl}.`, { cause: error });
27
33
  }
28
34
  if (findAbortError(error) !== null) {
29
35
  return new UserError(`Request aborted: ${redactedUrl}.`, { cause: error });
@@ -2,12 +2,15 @@ import { type CommandNode } from "../../toolcraft/dist/index.js";
2
2
  import { type DefineClientOptions, type DefinedClient, type OpenApiClientServices } from "./define-client.js";
3
3
  import { type OpenApiDocument } from "./generate.js";
4
4
  import type { ToolcraftConfig } from "./config.js";
5
- import { type OpenApiSourceFileSystem } from "./spec-source.js";
5
+ import { type OpenApiSpecCacheFileSystem, type OpenApiSpecCacheOptions, type OpenApiTimeoutContext } from "./spec-cache.js";
6
6
  export type OpenApiDocumentSource = OpenApiDocument | string | URL;
7
7
  export interface CommandsFromSpecOptions {
8
8
  cwd?: string;
9
9
  fetch?: typeof globalThis.fetch;
10
- fs?: OpenApiSourceFileSystem;
10
+ fs?: OpenApiSpecCacheFileSystem;
11
+ cache?: false | OpenApiSpecCacheOptions;
12
+ onTimeout?: (context: OpenApiTimeoutContext) => void | Promise<void>;
13
+ timeoutMs?: number;
11
14
  config?: ToolcraftConfig;
12
15
  }
13
16
  export type DefineClientFromSpecOptions<TServices extends object = Record<string, never>> = Omit<DefineClientOptions<TServices>, "baseUrl" | "commands"> & CommandsFromSpecOptions & {
@@ -5,15 +5,27 @@ import { collectTagDescriptions, collectSchemaOptionEntries, collectGeneratedCom
5
5
  import { groupByNoun } from "./group-by-noun.js";
6
6
  import { prepareMultipartFileInputs, requestJson, writeBinaryResponseOutput } from "./http.js";
7
7
  import { buildRequestShape, executePreflightBlocks } from "./interpreter.js";
8
+ import { DEFAULT_OPENAPI_FETCH_TIMEOUT_MS, loadCachedOpenApiSource } from "./spec-cache.js";
8
9
  import { parseOpenApiDocument, readOpenApiSourceText } from "./spec-source.js";
9
10
  const RUNTIME_COMMAND_SCOPE = ["cli", "mcp", "sdk"];
10
11
  export async function commandsFromSpec(source, options = {}) {
11
- const document = await resolveDocument(source, options);
12
- return createRuntimeNodes(document, options.config);
12
+ const resolved = await resolveDocument(source, options);
13
+ const commands = createRuntimeNodes(resolved.document, options.config);
14
+ await resolved.commit?.();
15
+ return commands;
13
16
  }
14
17
  export async function defineClientFromSpec(spec, options) {
15
- const { cwd, fetch, fs: specFs, config, baseUrl, environment, env, ...clientOptions } = options;
16
- const document = await resolveDocument(spec, { cwd, fetch, fs: specFs, config });
18
+ const { cwd, fetch, fs: specFs, cache, onTimeout, timeoutMs, config, baseUrl, environment, env, ...clientOptions } = options;
19
+ const resolved = await resolveDocument(spec, {
20
+ cwd,
21
+ fetch,
22
+ fs: specFs,
23
+ cache,
24
+ onTimeout,
25
+ timeoutMs,
26
+ config
27
+ });
28
+ const document = resolved.document;
17
29
  const resolvedBaseUrl = baseUrl ??
18
30
  resolveOpenApiBaseUrl({
19
31
  document,
@@ -25,18 +37,51 @@ export async function defineClientFromSpec(spec, options) {
25
37
  throw new UserError("defineClientFromSpec could not resolve a base URL. Pass baseUrl, configure an environment, or define OpenAPI servers[0].url.");
26
38
  }
27
39
  const commands = createRuntimeNodes(document, config);
28
- return defineClient({ ...clientOptions, baseUrl: resolvedBaseUrl, commands });
40
+ const client = defineClient({ ...clientOptions, baseUrl: resolvedBaseUrl, commands });
41
+ await resolved.commit?.();
42
+ return client;
29
43
  }
30
44
  async function resolveDocument(source, options) {
31
45
  if (typeof source !== "string" && !(source instanceof URL)) {
32
- return source;
46
+ return { document: source };
47
+ }
48
+ const sourceUrl = source instanceof URL ? source : tryParseUrl(source);
49
+ if (sourceUrl !== null && (sourceUrl.protocol === "http:" || sourceUrl.protocol === "https:")) {
50
+ const loaded = await loadCachedOpenApiSource(sourceUrl, {
51
+ cache: options.cache ?? resolveDefaultCache(options),
52
+ fetch: options.fetch ?? globalThis.fetch,
53
+ fs: options.fs ?? fs,
54
+ onTimeout: options.onTimeout,
55
+ timeoutMs: options.timeoutMs ?? DEFAULT_OPENAPI_FETCH_TIMEOUT_MS
56
+ });
57
+ return {
58
+ document: loaded.document,
59
+ ...(loaded.commit === undefined ? {} : { commit: loaded.commit })
60
+ };
33
61
  }
34
62
  const sourceText = await readOpenApiSourceText(source, {
35
63
  cwd: options.cwd ?? process.cwd(),
36
64
  fetch: options.fetch ?? globalThis.fetch,
37
65
  fs: options.fs ?? fs
38
66
  });
39
- return parseOpenApiDocument(sourceText, source);
67
+ return { document: parseOpenApiDocument(sourceText, source) };
68
+ }
69
+ function resolveDefaultCache(options) {
70
+ if (options.fetch !== undefined) {
71
+ return false;
72
+ }
73
+ const configured = Object.prototype.hasOwnProperty.call(process.env, "TOOLCRAFT_OPENAPI_CACHE")
74
+ ? process.env.TOOLCRAFT_OPENAPI_CACHE?.trim().toLowerCase()
75
+ : undefined;
76
+ return configured === "0" || configured === "false" ? false : {};
77
+ }
78
+ function tryParseUrl(value) {
79
+ try {
80
+ return new URL(value);
81
+ }
82
+ catch {
83
+ return null;
84
+ }
40
85
  }
41
86
  export function resolveOpenApiBaseUrl(options) {
42
87
  const environments = options.environments;
@@ -0,0 +1,41 @@
1
+ import { type OpenApiSourceFileSystem } from "./spec-source.js";
2
+ import type { OpenApiDocument } from "./generate.js";
3
+ export declare const DEFAULT_OPENAPI_FETCH_TIMEOUT_MS = 3000;
4
+ export declare const DEFAULT_OPENAPI_CACHE_MAX_AGE_MS: number;
5
+ export interface OpenApiSpecCacheOptions {
6
+ directory?: string;
7
+ maxAgeMs?: number;
8
+ onFallback?: (message: string) => void | Promise<void>;
9
+ }
10
+ export interface LoadCachedOpenApiSourceOptions {
11
+ cache: false | OpenApiSpecCacheOptions;
12
+ fetch: typeof globalThis.fetch;
13
+ fs: OpenApiSpecCacheFileSystem;
14
+ onTimeout?: (context: OpenApiTimeoutContext) => void | Promise<void>;
15
+ timeoutMs?: number;
16
+ }
17
+ export interface OpenApiTimeoutContext {
18
+ source: string;
19
+ timeoutMs: number;
20
+ usingCachedDocument: boolean;
21
+ }
22
+ export interface OpenApiSpecCacheFileSystem extends OpenApiSourceFileSystem {
23
+ writeFile?(filePath: string, contents: string, options?: {
24
+ encoding?: BufferEncoding;
25
+ flag?: string;
26
+ mode?: number;
27
+ }): Promise<void>;
28
+ rename?(fromPath: string, toPath: string): Promise<void>;
29
+ mkdir?(directoryPath: string, options?: {
30
+ recursive?: boolean;
31
+ mode?: number;
32
+ }): Promise<unknown>;
33
+ unlink?(filePath: string): Promise<void>;
34
+ realpath?(filePath: string): Promise<string>;
35
+ }
36
+ export interface LoadedOpenApiSource {
37
+ sourceText: string;
38
+ document: OpenApiDocument;
39
+ commit?: () => Promise<void>;
40
+ }
41
+ export declare function loadCachedOpenApiSource(inputUrl: URL, options: LoadCachedOpenApiSourceOptions): Promise<LoadedOpenApiSource>;
@@ -0,0 +1,376 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { UserError } from "toolcraft";
5
+ import { hasOwnErrorCode } from "./error-codes.js";
6
+ import { fetchOpenApiHttpSource, OpenApiTimeoutError, OpenApiTransportError, parseOpenApiDocument } from "./spec-source.js";
7
+ import { redactSensitiveQueryValues } from "./redaction.js";
8
+ export const DEFAULT_OPENAPI_FETCH_TIMEOUT_MS = 3_000;
9
+ export const DEFAULT_OPENAPI_CACHE_MAX_AGE_MS = 5 * 60_000;
10
+ export async function loadCachedOpenApiSource(inputUrl, options) {
11
+ validateTimeout(options.timeoutMs);
12
+ if (options.cache === false) {
13
+ let result;
14
+ try {
15
+ result = await fetchOpenApiHttpSource(inputUrl, options.fetch, {
16
+ timeoutMs: options.timeoutMs
17
+ });
18
+ }
19
+ catch (error) {
20
+ notifyTimeout(options, inputUrl, error, false);
21
+ throw error;
22
+ }
23
+ if (result.status === "not-modified") {
24
+ throw new UserError(`Failed to fetch ${JSON.stringify(inputUrl.toString())}: received 304 without a cached document.`);
25
+ }
26
+ return {
27
+ sourceText: result.sourceText,
28
+ document: parseOpenApiDocument(result.sourceText, inputUrl)
29
+ };
30
+ }
31
+ validateMaxAge(options.cache.maxAgeMs);
32
+ const directory = resolveCacheDirectory(options.cache.directory);
33
+ const cachePath = resolveCachePath(inputUrl, directory);
34
+ const cached = await readCacheEntry(cachePath, options.fs);
35
+ const now = Date.now();
36
+ const freshnessMs = cached?.entry.maxAgeMs;
37
+ if (cached !== null &&
38
+ cached.entry.validatedAt <= now &&
39
+ freshnessMs !== undefined &&
40
+ now - cached.entry.validatedAt < freshnessMs) {
41
+ return {
42
+ sourceText: cached.entry.sourceText,
43
+ document: cached.document
44
+ };
45
+ }
46
+ let result;
47
+ try {
48
+ result = await fetchOpenApiHttpSource(inputUrl, options.fetch, {
49
+ ...(cached?.entry.etag === undefined ? {} : { etag: cached.entry.etag }),
50
+ timeoutMs: options.timeoutMs
51
+ });
52
+ }
53
+ catch (error) {
54
+ notifyTimeout(options, inputUrl, error, cached !== null);
55
+ if (!(error instanceof OpenApiTransportError) || cached === null) {
56
+ throw error;
57
+ }
58
+ notifyFallback(options.cache, inputUrl, error);
59
+ return {
60
+ sourceText: cached.entry.sourceText,
61
+ document: cached.document
62
+ };
63
+ }
64
+ const writableFs = getWritableFileSystem(options.fs);
65
+ if (result.status === "not-modified") {
66
+ if (cached === null) {
67
+ throw new UserError(`Failed to fetch ${JSON.stringify(inputUrl.toString())}: received 304 without a cached document.`);
68
+ }
69
+ if (cached.entry.etag === undefined) {
70
+ throw new UserError(`Failed to fetch ${JSON.stringify(inputUrl.toString())}: received 304 without a cached validator.`);
71
+ }
72
+ const policy = result.cacheControl === undefined
73
+ ? {
74
+ store: true,
75
+ maxAgeMs: Math.max(0, cached.entry.maxAgeMs - (parseDeltaSeconds(result.age ?? "") ?? 0) * 1_000)
76
+ }
77
+ : resolveCachePolicy(result.cacheControl, result.age, options.cache.maxAgeMs, cached.entry.maxAgeMs);
78
+ if (!policy.store) {
79
+ return {
80
+ sourceText: cached.entry.sourceText,
81
+ document: cached.document,
82
+ ...(writableFs === null ? {} : { commit: () => removeCacheEntry(cachePath, writableFs) })
83
+ };
84
+ }
85
+ const entry = {
86
+ ...cached.entry,
87
+ validatedAt: now,
88
+ maxAgeMs: policy.maxAgeMs,
89
+ ...(result.etag === undefined ? {} : { etag: result.etag })
90
+ };
91
+ return {
92
+ sourceText: cached.entry.sourceText,
93
+ document: cached.document,
94
+ ...(writableFs === null
95
+ ? {}
96
+ : { commit: () => persistCacheEntry(cachePath, entry, writableFs) })
97
+ };
98
+ }
99
+ const policy = resolveCachePolicy(result.cacheControl, result.age, options.cache.maxAgeMs, DEFAULT_OPENAPI_CACHE_MAX_AGE_MS);
100
+ const document = parseOpenApiDocument(result.sourceText, inputUrl);
101
+ if (!policy.store) {
102
+ return {
103
+ sourceText: result.sourceText,
104
+ document,
105
+ ...(writableFs === null ? {} : { commit: () => removeCacheEntry(cachePath, writableFs) })
106
+ };
107
+ }
108
+ const entry = {
109
+ version: 1,
110
+ sourceText: result.sourceText,
111
+ validatedAt: now,
112
+ maxAgeMs: policy.maxAgeMs,
113
+ ...(result.etag === undefined ? {} : { etag: result.etag })
114
+ };
115
+ return {
116
+ sourceText: result.sourceText,
117
+ document,
118
+ ...(writableFs === null
119
+ ? {}
120
+ : { commit: () => persistCacheEntry(cachePath, entry, writableFs) })
121
+ };
122
+ }
123
+ function notifyTimeout(options, inputUrl, error, usingCachedDocument) {
124
+ if (!(error instanceof OpenApiTimeoutError) || options.onTimeout === undefined) {
125
+ return;
126
+ }
127
+ try {
128
+ void Promise.resolve(options.onTimeout({
129
+ source: redactSensitiveQueryValues(inputUrl.toString()),
130
+ timeoutMs: error.timeoutMs,
131
+ usingCachedDocument
132
+ })).catch(() => undefined);
133
+ }
134
+ catch {
135
+ // A presentation hook cannot change source-loading behavior.
136
+ }
137
+ }
138
+ function resolveCachePolicy(cacheControl, age, configuredMaxAgeMs, fallbackMaxAgeMs) {
139
+ const directives = (cacheControl ?? "")
140
+ .split(",")
141
+ .map((directive) => directive.trim().toLowerCase());
142
+ if (directives.includes("no-store")) {
143
+ return { store: false, maxAgeMs: 0 };
144
+ }
145
+ if (directives.includes("no-cache")) {
146
+ return { store: true, maxAgeMs: 0 };
147
+ }
148
+ for (const directive of directives) {
149
+ if (!directive.startsWith("max-age=")) {
150
+ continue;
151
+ }
152
+ const seconds = parseDeltaSeconds(directive.slice("max-age=".length));
153
+ if (seconds !== null) {
154
+ const responseAge = parseDeltaSeconds(age ?? "") ?? 0;
155
+ return { store: true, maxAgeMs: Math.max(0, seconds - responseAge) * 1_000 };
156
+ }
157
+ }
158
+ return {
159
+ store: true,
160
+ maxAgeMs: configuredMaxAgeMs ?? fallbackMaxAgeMs
161
+ };
162
+ }
163
+ function parseDeltaSeconds(value) {
164
+ const trimmed = value.trim();
165
+ const unquoted = trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')
166
+ ? trimmed.slice(1, -1)
167
+ : trimmed;
168
+ if (unquoted.length === 0) {
169
+ return null;
170
+ }
171
+ const seconds = Number(unquoted);
172
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
173
+ }
174
+ function resolveCacheDirectory(configuredDirectory) {
175
+ const envDirectory = readOwnEnvValue(process.env, "TOOLCRAFT_OPENAPI_CACHE_DIR");
176
+ const xdgCacheHome = readOwnEnvValue(process.env, "XDG_CACHE_HOME");
177
+ const directory = configuredDirectory ?? envDirectory;
178
+ if (directory !== undefined) {
179
+ if (!path.isAbsolute(directory)) {
180
+ throw new UserError("OpenAPI cache directory must be an absolute path.");
181
+ }
182
+ return directory;
183
+ }
184
+ const cacheRoot = xdgCacheHome ?? path.join(os.homedir(), ".cache");
185
+ if (!path.isAbsolute(cacheRoot)) {
186
+ throw new UserError("XDG_CACHE_HOME must be an absolute path.");
187
+ }
188
+ return path.join(cacheRoot, "toolcraft-openapi", "specs");
189
+ }
190
+ function resolveCachePath(inputUrl, directory) {
191
+ const canonicalUrl = new URL(inputUrl);
192
+ canonicalUrl.hash = "";
193
+ const key = createHash("sha256").update(canonicalUrl.toString()).digest("hex");
194
+ return path.join(directory, `${key}.json`);
195
+ }
196
+ async function readCacheEntry(cachePath, fs) {
197
+ if (!hasRealpath(fs)) {
198
+ return null;
199
+ }
200
+ try {
201
+ await assertSafeCachePath(cachePath, fs);
202
+ const parsed = JSON.parse(await fs.readFile(cachePath, "utf8"));
203
+ if (!isCacheEntry(parsed)) {
204
+ return null;
205
+ }
206
+ return {
207
+ entry: sanitizeCacheEntry(parsed),
208
+ document: parseOpenApiDocument(parsed.sourceText, cachePath)
209
+ };
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ }
215
+ function sanitizeCacheEntry(entry) {
216
+ if (entry.etag === undefined || isValidEtag(entry.etag)) {
217
+ return entry;
218
+ }
219
+ const withoutEtag = { ...entry };
220
+ delete withoutEtag.etag;
221
+ return withoutEtag;
222
+ }
223
+ function isValidEtag(etag) {
224
+ try {
225
+ new Headers({ "If-None-Match": etag });
226
+ return true;
227
+ }
228
+ catch {
229
+ return false;
230
+ }
231
+ }
232
+ async function persistCacheEntry(cachePath, entry, fs) {
233
+ try {
234
+ await assertSafeCachePath(cachePath, fs);
235
+ await fs.mkdir(path.dirname(cachePath), { recursive: true, mode: 0o700 });
236
+ await assertSafeCachePath(cachePath, fs);
237
+ for (let attempt = 0; attempt < 3; attempt += 1) {
238
+ const temporaryPath = `${cachePath}.${randomUUID()}.tmp`;
239
+ let temporaryCreated = false;
240
+ try {
241
+ await fs.writeFile(temporaryPath, JSON.stringify(entry), {
242
+ encoding: "utf8",
243
+ flag: "wx",
244
+ mode: 0o600
245
+ });
246
+ temporaryCreated = true;
247
+ await fs.rename(temporaryPath, cachePath);
248
+ return;
249
+ }
250
+ catch (error) {
251
+ if (temporaryCreated || !hasOwnErrorCode(error, "EEXIST")) {
252
+ await fs.unlink(temporaryPath).catch(() => undefined);
253
+ }
254
+ if (!hasOwnErrorCode(error, "EEXIST")) {
255
+ return;
256
+ }
257
+ }
258
+ }
259
+ }
260
+ catch {
261
+ // Cache writes are best-effort; the materialized live document remains usable.
262
+ }
263
+ }
264
+ async function removeCacheEntry(cachePath, fs) {
265
+ try {
266
+ await assertSafeCachePath(cachePath, fs);
267
+ await fs.unlink(cachePath);
268
+ }
269
+ catch {
270
+ // Cache removal is best-effort; the materialized live document remains usable.
271
+ }
272
+ }
273
+ function getWritableFileSystem(fs) {
274
+ if (typeof fs.writeFile !== "function" ||
275
+ typeof fs.rename !== "function" ||
276
+ typeof fs.mkdir !== "function" ||
277
+ typeof fs.unlink !== "function" ||
278
+ typeof fs.realpath !== "function") {
279
+ return null;
280
+ }
281
+ return fs;
282
+ }
283
+ function hasRealpath(fs) {
284
+ return typeof fs.realpath === "function";
285
+ }
286
+ async function assertSafeCachePath(cachePath, fs) {
287
+ const directory = path.resolve(path.dirname(cachePath));
288
+ let existingPath = directory;
289
+ while (true) {
290
+ try {
291
+ if (path.resolve(await fs.realpath(existingPath)) !== existingPath) {
292
+ throw new UserError("OpenAPI cache path must remain inside its configured directory.");
293
+ }
294
+ break;
295
+ }
296
+ catch (error) {
297
+ if (!hasOwnErrorCode(error, "ENOENT")) {
298
+ throw error;
299
+ }
300
+ const parentPath = path.dirname(existingPath);
301
+ if (parentPath === existingPath) {
302
+ throw error;
303
+ }
304
+ existingPath = parentPath;
305
+ }
306
+ }
307
+ try {
308
+ if (path.resolve(await fs.realpath(directory)) !== directory) {
309
+ throw new UserError("OpenAPI cache path must remain inside its configured directory.");
310
+ }
311
+ }
312
+ catch (error) {
313
+ if (hasOwnErrorCode(error, "ENOENT")) {
314
+ return;
315
+ }
316
+ throw error;
317
+ }
318
+ try {
319
+ const canonicalCachePath = path.resolve(await fs.realpath(cachePath));
320
+ if (canonicalCachePath !== path.resolve(cachePath)) {
321
+ throw new UserError("OpenAPI cache path must remain inside its configured directory.");
322
+ }
323
+ }
324
+ catch (error) {
325
+ if (!hasOwnErrorCode(error, "ENOENT")) {
326
+ throw error;
327
+ }
328
+ }
329
+ }
330
+ function isCacheEntry(value) {
331
+ if (typeof value !== "object" || value === null) {
332
+ return false;
333
+ }
334
+ const entry = value;
335
+ return (Object.hasOwn(entry, "version") &&
336
+ entry.version === 1 &&
337
+ Object.hasOwn(entry, "sourceText") &&
338
+ typeof entry.sourceText === "string" &&
339
+ Object.hasOwn(entry, "validatedAt") &&
340
+ typeof entry.validatedAt === "number" &&
341
+ Number.isFinite(entry.validatedAt) &&
342
+ Object.hasOwn(entry, "maxAgeMs") &&
343
+ typeof entry.maxAgeMs === "number" &&
344
+ Number.isFinite(entry.maxAgeMs) &&
345
+ entry.maxAgeMs >= 0 &&
346
+ (!Object.hasOwn(entry, "etag") || typeof entry.etag === "string"));
347
+ }
348
+ function notifyFallback(cache, inputUrl, error) {
349
+ if (cache.onFallback === undefined) {
350
+ return;
351
+ }
352
+ const message = error instanceof Error ? error.message : String(error);
353
+ try {
354
+ void Promise.resolve(cache.onFallback(`Using cached OpenAPI document for ${JSON.stringify(redactSensitiveQueryValues(inputUrl.toString()))} because refresh failed: ${message}`)).catch(() => undefined);
355
+ }
356
+ catch {
357
+ // Cache fallback remains usable even if a presentation callback fails.
358
+ }
359
+ }
360
+ function validateTimeout(timeoutMs) {
361
+ if (timeoutMs !== undefined && (!Number.isFinite(timeoutMs) || timeoutMs < 0)) {
362
+ throw new UserError("OpenAPI fetch timeout must be a finite non-negative number.");
363
+ }
364
+ }
365
+ function validateMaxAge(maxAgeMs) {
366
+ if (maxAgeMs !== undefined && (!Number.isFinite(maxAgeMs) || maxAgeMs < 0)) {
367
+ throw new UserError("OpenAPI cache maxAgeMs must be a finite non-negative number.");
368
+ }
369
+ }
370
+ function readOwnEnvValue(env, key) {
371
+ if (!Object.prototype.hasOwnProperty.call(env, key)) {
372
+ return undefined;
373
+ }
374
+ const value = env[key]?.trim();
375
+ return value === undefined || value.length === 0 ? undefined : value;
376
+ }
@@ -1,3 +1,4 @@
1
+ import { UserError } from "../../toolcraft/dist/index.js";
1
2
  import type { OpenApiDocument } from "./generate.js";
2
3
  export interface OpenApiSourceFileSystem {
3
4
  readFile(filePath: string, encoding: BufferEncoding): Promise<string>;
@@ -7,5 +8,30 @@ export interface OpenApiSourceServices {
7
8
  fetch: typeof globalThis.fetch;
8
9
  fs: OpenApiSourceFileSystem;
9
10
  }
11
+ export interface OpenApiHttpSourceOptions {
12
+ etag?: string;
13
+ timeoutMs?: number;
14
+ }
15
+ export type OpenApiHttpSourceResult = {
16
+ status: "modified";
17
+ sourceText: string;
18
+ etag?: string;
19
+ cacheControl?: string;
20
+ age?: string;
21
+ } | {
22
+ status: "not-modified";
23
+ etag?: string;
24
+ cacheControl?: string;
25
+ age?: string;
26
+ };
27
+ export declare class OpenApiHttpStatusError extends UserError {
28
+ }
29
+ export declare class OpenApiTransportError extends UserError {
30
+ }
31
+ export declare class OpenApiTimeoutError extends OpenApiTransportError {
32
+ readonly timeoutMs: number;
33
+ constructor(message: string, timeoutMs: number, options?: ErrorOptions);
34
+ }
10
35
  export declare function readOpenApiSourceText(input: string | URL, services: OpenApiSourceServices): Promise<string>;
36
+ export declare function fetchOpenApiHttpSource(inputUrl: URL, fetch: typeof globalThis.fetch, options?: OpenApiHttpSourceOptions): Promise<OpenApiHttpSourceResult>;
11
37
  export declare function parseOpenApiDocument(sourceText: string, input: string | URL): OpenApiDocument;