api-spec-cli 0.2.4 → 0.2.5

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/src/glob.js CHANGED
@@ -1,34 +1,34 @@
1
- function globToRegex(pattern) {
2
- return new RegExp(
3
- "^" +
4
- pattern
5
- .replace(/[.+^${}()|[\]\\]/g, "\\$&")
6
- .replace(/\*/g, ".*")
7
- .replace(/\?/g, ".") +
8
- "$",
9
- "i"
10
- );
11
- }
12
-
13
- /**
14
- * Search match: plain text = substring, glob chars (* ?) = anchored glob.
15
- * Used by grep — broad matching is desirable for search.
16
- */
17
- export function matchGlob(pattern, str) {
18
- if (!pattern.includes("*") && !pattern.includes("?")) {
19
- return str.toLowerCase().includes(pattern.toLowerCase());
20
- }
21
- return globToRegex(pattern).test(str);
22
- }
23
-
24
- /**
25
- * Filter match: plain text = exact (case-insensitive), glob chars (* ?) = anchored glob.
26
- * Used by --allow-tool / --disable-tool — precision is required for whitelists.
27
- * Use *pattern* for explicit substring matching.
28
- */
29
- export function matchFilter(pattern, str) {
30
- if (!pattern.includes("*") && !pattern.includes("?")) {
31
- return str.toLowerCase() === pattern.toLowerCase();
32
- }
33
- return globToRegex(pattern).test(str);
34
- }
1
+ function globToRegex(pattern) {
2
+ return new RegExp(
3
+ "^" +
4
+ pattern
5
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
6
+ .replace(/\*/g, ".*")
7
+ .replace(/\?/g, ".") +
8
+ "$",
9
+ "i"
10
+ );
11
+ }
12
+
13
+ /**
14
+ * Search match: plain text = substring, glob chars (* ?) = anchored glob.
15
+ * Used by grep — broad matching is desirable for search.
16
+ */
17
+ export function matchGlob(pattern, str) {
18
+ if (!pattern.includes("*") && !pattern.includes("?")) {
19
+ return str.toLowerCase().includes(pattern.toLowerCase());
20
+ }
21
+ return globToRegex(pattern).test(str);
22
+ }
23
+
24
+ /**
25
+ * Filter match: plain text = exact (case-insensitive), glob chars (* ?) = anchored glob.
26
+ * Used by --allow-tool / --disable-tool — precision is required for whitelists.
27
+ * Use *pattern* for explicit substring matching.
28
+ */
29
+ export function matchFilter(pattern, str) {
30
+ if (!pattern.includes("*") && !pattern.includes("?")) {
31
+ return str.toLowerCase() === pattern.toLowerCase();
32
+ }
33
+ return globToRegex(pattern).test(str);
34
+ }
package/src/mcp-client.js CHANGED
@@ -4,17 +4,20 @@ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4
4
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5
5
  import { SpecCliOAuthProvider } from "./oauth/provider.js";
6
6
  import { ClientCredentialsProvider } from "@modelcontextprotocol/sdk/client/auth-extensions.js";
7
- import { loadTokenFile } from "./oauth/tokens.js";
7
+ import { getClientSecret } from "./oauth/tokens.js";
8
+ import {
9
+ expandSecrets,
10
+ expandSecretsMap,
11
+ envHeaderOverrides,
12
+ envUrlOverride,
13
+ mergeHeaders,
14
+ } from "./secrets.js";
8
15
 
9
16
  const MAX_RETRIES = parseInt(process.env.MCP_MAX_RETRIES ?? "3");
10
17
  const RETRY_DELAY = parseInt(process.env.MCP_RETRY_DELAY ?? "1000");
11
18
 
12
- // Expand ${VAR} placeholders from process.env at call time
13
19
  function expandEnv(val) {
14
- return val.replace(/\$\{([^}]+)\}/g, (_, name) => {
15
- if (!(name in process.env)) throw new Error(`Environment variable not set: ${name}`);
16
- return process.env[name];
17
- });
20
+ return expandSecrets(val);
18
21
  }
19
22
 
20
23
  async function connect(spec) {
@@ -33,36 +36,36 @@ async function connect(spec) {
33
36
  cwd: spec.cwd,
34
37
  });
