api-spec-cli 0.2.2 → 0.2.4

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.
@@ -1,80 +1,161 @@
1
- import { parseArgs, parseKV } from "../args.js";
2
- import { getRegistry, saveRegistry } from "../registry.js";
3
- import { out } from "../output.js";
4
-
5
- export async function addCmd(args) {
6
- const { flags, positional } = parseArgs(args);
7
- const name = positional[0];
8
- if (!name) throw new Error(
9
- "Usage: spec add <name> --openapi <url> | --graphql <url> | --mcp-http <url> | --mcp-sse <url> | --mcp-stdio \"<cmd>\""
10
- );
11
- if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
12
- throw new Error("Spec name must contain only letters, numbers, hyphens, and underscores.");
13
- }
14
-
15
- const registry = getRegistry();
16
- if (registry.find((e) => e.name === name)) {
17
- throw new Error(`Spec '${name}' already exists. Run 'spec remove ${name}' first.`);
18
- }
19
-
20
- const entry = { name, enabled: true };
21
-
22
- if (flags.description) entry.description = flags.description;
23
-
24
- if (flags.openapi) {
25
- entry.type = "openapi";
26
- entry.source = flags.openapi;
27
- entry.config = {
28
- baseUrl: flags["base-url"] || null,
29
- auth: flags.auth || null,
30
- headers: parseKV(flags.header),
31
- };
32
- } else if (flags.graphql) {
33
- entry.type = "graphql";
34
- entry.source = flags.graphql;
35
- entry.config = {
36
- auth: flags.auth || null,
37
- headers: parseKV(flags.header),
38
- };
39
- } else if (flags["mcp-stdio"]) {
40
- const raw = flags["mcp-stdio"];
41
- const parts = (raw.trim() ? raw.match(/(?:[^\s"]+|"[^"]*")+/g) : null)?.map((p) => p.replace(/^"|"$/g, ""));
42
- if (!parts?.length) throw new Error("--mcp-stdio requires a non-empty command string");
43
- entry.type = "mcp";
44
- entry.transport = "stdio";
45
- entry.command = parts[0];
46
- entry.args = parts.slice(1);
47
- if (flags.cwd) entry.cwd = flags.cwd;
48
- entry.config = { env: parseKV(flags.env) };
49
- } else if (flags["mcp-sse"]) {
50
- entry.type = "mcp";
51
- entry.transport = "sse";
52
- entry.url = flags["mcp-sse"];
53
- const headers = parseKV(flags.header);
54
- if (flags.auth && !headers["Authorization"]) headers["Authorization"] = `Bearer ${flags.auth}`;
55
- entry.config = { headers };
56
- } else if (flags["mcp-http"]) {
57
- entry.type = "mcp";
58
- entry.transport = "streamable-http";
59
- entry.url = flags["mcp-http"];
60
- const headers = parseKV(flags.header);
61
- if (flags.auth && !headers["Authorization"]) headers["Authorization"] = `Bearer ${flags.auth}`;
62
- entry.config = { headers };
63
- } else {
64
- throw new Error(
65
- "Specify a source: --openapi <url>, --graphql <url>, --mcp-http <url>, --mcp-sse <url>, or --mcp-stdio \"<cmd>\""
66
- );
67
- }
68
-
69
- // Tool filtering (MCP only)
70
- if (entry.type === "mcp") {
71
- const allowed = flags["allow-tool"];
72
- const disabled = flags["disable-tool"];
73
- if (allowed?.length) entry.config.allowedTools = allowed;
74
- if (disabled?.length) entry.config.disabledTools = disabled;
75
- }
76
-
77
- registry.push(entry);
78
- saveRegistry(registry);
79
- out({ ok: true, name, type: entry.type, transport: entry.transport });
80
- }
1
+ import { parseArgs, parseKV } from "../args.js";
2
+ import { getRegistry, saveRegistry } from "../registry.js";
3
+ import { out } from "../output.js";
4
+ import { saveTokenFile } from "../oauth/tokens.js";
5
+ import { runOAuthFlow } from "../oauth/auth-flow.js";
6
+
7
+ export async function addCmd(args) {
8
+ const { flags, positional } = parseArgs(args);
9
+ const name = positional[0];
10
+ if (!name)
11
+ throw new Error(
12
+ 'Usage: spec add <name> --openapi <url> | --graphql <url> | --mcp-http <url> | --mcp-sse <url> | --mcp-stdio "<cmd>"'
13
+ );
14
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
15
+ throw new Error("Spec name must contain only letters, numbers, hyphens, and underscores.");
16
+ }
17
+
18
+ const registry = getRegistry();
19
+
20
+ // Check for name collision across all sections
21
+ for (const section of ["mcp", "openapi", "graphql"]) {
22
+ if (registry[section]?.[name]) {
23
+ throw new Error(`Spec '${name}' already exists. Run 'spec remove ${name}' first.`);
24
+ }
25
+ }
26
+
27
+ const allowed = flags["allow-tool"];
28
+ const disabled = flags["disable-tool"];
29
+ const filterConfig = {
30
+ ...(allowed?.length ? { allowedTools: allowed } : {}),
31
+ ...(disabled?.length ? { disabledTools: disabled } : {}),
32
+ };
33
+ const base = {
34
+ enabled: true,
35
+ ...(flags.description ? { description: flags.description } : {}),
36
+ };
37
+
38
+ // Validate callback port once, shared by both mcp-http and mcp-sse
39
+ let callbackPort;
40
+ if (flags["oauth-callback-port"]) {
41
+ callbackPort = parseInt(flags["oauth-callback-port"], 10);
42
+ if (isNaN(callbackPort) || callbackPort < 1 || callbackPort > 65535) {
43
+ throw new Error("--oauth-callback-port must be an integer between 1 and 65535");
44
+ }
45
+ }
46
+
47
+ // Client secret is never stored in the registry — saved to token file below
48
+ const oauthClientSecret = flags["oauth-client-secret"];
49
+
50
+ let section, entry;
51
+
52
+ if (flags.openapi) {
53
+ section = "openapi";
54
+ entry = {
55
+ ...base,
56
+ type: "openapi",
57
+ source: flags.openapi,
58
+ config: {
59
+ baseUrl: flags["base-url"] || null,
60
+ auth: flags.auth || null,
61
+ headers: parseKV(flags.header),
62
+ ...filterConfig,
63
+ },
64
+ };
65
+ } else if (flags.graphql) {
66
+ section = "graphql";
67
+ entry = {
68
+ ...base,
69
+ type: "graphql",
70
+ source: flags.graphql,
71
+ config: {
72
+ auth: flags.auth || null,
73
+ headers: parseKV(flags.header),
74
+ ...filterConfig,
75
+ },
76
+ };
77
+ } else if (flags["mcp-stdio"]) {
78
+ const raw = flags["mcp-stdio"];
79
+ const parts = (raw.trim() ? raw.match(/(?:[^\s"]+|"[^"]*")+/g) : null)?.map((p) =>
80
+ p.replace(/^"|"$/g, "")
81
+ );
82
+ if (!parts?.length) throw new Error("--mcp-stdio requires a non-empty command string");
83
+ section = "mcp";
84
+ const env = parseKV(flags.env);
85
+ entry = {
86
+ ...base,
87
+ ...filterConfig,
88
+ type: "stdio",
89
+ command: parts[0],
90
+ args: parts.slice(1),
91
+ ...(flags.cwd ? { cwd: flags.cwd } : {}),
92
+ ...(Object.keys(env).length ? { env } : {}),
93
+ };
94
+ } else if (flags["mcp-sse"]) {
95
+ section = "mcp";
96
+ const headers = parseKV(flags.header);
97
+ if (flags.auth && !headers["Authorization"]) headers["Authorization"] = `Bearer ${flags.auth}`;
98
+ entry = {
99
+ ...base,
100
+ ...filterConfig,
101
+ type: "sse",
102
+ url: flags["mcp-sse"],
103
+ ...(Object.keys(headers).length ? { headers } : {}),
104
+ ...(flags["oauth-flow"] ? { oauthFlow: flags["oauth-flow"] } : {}),
105
+ ...(flags["oauth-client-id"] ? { oauthClientId: flags["oauth-client-id"] } : {}),
106
+ ...(callbackPort ? { oauthCallbackPort: callbackPort } : {}),
107
+ };
108
+ } else if (flags["mcp-http"]) {
109
+ section = "mcp";
110
+ const headers = parseKV(flags.header);
111
+ if (flags.auth && !headers["Authorization"]) headers["Authorization"] = `Bearer ${flags.auth}`;
112
+ entry = {
113
+ ...base,
114
+ ...filterConfig,
115
+ type: "http",
116
+ url: flags["mcp-http"],
117
+ ...(Object.keys(headers).length ? { headers } : {}),
118
+ ...(flags["oauth-flow"] ? { oauthFlow: flags["oauth-flow"] } : {}),
119
+ ...(flags["oauth-client-id"] ? { oauthClientId: flags["oauth-client-id"] } : {}),
120
+ ...(callbackPort ? { oauthCallbackPort: callbackPort } : {}),
121
+ };
122
+ } else {
123
+ throw new Error(
124
+ 'Specify a source: --openapi <url>, --graphql <url>, --mcp-http <url>, --mcp-sse <url>, or --mcp-stdio "<cmd>"'
125
+ );
126
+ }
127
+
128
+ registry[section][name] = entry;
129
+ saveRegistry(registry);
130
+
131
+ // Store client secret in token file (not registry) — it's sensitive
132
+ if (oauthClientSecret) {
133
+ saveTokenFile(name, { clientSecret: oauthClientSecret });
134
+ }
135
+
136
+ // Probe for OAuth on HTTP/SSE MCP entries (skip if static Authorization header already set)
137
+ if (
138
+ section === "mcp" &&
139
+ (entry.type === "http" || entry.type === "sse") &&
140
+ !entry.headers?.Authorization
141
+ ) {
142
+ await probeAndAuth({ ...entry, name, _section: "mcp" });
143
+ }
144
+
145
+ out({ ok: true, name, section, type: entry.type });
146
+ }
147
+
148
+ async function probeAndAuth(entry) {
149
+ try {
150
+ const { flow } = await runOAuthFlow(entry.name, entry);
151
+ if (flow === "none_required") process.stderr.write(`Connected (no auth required).\n`);
152
+ else if (flow === "browser")
153
+ process.stderr.write(`Authorization complete for '${entry.name}'.\n`);
154
+ } catch (e) {
155
+ process.stderr.write(
156
+ `Could not reach server: ${e.message}\nRun 'spec auth ${entry.name}' after the server is available.\n`
157
+ );
158
+ // Signal partial failure: entry was saved but connection failed
159
+ process.exitCode = 1;
160
+ }
161
+ }
@@ -0,0 +1,32 @@
1
+ import { parseArgs } from "../args.js";
2
+ import { getEntry } from "../registry.js";
3
+ import { clearTokenFile } from "../oauth/tokens.js";
4
+ import { runOAuthFlow } from "../oauth/auth-flow.js";
5
+ import { out } from "../output.js";
6
+
7
+ export async function authCmd(args) {
8
+ const { positional, flags } = parseArgs(args);
9
+ const name = positional[0];
10
+ if (!name) throw new Error("Usage: spec auth <name> [--revoke]");
11
+
12
+ const entry = getEntry(name);
13
+
14
+ if (entry._section !== "mcp" || (entry.type !== "http" && entry.type !== "sse")) {
15
+ throw new Error(
16
+ `'${name}' is not an HTTP/SSE MCP spec — OAuth only applies to mcp http and sse entries`
17
+ );
18
+ }
19
+
20
+ if ("revoke" in flags) {
21
+ // revokeAll wipes everything including clientSecret
22
+ clearTokenFile(name, { revokeAll: true });
23
+ out({ ok: true, name, revoked: true });
24
+ return;
25
+ }
26
+
27
+ // Clear session tokens but preserve clientSecret so client credentials flow still works
28
+ clearTokenFile(name);
29
+
30
+ const { flow } = await runOAuthFlow(name, entry);
31
+ out({ ok: true, name, flow });
32
+ }