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/README.md +48 -1
- package/package.json +5 -3
- package/src/cli.js +222 -67
- package/src/commands/add.js +8 -6
- package/src/commands/auth.js +32 -32
- package/src/commands/call.js +4 -0
- package/src/commands/fetch.js +344 -344
- package/src/commands/grep.js +67 -67
- package/src/commands/list.js +13 -2
- package/src/commands/show.js +224 -224
- package/src/commands/skill.js +40 -0
- package/src/commands/specs.js +82 -82
- package/src/commands/types.js +167 -167
- package/src/commands/usage.js +15 -0
- package/src/commands/validate.js +295 -295
- package/src/dotenv.js +38 -0
- package/src/glob.js +34 -34
- package/src/mcp-client.js +23 -20
- package/src/oauth/auth-flow.js +2 -2
- package/src/oauth/provider.js +3 -4
- package/src/oauth/tokens.js +6 -0
- package/src/output.js +65 -61
- package/src/registry.js +79 -79
- package/src/resolve.js +21 -19
- package/src/secrets.js +46 -0
- package/src/skill/SKILL.md +112 -0
- package/src/usage.js +62 -0
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 {
|
|
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
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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(
|
|
50
|
+
transport = new SSEClientTransport(new URL(url), {
|
|
48
51
|
authProvider,
|
|
49
|
-
requestInit:
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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(
|
|
66
|
+
transport = new StreamableHTTPClientTransport(new URL(url), {
|
|
64
67
|
authProvider,
|
|
65
|
-
requestInit:
|
|
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`);
|
package/src/oauth/auth-flow.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
package/src/oauth/provider.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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 };
|
package/src/oauth/tokens.js
CHANGED
|
@@ -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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
console.log(
|
|
22
|
-
break;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return `${" ".repeat(indent)}${key}:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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);
|
|
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
|
|
53
|
-
const
|
|
54
|
-
const
|
|
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
|
+
}
|