api-spec-cli 0.2.3 → 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.
- package/README.md +343 -282
- package/package.json +53 -41
- package/src/cli.js +183 -172
- package/src/commands/add.js +161 -78
- package/src/commands/auth.js +32 -0
- package/src/commands/call.js +220 -207
- package/src/commands/fetch.js +344 -308
- package/src/commands/grep.js +67 -64
- package/src/commands/list.js +78 -80
- package/src/commands/show.js +224 -215
- package/src/commands/specs.js +82 -69
- package/src/commands/types.js +167 -163
- package/src/commands/validate.js +295 -269
- package/src/glob.js +34 -29
- package/src/mcp-client.js +88 -63
- package/src/oauth/auth-flow.js +59 -0
- package/src/oauth/provider.js +192 -0
- package/src/oauth/tokens.js +53 -0
- package/src/registry.js +79 -53
- package/src/resolve.js +64 -65
package/src/mcp-client.js
CHANGED
|
@@ -1,63 +1,88 @@
|
|
|
1
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
-
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
|
-
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
4
|
-
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
})
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
4
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
5
|
+
import { SpecCliOAuthProvider } from "./oauth/provider.js";
|
|
6
|
+
import { ClientCredentialsProvider } from "@modelcontextprotocol/sdk/client/auth-extensions.js";
|
|
7
|
+
import { loadTokenFile } from "./oauth/tokens.js";
|
|
8
|
+
|
|
9
|
+
const MAX_RETRIES = parseInt(process.env.MCP_MAX_RETRIES ?? "3");
|
|
10
|
+
const RETRY_DELAY = parseInt(process.env.MCP_RETRY_DELAY ?? "1000");
|
|
11
|
+
|
|
12
|
+
// Expand ${VAR} placeholders from process.env at call time
|
|
13
|
+
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
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function connect(spec) {
|
|
21
|
+
const client = new Client({ name: "spec-cli", version: "1.0.0" });
|
|
22
|
+
|
|
23
|
+
let transport;
|
|
24
|
+
if (spec.type === "stdio") {
|
|
25
|
+
const rawEnv = spec.env || {};
|
|
26
|
+
const expandedEnv = Object.fromEntries(
|
|
27
|
+
Object.entries(rawEnv).map(([k, v]) => [k, expandEnv(v)])
|
|
28
|
+
);
|
|
29
|
+
transport = new StdioClientTransport({
|
|
30
|
+
command: spec.command,
|
|
31
|
+
args: spec.args,
|
|
32
|
+
env: Object.keys(expandedEnv).length > 0 ? { ...process.env, ...expandedEnv } : undefined,
|
|
33
|
+
cwd: spec.cwd,
|
|
34
|
+
});
|
|
35
|
+
} else if (spec.type === "sse") {
|
|
36
|
+
const h = spec.headers;
|
|
37
|
+
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
|
+
authProvider =
|
|
43
|
+
spec.oauthFlow === "client_credentials" && spec.oauthClientId && clientSecret
|
|
44
|
+
? new ClientCredentialsProvider({ clientId: spec.oauthClientId, clientSecret })
|
|
45
|
+
: new SpecCliOAuthProvider(spec.name, spec);
|
|
46
|
+
}
|
|
47
|
+
transport = new SSEClientTransport(new URL(spec.url), {
|
|
48
|
+
authProvider,
|
|
49
|
+
requestInit: h && Object.keys(h).length > 0 ? { headers: h } : undefined,
|
|
50
|
+
});
|
|
51
|
+
} else if (spec.type === "http") {
|
|
52
|
+
const h = spec.headers;
|
|
53
|
+
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
|
+
authProvider =
|
|
59
|
+
spec.oauthFlow === "client_credentials" && spec.oauthClientId && clientSecret
|
|
60
|
+
? new ClientCredentialsProvider({ clientId: spec.oauthClientId, clientSecret })
|
|
61
|
+
: new SpecCliOAuthProvider(spec.name, spec);
|
|
62
|
+
}
|
|
63
|
+
transport = new StreamableHTTPClientTransport(new URL(spec.url), {
|
|
64
|
+
authProvider,
|
|
65
|
+
requestInit: h && Object.keys(h).length > 0 ? { headers: h } : undefined,
|
|
66
|
+
});
|
|
67
|
+
} else {
|
|
68
|
+
throw new Error(`Unknown MCP type: ${spec.type}. Supported: stdio, sse, http`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
await client.connect(transport);
|
|
72
|
+
return client;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function createMcpClient(spec) {
|
|
76
|
+
let lastError;
|
|
77
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
78
|
+
try {
|
|
79
|
+
return await connect(spec);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
lastError = e;
|
|
82
|
+
if (attempt < MAX_RETRIES) {
|
|
83
|
+
await new Promise((r) => setTimeout(r, Math.min(RETRY_DELAY * Math.pow(2, attempt), 5000)));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
throw lastError;
|
|
88
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
3
|
+
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
|
4
|
+
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
5
|
+
import { ClientCredentialsProvider } from "@modelcontextprotocol/sdk/client/auth-extensions.js";
|
|
6
|
+
import { SpecCliOAuthProvider } from "./provider.js";
|
|
7
|
+
import { loadTokenFile } from "./tokens.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Run the full OAuth flow for a named MCP HTTP/SSE entry.
|
|
11
|
+
* Client secret is loaded from the token file (not the registry entry).
|
|
12
|
+
* Returns { flow: "client_credentials" | "browser" | "device" | "none_required" }.
|
|
13
|
+
* Throws on connection errors and unsupported flows.
|
|
14
|
+
*/
|
|
15
|
+
export async function runOAuthFlow(name, entry) {
|
|
16
|
+
const TransportClass = entry.type === "sse" ? SSEClientTransport : StreamableHTTPClientTransport;
|
|
17
|
+
const clientSecret = loadTokenFile(name).clientSecret;
|
|
18
|
+
|
|
19
|
+
// Only use client credentials grant when explicitly requested.
|
|
20
|
+
// Having a clientSecret does NOT imply client_credentials — for most OAuth apps
|
|
21
|
+
// (e.g. GitHub) the secret is used during the authorization code token exchange.
|
|
22
|
+
if (entry.oauthFlow === "client_credentials" && entry.oauthClientId && clientSecret) {
|
|
23
|
+
process.stderr.write(`Using client credentials flow for '${name}'...\n`);
|
|
24
|
+
const provider = new ClientCredentialsProvider({ clientId: entry.oauthClientId, clientSecret });
|
|
25
|
+
const transport = new TransportClass(new URL(entry.url), { authProvider: provider });
|
|
26
|
+
const client = new Client({ name: "spec-cli", version: "1.0.0" });
|
|
27
|
+
await client.connect(transport);
|
|
28
|
+
await client.close();
|
|
29
|
+
process.stderr.write(`Connected with client credentials.\n`);
|
|
30
|
+
return { flow: "client_credentials" };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const provider = new SpecCliOAuthProvider(name, entry);
|
|
34
|
+
await provider.prepareRedirect();
|
|
35
|
+
const transport = new TransportClass(new URL(entry.url), { authProvider: provider });
|
|
36
|
+
const client = new Client({ name: "spec-cli", version: "1.0.0" });
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
await client.connect(transport);
|
|
40
|
+
await client.close();
|
|
41
|
+
return { flow: "none_required" };
|
|
42
|
+
} catch (e) {
|
|
43
|
+
if (!(e instanceof UnauthorizedError)) throw e;
|
|
44
|
+
if ((entry.oauthFlow || "browser") !== "browser") {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`Device flow: open the URL above, complete authorization, then run:\n spec auth ${name}`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
process.stderr.write(`Waiting for browser authorization...\n`);
|
|
50
|
+
const code = await provider.waitForAuthCode();
|
|
51
|
+
await transport.finishAuth(code);
|
|
52
|
+
// Transport was already started by the first connect() — must use a fresh one
|
|
53
|
+
const transport2 = new TransportClass(new URL(entry.url), { authProvider: provider });
|
|
54
|
+
const client2 = new Client({ name: "spec-cli", version: "1.0.0" });
|
|
55
|
+
await client2.connect(transport2);
|
|
56
|
+
await client2.close();
|
|
57
|
+
return { flow: "browser" };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { createServer } from "http";
|
|
2
|
+
import { exec } from "child_process";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
|
+
import { loadTokenFile, saveTokenFile } from "./tokens.js";
|
|
5
|
+
|
|
6
|
+
function openBrowser(url) {
|
|
7
|
+
const cmd =
|
|
8
|
+
process.platform === "win32"
|
|
9
|
+
? `start "" "${url}"`
|
|
10
|
+
: process.platform === "darwin"
|
|
11
|
+
? `open "${url}"`
|
|
12
|
+
: `xdg-open "${url}"`;
|
|
13
|
+
exec(cmd);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getAvailablePort() {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const server = createServer();
|
|
19
|
+
server.listen(0, "127.0.0.1", () => {
|
|
20
|
+
const { port } = server.address();
|
|
21
|
+
server.close(() => resolve(port));
|
|
22
|
+
});
|
|
23
|
+
server.on("error", reject);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* OAuthClientProvider for spec-cli.
|
|
29
|
+
* Persists tokens, client registration, and discovery state to
|
|
30
|
+
* ~/spec-cli-config/tokens/<name>.json.
|
|
31
|
+
*
|
|
32
|
+
* Flows:
|
|
33
|
+
* "browser" (default) — opens browser + local PKCE callback server
|
|
34
|
+
* "device" — prints device code URL to stderr
|
|
35
|
+
*
|
|
36
|
+
* For client_credentials, use SDK's ClientCredentialsProvider directly.
|
|
37
|
+
*/
|
|
38
|
+
export class SpecCliOAuthProvider {
|
|
39
|
+
#name;
|
|
40
|
+
#flow;
|
|
41
|
+
#redirectPort;
|
|
42
|
+
#fixedPort;
|
|
43
|
+
#codeVerifier;
|
|
44
|
+
#pendingCode = null;
|
|
45
|
+
#callbackServer = null;
|
|
46
|
+
#oauthState = null;
|
|
47
|
+
#clientId;
|
|
48
|
+
|
|
49
|
+
constructor(name, entry = {}) {
|
|
50
|
+
this.#name = name;
|
|
51
|
+
this.#flow = entry.oauthFlow || "browser";
|
|
52
|
+
this.#clientId = entry.oauthClientId || undefined;
|
|
53
|
+
const envPort = process.env.SPEC_OAUTH_CALLBACK_PORT
|
|
54
|
+
? parseInt(process.env.SPEC_OAUTH_CALLBACK_PORT, 10)
|
|
55
|
+
: undefined;
|
|
56
|
+
this.#fixedPort = entry.oauthCallbackPort ? parseInt(entry.oauthCallbackPort, 10) : envPort;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get redirectUrl() {
|
|
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`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get clientMetadata() {
|
|
66
|
+
const clientSecret = loadTokenFile(this.#name).clientSecret;
|
|
67
|
+
return {
|
|
68
|
+
client_name: "spec-cli",
|
|
69
|
+
redirect_uris: this.#redirectPort ? [`http://127.0.0.1:${this.#redirectPort}/callback`] : [],
|
|
70
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
71
|
+
response_types: ["code"],
|
|
72
|
+
token_endpoint_auth_method: clientSecret ? "client_secret_post" : "none",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Called by the SDK to generate a CSRF state parameter for the authorization URL. */
|
|
77
|
+
state() {
|
|
78
|
+
if (!this.#oauthState) this.#oauthState = randomUUID();
|
|
79
|
+
return this.#oauthState;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
tokens() {
|
|
83
|
+
return loadTokenFile(this.#name).tokens ?? undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
saveTokens(tokens) {
|
|
87
|
+
saveTokenFile(this.#name, { tokens });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
clientInformation() {
|
|
91
|
+
const stored = loadTokenFile(this.#name).clientInfo;
|
|
92
|
+
if (stored) return stored;
|
|
93
|
+
if (this.#clientId) {
|
|
94
|
+
const clientSecret = loadTokenFile(this.#name).clientSecret;
|
|
95
|
+
return clientSecret
|
|
96
|
+
? { client_id: this.#clientId, client_secret: clientSecret }
|
|
97
|
+
: { client_id: this.#clientId };
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
saveClientInformation(info) {
|
|
103
|
+
saveTokenFile(this.#name, { clientInfo: info });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
discoveryState() {
|
|
107
|
+
return loadTokenFile(this.#name).discovery ?? undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
saveDiscoveryState(state) {
|
|
111
|
+
saveTokenFile(this.#name, { discovery: state });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
saveCodeVerifier(codeVerifier) {
|
|
115
|
+
this.#codeVerifier = codeVerifier;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
codeVerifier() {
|
|
119
|
+
if (!this.#codeVerifier) throw new Error("No code verifier saved");
|
|
120
|
+
return this.#codeVerifier;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Reserve a local port for the OAuth callback. Call before connecting. */
|
|
124
|
+
async prepareRedirect() {
|
|
125
|
+
if (this.#flow === "device") return;
|
|
126
|
+
this.#redirectPort = this.#fixedPort ?? (await getAvailablePort());
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
redirectToAuthorization(authorizationUrl) {
|
|
130
|
+
if (this.#flow === "device") {
|
|
131
|
+
process.stderr.write(
|
|
132
|
+
`\nOpen this URL to authorize spec-cli:\n ${authorizationUrl.toString()}\n\n`
|
|
133
|
+
);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let resolveCode, rejectCode;
|
|
138
|
+
this.#pendingCode = new Promise((resolve, reject) => {
|
|
139
|
+
resolveCode = resolve;
|
|
140
|
+
rejectCode = reject;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
this.#callbackServer = createServer((req, res) => {
|
|
144
|
+
const url = new URL(req.url, `http://127.0.0.1:${this.#redirectPort}`);
|
|
145
|
+
const code = url.searchParams.get("code");
|
|
146
|
+
const state = url.searchParams.get("state");
|
|
147
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
148
|
+
res.end("<html><body><h2>Authorization complete. You can close this tab.</h2></body></html>");
|
|
149
|
+
this.#callbackServer.close();
|
|
150
|
+
if (this.#oauthState !== null && state !== this.#oauthState) {
|
|
151
|
+
rejectCode(new Error("OAuth state mismatch — possible CSRF attack"));
|
|
152
|
+
} else {
|
|
153
|
+
resolveCode(code);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
this.#callbackServer.listen(this.#redirectPort, "127.0.0.1", () => {
|
|
158
|
+
process.stderr.write(`\nOpening browser for authorization...\n`);
|
|
159
|
+
openBrowser(authorizationUrl.toString());
|
|
160
|
+
process.stderr.write(
|
|
161
|
+
`Waiting for callback on http://127.0.0.1:${this.#redirectPort}/callback\n`
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Resolves with the authorization code once the browser callback arrives. */
|
|
167
|
+
async waitForAuthCode() {
|
|
168
|
+
if (this.#flow === "device") throw new Error("Device flow does not use a local callback");
|
|
169
|
+
if (!this.#pendingCode) throw new Error("redirectToAuthorization() was not called");
|
|
170
|
+
|
|
171
|
+
let timeoutId;
|
|
172
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
173
|
+
timeoutId = setTimeout(
|
|
174
|
+
() => {
|
|
175
|
+
this.#callbackServer?.close();
|
|
176
|
+
reject(
|
|
177
|
+
new Error(
|
|
178
|
+
"Authorization timed out after 5 minutes. Run 'spec auth <name>' to try again."
|
|
179
|
+
)
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
5 * 60 * 1000
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
return await Promise.race([this.#pendingCode, timeoutPromise]);
|
|
188
|
+
} finally {
|
|
189
|
+
clearTimeout(timeoutId);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "fs";
|
|
4
|
+
|
|
5
|
+
let TOKEN_DIR = join(homedir(), "spec-cli-config", "tokens");
|
|
6
|
+
|
|
7
|
+
export function setTokenDir(dir) {
|
|
8
|
+
TOKEN_DIR = dir;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function tokenPath(name) {
|
|
12
|
+
return join(TOKEN_DIR, `${name}.json`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function loadTokenFile(name) {
|
|
16
|
+
const file = tokenPath(name);
|
|
17
|
+
if (!existsSync(file)) return {};
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(readFileSync(file, "utf-8"));
|
|
20
|
+
} catch {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function saveTokenFile(name, data) {
|
|
26
|
+
mkdirSync(TOKEN_DIR, { recursive: true });
|
|
27
|
+
const existing = loadTokenFile(name);
|
|
28
|
+
writeFileSync(tokenPath(name), JSON.stringify({ ...existing, ...data }, null, 2));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Clear session tokens for re-auth.
|
|
33
|
+
* Preserves clientSecret (a permanent credential) unless revokeAll is true.
|
|
34
|
+
* Pass { revokeAll: true } for `spec auth <name> --revoke` to wipe everything.
|
|
35
|
+
*/
|
|
36
|
+
export function clearTokenFile(name, { revokeAll = false } = {}) {
|
|
37
|
+
const file = tokenPath(name);
|
|
38
|
+
if (!existsSync(file)) return;
|
|
39
|
+
if (revokeAll) {
|
|
40
|
+
rmSync(file);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Keep permanent credentials; wipe session tokens, discovery, and clientInfo
|
|
44
|
+
const existing = loadTokenFile(name);
|
|
45
|
+
if (existing.clientSecret) {
|
|
46
|
+
writeFileSync(
|
|
47
|
+
tokenPath(name),
|
|
48
|
+
JSON.stringify({ clientSecret: existing.clientSecret }, null, 2)
|
|
49
|
+
);
|
|
50
|
+
} else {
|
|
51
|
+
rmSync(file);
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/registry.js
CHANGED
|
@@ -1,53 +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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
}
|