35
38
  } else if (spec.type === "sse") {
36
- const h = spec.headers;
39
+ const h = expandSecretsMap(mergeHeaders(spec.headers, envHeaderOverrides()));
40
+ const url = envUrlOverride() ?? spec.url;
37
41
  let authProvider;
38
- // spec.name is only set for registry entries; inline connections (--mcp-sse <url>)
39
- // have no token storage location, so no OAuth provider is created for them.
40
- if (spec.name && !h?.Authorization) {
41
- const clientSecret = loadTokenFile(spec.name).clientSecret;
42
+ const hasAuthSse = Object.keys(h).some((k) => k.toLowerCase() === "authorization");
43
+ if (spec.name && !hasAuthSse) {
44
+ const clientSecret = getClientSecret(spec.name);
42
45
  authProvider =
43
46
  spec.oauthFlow === "client_credentials" && spec.oauthClientId && clientSecret
44
47
  ? new ClientCredentialsProvider({ clientId: spec.oauthClientId, clientSecret })
45
48
  : new SpecCliOAuthProvider(spec.name, spec);
46
49
  }
47
- transport = new SSEClientTransport(new URL(spec.url), {
50
+ transport = new SSEClientTransport(new URL(url), {
48
51
  authProvider,
49
- requestInit: h && Object.keys(h).length > 0 ? { headers: h } : undefined,
52
+ requestInit: Object.keys(h).length > 0 ? { headers: h } : undefined,
50
53
  });
51
54
  } else if (spec.type === "http") {
52
- const h = spec.headers;
55
+ const h = expandSecretsMap(mergeHeaders(spec.headers, envHeaderOverrides()));
56
+ const url = envUrlOverride() ?? spec.url;
53
57
  let authProvider;
54
- // spec.name is only set for registry entries; inline connections (--mcp-http <url>)
55
- // have no token storage location, so no OAuth provider is created for them.
56
- if (spec.name && !h?.Authorization) {
57
- const clientSecret = loadTokenFile(spec.name).clientSecret;
58
+ const hasAuthHttp = Object.keys(h).some((k) => k.toLowerCase() === "authorization");
59
+ if (spec.name && !hasAuthHttp) {
60
+ const clientSecret = getClientSecret(spec.name);
58
61
  authProvider =
59
62
  spec.oauthFlow === "client_credentials" && spec.oauthClientId && clientSecret
60
63
  ? new ClientCredentialsProvider({ clientId: spec.oauthClientId, clientSecret })
61
64
  : new SpecCliOAuthProvider(spec.name, spec);
62
65
  }
63
- transport = new StreamableHTTPClientTransport(new URL(spec.url), {
66
+ transport = new StreamableHTTPClientTransport(new URL(url), {
64
67
  authProvider,
65
- requestInit: h && Object.keys(h).length > 0 ? { headers: h } : undefined,
68
+ requestInit: Object.keys(h).length > 0 ? { headers: h } : undefined,
66
69
  });
67
70
  } else {
68
71
  throw new Error(`Unknown MCP type: ${spec.type}. Supported: stdio, sse, http`);
@@ -4,7 +4,7 @@ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4
4
  import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
5
5
  import { ClientCredentialsProvider } from "@modelcontextprotocol/sdk/client/auth-extensions.js";
6
6
  import { SpecCliOAuthProvider } from "./provider.js";
7
- import { loadTokenFile } from "./tokens.js";
7
+ import { getClientSecret } from "./tokens.js";
8
8
 
9
9
  /**
10
10
  * Run the full OAuth flow for a named MCP HTTP/SSE entry.
@@ -14,7 +14,7 @@ import { loadTokenFile } from "./tokens.js";
14
14
  */
15
15
  export async function runOAuthFlow(name, entry) {
16
16
  const TransportClass = entry.type === "sse" ? SSEClientTransport : StreamableHTTPClientTransport;
17
- const clientSecret = loadTokenFile(name).clientSecret;
17
+ const clientSecret = getClientSecret(name);
18
18
 
19
19
  // Only use client credentials grant when explicitly requested.
20
20
  // Having a clientSecret does NOT imply client_credentials — for most OAuth apps
@@ -1,7 +1,7 @@
1
1
  import { createServer } from "http";
2
2
  import { exec } from "child_process";
3
3
  import { randomUUID } from "crypto";
4
- import { loadTokenFile, saveTokenFile } from "./tokens.js";
4
+ import { loadTokenFile, saveTokenFile, getClientSecret } from "./tokens.js";
5
5
 
6
6
  function openBrowser(url) {
7
7
  const cmd =
@@ -58,8 +58,7 @@ export class SpecCliOAuthProvider {
58
58
 
59
59
  get redirectUrl() {
60
60
  if (this.#flow === "device") return undefined;
61
- if (!this.#redirectPort) throw new Error("Call prepareRedirect() before accessing redirectUrl");
62
- return `http://127.0.0.1:${this.#redirectPort}/callback`;
61
+ return `http://127.0.0.1:${this.#redirectPort || 0}/callback`;
63
62
  }
64
63
 
65
64
  get clientMetadata() {
@@ -91,7 +90,7 @@ export class SpecCliOAuthProvider {
91
90
  const stored = loadTokenFile(this.#name).clientInfo;
92
91
  if (stored) return stored;
93
92
  if (this.#clientId) {
94
- const clientSecret = loadTokenFile(this.#name).clientSecret;
93
+ const clientSecret = getClientSecret(this.#name);
95
94
  return clientSecret
96
95
  ? { client_id: this.#clientId, client_secret: clientSecret }
97
96
  : { client_id: this.#clientId };
@@ -1,6 +1,7 @@
1
1
  import { homedir } from "os";
2
2
  import { join } from "path";
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "fs";
4
+ import { expandSecrets } from "../secrets.js";
4
5
 
5
6
  let TOKEN_DIR = join(homedir(), "spec-cli-config", "tokens");
6
7
 
@@ -12,6 +13,11 @@ function tokenPath(name) {
12
13
  return join(TOKEN_DIR, `${name}.json`);
13
14
  }
14
15
 
16
+ export function getClientSecret(name) {
17
+ const secret = loadTokenFile(name).clientSecret;
18
+ return secret ? expandSecrets(secret) : secret;
19
+ }
20
+
15
21
  export function loadTokenFile(name) {
16
22
  const file = tokenPath(name);
17
23
  if (!existsSync(file)) return {};
package/src/output.js CHANGED
@@ -1,61 +1,65 @@
1
- import YAML from "yaml";
2
-
3
- let outputFormat = "json";
4
-
5
- export function setFormat(format) {
6
- if (format && ["json", "text", "yaml"].includes(format)) {
7
- outputFormat = format;
8
- }
9
- }
10
-
11
- export function out(data) {
12
- switch (outputFormat) {
13
- case "yaml":
14
- console.log(YAML.stringify(data).trimEnd());
15
- break;
16
- case "text":
17
- console.log(formatText(data));
18
- break;
19
- case "json":
20
- default:
21
- console.log(JSON.stringify(data, null, 2));
22
- break;
23
- }
24
- }
25
-
26
- export function err(message) {
27
- // Errors are always JSON for reliable agent parsing
28
- console.error(JSON.stringify({ error: message }));
29
- }
30
-
31
- function formatText(data, indent = 0) {
32
- if (data === null || data === undefined) return "null";
33
- if (typeof data === "string") return data;
34
- if (typeof data === "number" || typeof data === "boolean") return String(data);
35
-
36
- if (Array.isArray(data)) {
37
- if (data.length === 0) return "(empty)";
38
- return data
39
- .map((item, i) => {
40
- if (typeof item === "object" && item !== null) {
41
- return `${" ".repeat(indent)}[${i}]\n${formatText(item, indent + 1)}`;
42
- }
43
- return `${" ".repeat(indent)}- ${item}`;
44
- })
45
- .join("\n");
46
- }
47
-
48
- if (typeof data === "object") {
49
- return Object.entries(data)
50
- .map(([key, val]) => {
51
- if (val === null || val === undefined) return `${" ".repeat(indent)}${key}: null`;
52
- if (typeof val === "object") {
53
- return `${" ".repeat(indent)}${key}:\n${formatText(val, indent + 1)}`;
54
- }
55
- return `${" ".repeat(indent)}${key}: ${val}`;
56
- })
57
- .join("\n");
58
- }
59
-
60
- return String(data);
61
- }
1
+ import YAML from "yaml";
2
+ import { encode } from "@toon-format/toon";
3
+
4
+ let outputFormat = "json";
5
+
6
+ export function setFormat(format) {
7
+ if (format && ["json", "text", "yaml", "toon"].includes(format)) {
8
+ outputFormat = format;
9
+ }
10
+ }
11
+
12
+ export function out(data) {
13
+ switch (outputFormat) {
14
+ case "yaml":
15
+ console.log(YAML.stringify(data).trimEnd());
16
+ break;
17
+ case "toon":
18
+ console.log(encode(data).trimEnd());
19
+ break;
20
+ case "text":
21
+ console.log(formatText(data));
22
+ break;
23
+ case "json":
24
+ default:
25
+ console.log(JSON.stringify(data, null, 2));
26
+ break;
27
+ }
28
+ }
29
+
30
+ export function err(message) {
31
+ // Errors are always JSON for reliable agent parsing
32
+ console.error(JSON.stringify({ error: message }));
33
+ }
34
+
35
+ function formatText(data, indent = 0) {
36
+ if (data === null || data === undefined) return "null";
37
+ if (typeof data === "string") return data;
38
+ if (typeof data === "number" || typeof data === "boolean") return String(data);
39
+
40
+ if (Array.isArray(data)) {
41
+ if (data.length === 0) return "(empty)";
42
+ return data
43
+ .map((item, i) => {
44
+ if (typeof item === "object" && item !== null) {
45
+ return `${" ".repeat(indent)}[${i}]\n${formatText(item, indent + 1)}`;
46
+ }
47
+ return `${" ".repeat(indent)}- ${item}`;
48
+ })
49
+ .join("\n");
50
+ }
51
+
52
+ if (typeof data === "object") {
53
+ return Object.entries(data)
54
+ .map(([key, val]) => {
55
+ if (val === null || val === undefined) return `${" ".repeat(indent)}${key}: null`;
56
+ if (typeof val === "object") {
57
+ return `${" ".repeat(indent)}${key}:\n${formatText(val, indent + 1)}`;
58
+ }
59
+ return `${" ".repeat(indent)}${key}: ${val}`;
60
+ })
61
+ .join("\n");
62
+ }
63
+
64
+ return String(data);
65
+ }
package/src/registry.js CHANGED
@@ -1,79 +1,79 @@
1
- import { homedir } from "os";
2
- import { join } from "path";
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "fs";
4
-
5
- const REGISTRY_DIR = join(homedir(), "spec-cli-config");
6
- const REGISTRY_FILE = join(REGISTRY_DIR, "registry.json");
7
- const CACHE_DIR = join(REGISTRY_DIR, "cache");
8
-
9
- function ensureDir(dir) {
10
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
11
- }
12
-
13
- const EMPTY = { mcp: {}, openapi: {}, graphql: {} };
14
-
15
- export function getRegistry() {
16
- if (!existsSync(REGISTRY_FILE)) return { ...EMPTY };
17
- try {
18
- const data = JSON.parse(readFileSync(REGISTRY_FILE, "utf-8"));
19
- if (!data || typeof data !== "object" || Array.isArray(data)) {
20
- throw new Error(`Registry file has old format: ${REGISTRY_FILE}. Delete it to reset.`);
21
- }
22
- return data;
23
- } catch (e) {
24
- if (e.message.includes("old format")) throw e;
25
- throw new Error(`Registry file is corrupt: ${REGISTRY_FILE}. Delete it to reset.`);
26
- }
27
- }
28
-
29
- export function saveRegistry(registry) {
30
- ensureDir(REGISTRY_DIR);
31
- writeFileSync(REGISTRY_FILE, JSON.stringify(registry, null, 2));
32
- }
33
-
34
- /**
35
- * Find an entry by name across all sections.
36
- * Returns the entry with `name` and `_section` injected.
37
- */
38
- export function allEntries(registry) {
39
- const entries = [];
40
- for (const section of ["mcp", "openapi", "graphql"]) {
41
- for (const [name, entry] of Object.entries(registry[section] || {})) {
42
- entries.push({ ...entry, name, _section: section });
43
- }
44
- }
45
- return entries;
46
- }
47
-
48
- export function getEntry(name) {
49
- const registry = getRegistry();
50
- for (const section of ["mcp", "openapi", "graphql"]) {
51
- const entry = registry[section]?.[name];
52
- if (entry) {
53
- if (!entry.enabled)
54
- throw new Error(`Spec '${name}' is disabled. Run 'spec enable ${name}' first.`);
55
- return { ...entry, name, _section: section };
56
- }
57
- }
58
- throw new Error(`No spec named '${name}'. Run 'spec specs' to see available.`);
59
- }
60
-
61
- export function getCachedSpec(name) {
62
- const file = join(CACHE_DIR, `${name}.json`);
63
- if (!existsSync(file)) return null;
64
- try {
65
- return JSON.parse(readFileSync(file, "utf-8"));
66
- } catch {
67
- return null;
68
- }
69
- }
70
-
71
- export function saveCachedSpec(name, spec) {
72
- ensureDir(CACHE_DIR);
73
- writeFileSync(join(CACHE_DIR, `${name}.json`), JSON.stringify(spec, null, 2));
74
- }
75
-
76
- export function removeCachedSpec(name) {
77
- const file = join(CACHE_DIR, `${name}.json`);
78
- if (existsSync(file)) rmSync(file);
79
- }
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "fs";
4
+
5
+ const REGISTRY_DIR = join(homedir(), "spec-cli-config");
6
+ const REGISTRY_FILE = join(REGISTRY_DIR, "registry.json");
7
+ const CACHE_DIR = join(REGISTRY_DIR, "cache");
8
+
9
+ function ensureDir(dir) {
10
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
11
+ }
12
+
13
+ const EMPTY = { mcp: {}, openapi: {}, graphql: {} };
14
+
15
+ export function getRegistry() {
16
+ if (!existsSync(REGISTRY_FILE)) return { ...EMPTY };
17
+ try {
18
+ const data = JSON.parse(readFileSync(REGISTRY_FILE, "utf-8"));
19
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
20
+ throw new Error(`Registry file has old format: ${REGISTRY_FILE}. Delete it to reset.`);
21
+ }
22
+ return data;
23
+ } catch (e) {
24
+ if (e.message.includes("old format")) throw e;
25
+ throw new Error(`Registry file is corrupt: ${REGISTRY_FILE}. Delete it to reset.`);
26
+ }
27
+ }
28
+
29
+ export function saveRegistry(registry) {
30
+ ensureDir(REGISTRY_DIR);
31
+ writeFileSync(REGISTRY_FILE, JSON.stringify(registry, null, 2));
32
+ }
33
+
34
+ /**
35
+ * Find an entry by name across all sections.
36
+ * Returns the entry with `name` and `_section` injected.
37
+ */
38
+ export function allEntries(registry) {
39
+ const entries = [];
40
+ for (const section of ["mcp", "openapi", "graphql"]) {
41
+ for (const [name, entry] of Object.entries(registry[section] || {})) {
42
+ entries.push({ ...entry, name, _section: section });
43
+ }
44
+ }
45
+ return entries;
46
+ }
47
+
48
+ export function getEntry(name) {
49
+ const registry = getRegistry();
50
+ for (const section of ["mcp", "openapi", "graphql"]) {
51
+ const entry = registry[section]?.[name];
52
+ if (entry) {
53
+ if (!entry.enabled)
54
+ throw new Error(`Spec '${name}' is disabled. Run 'spec enable ${name}' first.`);
55
+ return { ...entry, name, _section: section };
56
+ }
57
+ }
58
+ throw new Error(`No spec named '${name}'. Run 'spec specs' to see available.`);
59
+ }
60
+
61
+ export function getCachedSpec(name) {
62
+ const file = join(CACHE_DIR, `${name}.json`);
63
+ if (!existsSync(file)) return null;
64
+ try {
65
+ return JSON.parse(readFileSync(file, "utf-8"));
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ export function saveCachedSpec(name, spec) {
72
+ ensureDir(CACHE_DIR);
73
+ writeFileSync(join(CACHE_DIR, `${name}.json`), JSON.stringify(spec, null, 2));
74
+ }
75
+
76
+ export function removeCachedSpec(name) {
77
+ const file = join(CACHE_DIR, `${name}.json`);
78
+ if (existsSync(file)) rmSync(file);
79
+ }
package/src/resolve.js CHANGED
@@ -2,17 +2,17 @@ import { getEntry, getCachedSpec, saveCachedSpec } from "./registry.js";
2
2
  import { fetchSpec, inlineEntryFromFlags } from "./commands/fetch.js";
3
3
  import { getConfig } from "./store.js";
4
4
  import { parseKV } from "./args.js";
5
+ import {
6
+ expandSecrets,
7
+ expandSecretsMap,
8
+ envHeaderOverrides,
9
+ envUrlOverride,
10
+ mergeHeaders,
11
+ } from "./secrets.js";
5
12
 
6
- /**
7
- * Resolve the active spec from flags.
8
- * Priority:
9
- * 1. --spec <name> → registry (auto-caches on first use)
10
- * 2. Inline flags → ad-hoc, no caching
11
- * 3. Error → no spec source given
12
- */
13
13
  export async function resolveSpec(flags) {
14
14
  if (flags.spec) {
15
- const entry = getEntry(flags.spec); // throws if missing or disabled
15
+ const entry = getEntry(flags.spec);
16
16
  let spec = getCachedSpec(flags.spec);
17
17
  if (!spec) {
18
18
  spec = await fetchSpec(entry);
@@ -37,23 +37,25 @@ export async function resolveSpec(flags) {
37
37
  );
38
38
  }
39
39
 
40
- /**
41
- * Build the effective config for a command.
42
- * Precedence (highest → lowest):
43
- * 1. Call-time flags: --auth, --base-url, --header k=v
44
- * 2. Registry entry config
45
- * 3. .spec-cli/config.json
46
- */
47
40
  export function resolveConfig(flags, entry) {
48
41
  const global = getConfig();
49
42
  const entryConfig = entry?.config || {};
50
43
  const callHeaders = parseKV(flags.header);
51
44
 
52
- const auth = flags.auth || entryConfig.auth || global.auth;
53
- const baseUrl = flags["base-url"] || entryConfig.baseUrl || global.baseUrl;
54
- const headers = { ...global.headers, ...(entryConfig.headers || {}), ...callHeaders };
45
+ const rawAuth = flags.auth || entryConfig.auth || global.auth;
46
+ const auth = rawAuth ? expandSecrets(rawAuth) : rawAuth;
47
+ const isGraphql = entry?.type === "graphql" || entry?._section === "graphql";
48
+ const envUrl = isGraphql ? envUrlOverride() : undefined;
49
+ const baseUrl = flags["base-url"] || envUrl || entryConfig.baseUrl || global.baseUrl;
50
+
51
+ const mergedHeaders = mergeHeaders(
52
+ global.headers,
53
+ entryConfig.headers,
54
+ envHeaderOverrides(),
55
+ callHeaders
56
+ );
57
+ const headers = expandSecretsMap(mergedHeaders);
55
58
 
56
- // Apply auth as Authorization header if not already there (case-insensitive check)
57
59
  const hasAuthHeader = Object.keys(headers).some((k) => k.toLowerCase() === "authorization");
58
60
  if (auth && !hasAuthHeader) {
59
61
  headers["Authorization"] =
package/src/secrets.js ADDED
@@ -0,0 +1,46 @@
1
+ const ENV_VAR_RE = /\$\{([^}]+)\}/g;
2
+
3
+ export function expandSecrets(str) {
4
+ if (typeof str !== "string") return str;
5
+ if (!str.includes("${")) return str;
6
+ return str.replace(ENV_VAR_RE, (_, name) => {
7
+ if (!(name in process.env)) throw new Error(`Environment variable not set: ${name}`);
8
+ return process.env[name];
9
+ });
10
+ }
11
+
12
+ export function expandSecretsMap(obj) {
13
+ if (!obj || typeof obj !== "object") return obj;
14
+ return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, expandSecrets(v)]));
15
+ }
16
+
17
+ export function envHeaderOverrides() {
18
+ const headers = {};
19
+ for (const [key, value] of Object.entries(process.env)) {
20
+ if (!key.startsWith("SPEC_HEADER_")) continue;
21
+ const headerName = key.slice("SPEC_HEADER_".length).replace(/_/g, "-").toLowerCase();
22
+ headers[headerName] = value;
23
+ }
24
+ return headers;
25
+ }
26
+
27
+ export function mergeHeaders(...maps) {
28
+ const canonical = {};
29
+ const result = {};
30
+ for (const map of maps) {
31
+ if (!map) continue;
32
+ for (const [key, value] of Object.entries(map)) {
33
+ const lower = key.toLowerCase();
34
+ if (lower in canonical) {
35
+ delete result[canonical[lower]];
36
+ }
37
+ canonical[lower] = key;
38
+ result[key] = value;
39
+ }
40
+ }
41
+ return result;
42
+ }
43
+
44
+ export function envUrlOverride() {
45
+ return process.env.SPEC_URL;
46
+ }