@switchboard.spot/cli 0.2.0 → 0.2.2
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 +27 -7
- package/bin/switchboard.js +5 -1
- package/lib/commands/auth.js +222 -49
- package/lib/commands/billing.js +43 -0
- package/lib/commands/docs.js +176 -0
- package/lib/commands/doctor.js +49 -0
- package/lib/commands/env.js +3 -1
- package/lib/commands/init.js +7 -4
- package/lib/commands/launch.js +192 -0
- package/lib/commands/projects.js +50 -4
- package/lib/commands/setup.js +7 -4
- package/lib/config.js +36 -5
- package/lib/credentialStore.js +68 -0
- package/lib/docsClient.js +157 -0
- package/lib/mcpServer.js +193 -0
- package/lib/output.js +65 -9
- package/lib/verify/index.js +32 -4
- package/package.json +4 -2
package/lib/credentialStore.js
CHANGED
|
@@ -9,12 +9,16 @@
|
|
|
9
9
|
|
|
10
10
|
import { execFile } from "node:child_process";
|
|
11
11
|
import { spawn } from "node:child_process";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
12
15
|
import { promisify } from "node:util";
|
|
13
16
|
|
|
14
17
|
const execFileAsync = promisify(execFile);
|
|
15
18
|
const SERVICE = "Switchboard CLI";
|
|
16
19
|
const ACCOUNT = "account-session";
|
|
17
20
|
const PROJECT_SECRET_PREFIX = "project-secret";
|
|
21
|
+
const ACCOUNT_SESSION_FILE = "account-session.json";
|
|
18
22
|
|
|
19
23
|
/**
|
|
20
24
|
* Reads the current account session token from the OS keychain.
|
|
@@ -54,6 +58,30 @@ export async function getAccountToken() {
|
|
|
54
58
|
}
|
|
55
59
|
}
|
|
56
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Reads the current account session token from the isolated CLI config dir.
|
|
63
|
+
*/
|
|
64
|
+
export function getConfigDirAccountToken(configDir = switchboardConfigDir()) {
|
|
65
|
+
const file = accountSessionFile(configDir);
|
|
66
|
+
|
|
67
|
+
if (!fs.existsSync(file)) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let data;
|
|
72
|
+
try {
|
|
73
|
+
data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
74
|
+
} catch (error) {
|
|
75
|
+
throw new Error(`Could not read Switchboard config-dir account session: ${error.message}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!data || typeof data.token !== "string" || data.token.trim() === "") {
|
|
79
|
+
throw new Error("Switchboard config-dir account session is invalid");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return data.token;
|
|
83
|
+
}
|
|
84
|
+
|
|
57
85
|
/**
|
|
58
86
|
* Writes the current account session token to the OS keychain.
|
|
59
87
|
*/
|
|
@@ -92,6 +120,23 @@ export async function setAccountToken(token) {
|
|
|
92
120
|
}
|
|
93
121
|
}
|
|
94
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Writes the current account session token to the isolated CLI config dir.
|
|
125
|
+
*/
|
|
126
|
+
export function setConfigDirAccountToken(token, configDir = switchboardConfigDir()) {
|
|
127
|
+
if (!token) {
|
|
128
|
+
throw new Error("Cannot store an empty Switchboard account token");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
132
|
+
fs.writeFileSync(
|
|
133
|
+
accountSessionFile(configDir),
|
|
134
|
+
`${JSON.stringify({ token }, null, 2)}\n`,
|
|
135
|
+
{ mode: 0o600 },
|
|
136
|
+
);
|
|
137
|
+
chmodAccountSessionFile(configDir);
|
|
138
|
+
}
|
|
139
|
+
|
|
95
140
|
/**
|
|
96
141
|
* Deletes the current account session token from the OS keychain.
|
|
97
142
|
*/
|
|
@@ -129,6 +174,29 @@ export async function deleteAccountToken() {
|
|
|
129
174
|
}
|
|
130
175
|
}
|
|
131
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Deletes the current account session token from the isolated CLI config dir.
|
|
179
|
+
*/
|
|
180
|
+
export function deleteConfigDirAccountToken(configDir = switchboardConfigDir()) {
|
|
181
|
+
fs.rmSync(accountSessionFile(configDir), { force: true });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function accountSessionFile(configDir = switchboardConfigDir()) {
|
|
185
|
+
return path.join(configDir, ACCOUNT_SESSION_FILE);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function switchboardConfigDir() {
|
|
189
|
+
return process.env.SWITCHBOARD_CONFIG_DIR || path.join(os.homedir(), ".switchboard");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function chmodAccountSessionFile(configDir) {
|
|
193
|
+
try {
|
|
194
|
+
fs.chmodSync(accountSessionFile(configDir), 0o600);
|
|
195
|
+
} catch {
|
|
196
|
+
/* best effort on platforms that do not support chmod */
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
132
200
|
/**
|
|
133
201
|
* Reads a stored project secret key from the OS keychain.
|
|
134
202
|
*/
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public docs client used by CLI docs commands and the embedded MCP server.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { gatewayApiUrl, resolveAccountConfig, resolveConfig, accountApiUrl } from "./config.js";
|
|
6
|
+
import { redactSecrets } from "./output.js";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
9
|
+
|
|
10
|
+
export function docsBaseUrl(config = resolveConfig()) {
|
|
11
|
+
return gatewayApiUrl(config);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function listDocs({ config } = {}) {
|
|
15
|
+
const data = await publicJson("/docs", { config });
|
|
16
|
+
return redactSecrets(data);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function readDoc(id, { config } = {}) {
|
|
20
|
+
const data = await publicJson(`/docs/${encodeURIComponent(id)}`, { config });
|
|
21
|
+
return redactSecrets(data);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function searchDocs(query, { limit, config } = {}) {
|
|
25
|
+
const params = new URLSearchParams({ q: query });
|
|
26
|
+
if (limit != null) params.set("limit", String(limit));
|
|
27
|
+
const data = await publicJson(`/docs/search?${params}`, { config });
|
|
28
|
+
return redactSecrets(data);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function docsCapabilities({ config } = {}) {
|
|
32
|
+
const data = await publicJson("/docs/capabilities", { config });
|
|
33
|
+
return redactSecrets(data);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function openApi({ config } = {}) {
|
|
37
|
+
const data = await publicJson("/openapi.json", { config });
|
|
38
|
+
return redactSecrets(data);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function models({ config } = {}) {
|
|
42
|
+
const data = await publicJson("/models", { config });
|
|
43
|
+
return redactSecrets(data);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function integrationKit({ stack, config } = {}) {
|
|
47
|
+
let cfg;
|
|
48
|
+
try {
|
|
49
|
+
cfg = await resolveAccountConfig(config || resolveConfig());
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
error: {
|
|
54
|
+
type: "keychain_unavailable",
|
|
55
|
+
message: error.message || "Could not read Switchboard account session from the OS keychain.",
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!cfg.accountToken) {
|
|
61
|
+
return {
|
|
62
|
+
ok: false,
|
|
63
|
+
error: {
|
|
64
|
+
type: "authentication_required",
|
|
65
|
+
message: "Run `switchboard auth login`, then select a project with `switchboard projects use <id>`.",
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!cfg.projectId) {
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
error: {
|
|
74
|
+
type: "project_required",
|
|
75
|
+
message: "Select a project with `switchboard projects use <id>` or set SWITCHBOARD_PROJECT_ID.",
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const params = new URLSearchParams({ project_id: cfg.projectId });
|
|
81
|
+
if (stack) params.set("stack", stack);
|
|
82
|
+
|
|
83
|
+
const url = `${accountApiUrl(cfg)}/integration_kit?${params}`;
|
|
84
|
+
const data = await fetchJson(url, {
|
|
85
|
+
headers: {
|
|
86
|
+
Authorization: `Bearer ${cfg.accountToken}`,
|
|
87
|
+
Accept: "application/json",
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return redactSecrets(data);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function publicResource(name, options = {}) {
|
|
95
|
+
switch (name) {
|
|
96
|
+
case "docs":
|
|
97
|
+
return listDocs(options);
|
|
98
|
+
case "llms":
|
|
99
|
+
return readDoc("llms", options);
|
|
100
|
+
case "knowledge":
|
|
101
|
+
return readDoc("knowledge", options);
|
|
102
|
+
case "openapi":
|
|
103
|
+
return openApi(options);
|
|
104
|
+
case "integration-kit":
|
|
105
|
+
return integrationKit(options);
|
|
106
|
+
case "capabilities":
|
|
107
|
+
return docsCapabilities(options);
|
|
108
|
+
default:
|
|
109
|
+
throw new DocsClientError(`Unknown Switchboard resource: ${name}`, "not_found", 404);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function publicJson(path, { config } = {}) {
|
|
114
|
+
const cfg = config || resolveConfig();
|
|
115
|
+
return fetchJson(`${docsBaseUrl(cfg)}${path}`, {
|
|
116
|
+
headers: { Accept: "application/json" },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function fetchJson(url, init = {}) {
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
const timeout = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS);
|
|
123
|
+
|
|
124
|
+
let res;
|
|
125
|
+
try {
|
|
126
|
+
res = await fetch(url, { ...init, signal: controller.signal });
|
|
127
|
+
} catch (error) {
|
|
128
|
+
throw new DocsClientError(error.message || "Switchboard request failed.", "network_error", 3);
|
|
129
|
+
} finally {
|
|
130
|
+
clearTimeout(timeout);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const text = await res.text();
|
|
134
|
+
let data;
|
|
135
|
+
try {
|
|
136
|
+
data = text ? JSON.parse(text) : null;
|
|
137
|
+
} catch {
|
|
138
|
+
data = { raw: text };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!res.ok) {
|
|
142
|
+
const message = data?.error?.message || text || `HTTP ${res.status}`;
|
|
143
|
+
const type = data?.error?.type || "http_error";
|
|
144
|
+
throw new DocsClientError(message, type, res.status);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return data;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export class DocsClientError extends Error {
|
|
151
|
+
constructor(message, type = "error", status = 1) {
|
|
152
|
+
super(message);
|
|
153
|
+
this.name = "DocsClientError";
|
|
154
|
+
this.type = type;
|
|
155
|
+
this.status = status;
|
|
156
|
+
}
|
|
157
|
+
}
|
package/lib/mcpServer.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedded Switchboard MCP stdio server.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
6
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import {
|
|
9
|
+
docsCapabilities,
|
|
10
|
+
integrationKit,
|
|
11
|
+
listDocs,
|
|
12
|
+
models,
|
|
13
|
+
openApi,
|
|
14
|
+
publicResource,
|
|
15
|
+
readDoc,
|
|
16
|
+
searchDocs,
|
|
17
|
+
} from "./docsClient.js";
|
|
18
|
+
import { redactSecrets } from "./output.js";
|
|
19
|
+
|
|
20
|
+
const SERVER_VERSION = "0.1.0";
|
|
21
|
+
|
|
22
|
+
export async function runMcpServer() {
|
|
23
|
+
const server = createMcpServer();
|
|
24
|
+
await server.connect(new StdioServerTransport());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createMcpServer() {
|
|
28
|
+
const server = new McpServer({
|
|
29
|
+
name: "@switchboard.spot/cli",
|
|
30
|
+
version: SERVER_VERSION,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
registerResources(server);
|
|
34
|
+
registerTools(server);
|
|
35
|
+
|
|
36
|
+
return server;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function registerResources(server) {
|
|
40
|
+
registerJsonResource(server, "switchboard_docs", "switchboard://docs", "Public Switchboard docs catalog", () =>
|
|
41
|
+
listDocs(),
|
|
42
|
+
);
|
|
43
|
+
registerJsonResource(server, "switchboard_llms", "switchboard://llms", "Public llms.txt context", () =>
|
|
44
|
+
publicResource("llms"),
|
|
45
|
+
);
|
|
46
|
+
registerJsonResource(
|
|
47
|
+
server,
|
|
48
|
+
"switchboard_knowledge",
|
|
49
|
+
"switchboard://knowledge",
|
|
50
|
+
"Public Switchboard knowledge context",
|
|
51
|
+
() => publicResource("knowledge"),
|
|
52
|
+
);
|
|
53
|
+
registerJsonResource(server, "switchboard_openapi", "switchboard://openapi", "Switchboard OpenAPI schema", () =>
|
|
54
|
+
openApi(),
|
|
55
|
+
);
|
|
56
|
+
registerJsonResource(
|
|
57
|
+
server,
|
|
58
|
+
"switchboard_integration_kit",
|
|
59
|
+
"switchboard://integration-kit",
|
|
60
|
+
"Project Integration Kit for the selected CLI project",
|
|
61
|
+
() => integrationKit(),
|
|
62
|
+
);
|
|
63
|
+
registerJsonResource(
|
|
64
|
+
server,
|
|
65
|
+
"switchboard_capabilities",
|
|
66
|
+
"switchboard://capabilities",
|
|
67
|
+
"Switchboard hosted MCP capabilities",
|
|
68
|
+
() => docsCapabilities(),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
server.registerResource(
|
|
72
|
+
"switchboard_doc",
|
|
73
|
+
new ResourceTemplate("switchboard://docs/{id}", {
|
|
74
|
+
list: async () => {
|
|
75
|
+
const catalog = await listDocs();
|
|
76
|
+
return {
|
|
77
|
+
resources: (catalog.data || []).map((doc) => ({
|
|
78
|
+
uri: `switchboard://docs/${doc.id}`,
|
|
79
|
+
name: doc.id,
|
|
80
|
+
title: doc.title,
|
|
81
|
+
description: doc.description,
|
|
82
|
+
mimeType: "text/markdown",
|
|
83
|
+
})),
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
{
|
|
88
|
+
title: "Switchboard doc",
|
|
89
|
+
description: "Read one public Switchboard doc by stable id.",
|
|
90
|
+
mimeType: "text/markdown",
|
|
91
|
+
},
|
|
92
|
+
async (_uri, variables) => {
|
|
93
|
+
const doc = await readDoc(String(variables.id));
|
|
94
|
+
return {
|
|
95
|
+
contents: [
|
|
96
|
+
{
|
|
97
|
+
uri: `switchboard://docs/${doc.data.id}`,
|
|
98
|
+
mimeType: doc.data.content_type || "text/markdown",
|
|
99
|
+
text: redactText(doc.data.content),
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function registerTools(server) {
|
|
108
|
+
server.registerTool(
|
|
109
|
+
"switchboard_docs_search",
|
|
110
|
+
{
|
|
111
|
+
title: "Search Switchboard docs",
|
|
112
|
+
description: "Search public Switchboard docs and return bounded snippets.",
|
|
113
|
+
inputSchema: {
|
|
114
|
+
query: z.string().min(1),
|
|
115
|
+
limit: z.number().int().min(1).max(20).optional(),
|
|
116
|
+
},
|
|
117
|
+
annotations: { readOnlyHint: true },
|
|
118
|
+
},
|
|
119
|
+
async ({ query, limit }) => jsonTool(await searchDocs(query, { limit })),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
server.registerTool(
|
|
123
|
+
"switchboard_docs_read",
|
|
124
|
+
{
|
|
125
|
+
title: "Read Switchboard doc",
|
|
126
|
+
description: "Read one public Switchboard doc by stable id.",
|
|
127
|
+
inputSchema: {
|
|
128
|
+
id: z.string().min(1),
|
|
129
|
+
},
|
|
130
|
+
annotations: { readOnlyHint: true },
|
|
131
|
+
},
|
|
132
|
+
async ({ id }) => jsonTool(await readDoc(id)),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
server.registerTool(
|
|
136
|
+
"switchboard_integration_kit",
|
|
137
|
+
{
|
|
138
|
+
title: "Switchboard Integration Kit",
|
|
139
|
+
description: "Return Integration Kit data for the logged-in selected CLI project.",
|
|
140
|
+
inputSchema: {
|
|
141
|
+
stack: z.string().optional(),
|
|
142
|
+
},
|
|
143
|
+
annotations: { readOnlyHint: true },
|
|
144
|
+
},
|
|
145
|
+
async ({ stack }) => jsonTool(await integrationKit({ stack })),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
server.registerTool(
|
|
149
|
+
"switchboard_models",
|
|
150
|
+
{
|
|
151
|
+
title: "Switchboard models",
|
|
152
|
+
description: "List public OpenAI-compatible Switchboard models.",
|
|
153
|
+
annotations: { readOnlyHint: true },
|
|
154
|
+
},
|
|
155
|
+
async () => jsonTool(await models()),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function registerJsonResource(server, name, uri, description, loader) {
|
|
160
|
+
server.registerResource(
|
|
161
|
+
name,
|
|
162
|
+
uri,
|
|
163
|
+
{
|
|
164
|
+
title: name,
|
|
165
|
+
description,
|
|
166
|
+
mimeType: "application/json",
|
|
167
|
+
},
|
|
168
|
+
async () => ({
|
|
169
|
+
contents: [
|
|
170
|
+
{
|
|
171
|
+
uri,
|
|
172
|
+
mimeType: "application/json",
|
|
173
|
+
text: JSON.stringify(redactSecrets(await loader()), null, 2),
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function jsonTool(data) {
|
|
181
|
+
return {
|
|
182
|
+
content: [
|
|
183
|
+
{
|
|
184
|
+
type: "text",
|
|
185
|
+
text: JSON.stringify(redactSecrets(data), null, 2),
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function redactText(text) {
|
|
192
|
+
return typeof text === "string" ? redactSecrets(text) : JSON.stringify(redactSecrets(text), null, 2);
|
|
193
|
+
}
|
package/lib/output.js
CHANGED
|
@@ -2,16 +2,30 @@
|
|
|
2
2
|
* Human-readable and JSON output helpers for the Switchboard CLI.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
const REDACTED = "[REDACTED]";
|
|
6
|
+
|
|
7
|
+
const SECRET_PATTERNS = [
|
|
8
|
+
/\bsb_(?:test|live|sess|eusr)_[A-Za-z0-9._-]+\b/g,
|
|
9
|
+
/\bsk-(?:proj-|ant-)?[A-Za-z0-9._-]{20,}\b/g,
|
|
10
|
+
/\bsk_(?:live|test)_[A-Za-z0-9._-]+\b/g,
|
|
11
|
+
/\brk_(?:live|test)_[A-Za-z0-9._-]+\b/g,
|
|
12
|
+
/\bpk_(?:live|test)_[A-Za-z0-9._-]+\b/g,
|
|
13
|
+
/\bwhsec_[A-Za-z0-9._-]+\b/g,
|
|
14
|
+
/\b0x[0-9A-Za-z_-]{20,}\b/g,
|
|
15
|
+
/\b[A-Za-z0-9_-]{24,}\.[A-Za-z0-9_-]{24,}\.[A-Za-z0-9_-]{24,}\b/g,
|
|
16
|
+
/\b(?:OPENAI|ANTHROPIC|GROQ|MISTRAL|GOOGLE|STRIPE|CLOUDFLARE)_[A-Z0-9_]*(?:KEY|TOKEN|SECRET)\s*=\s*[^\s]+/gi,
|
|
17
|
+
];
|
|
18
|
+
|
|
5
19
|
/**
|
|
6
20
|
* Prints data as JSON or a human message depending on global flags.
|
|
7
21
|
*/
|
|
8
22
|
export function emit(data, { json, quiet } = {}) {
|
|
9
23
|
if (json) {
|
|
10
|
-
console.log(JSON.stringify(data, null, 2));
|
|
24
|
+
console.log(JSON.stringify(redactSecrets(data), null, 2));
|
|
11
25
|
return;
|
|
12
26
|
}
|
|
13
27
|
if (!quiet && typeof data === "string") {
|
|
14
|
-
console.log(data);
|
|
28
|
+
console.log(redactSecrets(data));
|
|
15
29
|
}
|
|
16
30
|
}
|
|
17
31
|
|
|
@@ -35,13 +49,13 @@ export function fail(message, code = 1, json = false, type = "invalid_request")
|
|
|
35
49
|
if (json) {
|
|
36
50
|
console.log(
|
|
37
51
|
JSON.stringify(
|
|
38
|
-
{
|
|
52
|
+
redactSecrets({
|
|
39
53
|
ok: false,
|
|
40
54
|
error: {
|
|
41
55
|
type,
|
|
42
56
|
message,
|
|
43
57
|
},
|
|
44
|
-
},
|
|
58
|
+
}),
|
|
45
59
|
null,
|
|
46
60
|
2,
|
|
47
61
|
),
|
|
@@ -49,7 +63,7 @@ export function fail(message, code = 1, json = false, type = "invalid_request")
|
|
|
49
63
|
process.exit(code);
|
|
50
64
|
}
|
|
51
65
|
|
|
52
|
-
console.error(message);
|
|
66
|
+
console.error(redactSecrets(message));
|
|
53
67
|
process.exit(code);
|
|
54
68
|
}
|
|
55
69
|
|
|
@@ -93,9 +107,9 @@ export function normalizeError(data, text, status) {
|
|
|
93
107
|
|
|
94
108
|
export function emitHttpError(error, status, flags = {}) {
|
|
95
109
|
if (flags.json) {
|
|
96
|
-
console.log(JSON.stringify({ ok: false, status, error }, null, 2));
|
|
110
|
+
console.log(JSON.stringify(redactSecrets({ ok: false, status, error }), null, 2));
|
|
97
111
|
} else {
|
|
98
|
-
console.error(error.message);
|
|
112
|
+
console.error(redactSecrets(error.message));
|
|
99
113
|
}
|
|
100
114
|
|
|
101
115
|
process.exit(exitCodeForStatus(status));
|
|
@@ -105,8 +119,50 @@ export function emitHttpError(error, status, flags = {}) {
|
|
|
105
119
|
* Formats a simple key-value listing for human output.
|
|
106
120
|
*/
|
|
107
121
|
export function printList(title, items, formatter) {
|
|
108
|
-
console.log(title);
|
|
122
|
+
console.log(redactSecrets(title));
|
|
109
123
|
for (const item of items) {
|
|
110
|
-
console.log(formatter(item));
|
|
124
|
+
console.log(redactSecrets(formatter(item)));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function redactSecrets(value) {
|
|
129
|
+
if (typeof value === "string") return redactString(value);
|
|
130
|
+
if (Array.isArray(value)) return value.map((entry) => redactSecrets(entry));
|
|
131
|
+
if (!value || typeof value !== "object") return value;
|
|
132
|
+
|
|
133
|
+
const redacted = {};
|
|
134
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
135
|
+
redacted[key] = isSecretKeyName(key) ? redactKnownPublicKey(key, entry) : redactSecrets(entry);
|
|
111
136
|
}
|
|
137
|
+
|
|
138
|
+
return redacted;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function redactKnownPublicKey(key, value) {
|
|
142
|
+
if (key === "site_key" || key === "turnstile_site_key") return redactSecrets(value);
|
|
143
|
+
return typeof value === "boolean" || value == null ? value : REDACTED;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function redactString(value) {
|
|
147
|
+
return SECRET_PATTERNS.reduce(
|
|
148
|
+
(text, pattern) => text.replace(pattern, (match) => redactAssignment(match)),
|
|
149
|
+
value,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function redactAssignment(match) {
|
|
154
|
+
const index = match.indexOf("=");
|
|
155
|
+
if (index === -1) return REDACTED;
|
|
156
|
+
return `${match.slice(0, index + 1)}${REDACTED}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isSecretKeyName(key) {
|
|
160
|
+
const normalized = key.replace(/[A-Z]/g, "_$&").toLowerCase();
|
|
161
|
+
if (normalized.endsWith("_count") || normalized.endsWith("_configured")) return false;
|
|
162
|
+
if (normalized === "site_key" || normalized === "turnstile_site_key") return false;
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
/(^|_)(secret|token|password|plaintext|session)($|_)/.test(normalized) ||
|
|
166
|
+
/(^|_)api_key($|_)/.test(normalized)
|
|
167
|
+
);
|
|
112
168
|
}
|
package/lib/verify/index.js
CHANGED
|
@@ -70,6 +70,8 @@ const PROTECTED_AUTH_HOST_PATTERNS = [
|
|
|
70
70
|
];
|
|
71
71
|
|
|
72
72
|
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0"]);
|
|
73
|
+
const DEV_BROWSER_CHALLENGE_TOKEN = "dev_browser_challenge";
|
|
74
|
+
const DEFAULT_BROWSER_CHALLENGE_TOKEN = "switchboard-verification";
|
|
73
75
|
|
|
74
76
|
export function loadScenario(filePath) {
|
|
75
77
|
if (!filePath) return DEFAULT_SCENARIO;
|
|
@@ -427,7 +429,7 @@ async function executeScenarioCheck({
|
|
|
427
429
|
const result = await browserFetchJson(page, clientUrl, "/auth/anonymous/session", {
|
|
428
430
|
method: "POST",
|
|
429
431
|
body: {
|
|
430
|
-
browser_challenge_token: check
|
|
432
|
+
browser_challenge_token: browserChallengeTokenFor(check, clientUrl),
|
|
431
433
|
},
|
|
432
434
|
});
|
|
433
435
|
if (result.data?.token) {
|
|
@@ -478,14 +480,16 @@ async function executeScenarioCheck({
|
|
|
478
480
|
}
|
|
479
481
|
|
|
480
482
|
async function browserFetchJson(page, clientUrl, endpointPath, init) {
|
|
483
|
+
const endpointUrl = clientEndpointUrl(clientUrl, endpointPath);
|
|
484
|
+
|
|
481
485
|
return page.evaluate(
|
|
482
|
-
async ({
|
|
486
|
+
async ({ endpointUrl, init }) => {
|
|
483
487
|
try {
|
|
484
488
|
const headers = new Headers(init.headers ?? {});
|
|
485
489
|
headers.set("Accept", "application/json");
|
|
486
490
|
if (init.body) headers.set("Content-Type", "application/json");
|
|
487
491
|
|
|
488
|
-
const response = await fetch(
|
|
492
|
+
const response = await fetch(endpointUrl, {
|
|
489
493
|
method: init.method,
|
|
490
494
|
headers,
|
|
491
495
|
body: init.body ? JSON.stringify(init.body) : undefined,
|
|
@@ -508,10 +512,18 @@ async function browserFetchJson(page, clientUrl, endpointPath, init) {
|
|
|
508
512
|
return { ok: false, status: 0, data: null, error: error.message };
|
|
509
513
|
}
|
|
510
514
|
},
|
|
511
|
-
{
|
|
515
|
+
{ endpointUrl, init },
|
|
512
516
|
);
|
|
513
517
|
}
|
|
514
518
|
|
|
519
|
+
function clientEndpointUrl(clientUrl, endpointPath) {
|
|
520
|
+
const endpoint = endpointPath.startsWith("/") ? endpointPath.slice(1) : endpointPath;
|
|
521
|
+
const basePath = clientUrl.pathname.endsWith("/") ? clientUrl.pathname : `${clientUrl.pathname}/`;
|
|
522
|
+
const url = new URL(clientUrl.href);
|
|
523
|
+
url.pathname = `${basePath}${endpoint}`.replace(/\/{2,}/g, "/");
|
|
524
|
+
return url.href;
|
|
525
|
+
}
|
|
526
|
+
|
|
515
527
|
function wireEvidence(page, evidence) {
|
|
516
528
|
page.on("console", (message) => {
|
|
517
529
|
evidence.consoleMessages.push({
|
|
@@ -709,6 +721,22 @@ function normalizedHostname(url) {
|
|
|
709
721
|
return url.hostname.replace(/^\[|\]$/g, "");
|
|
710
722
|
}
|
|
711
723
|
|
|
724
|
+
function browserChallengeTokenFor(check, clientUrl) {
|
|
725
|
+
if (Object.prototype.hasOwnProperty.call(check, "browserChallengeToken")) {
|
|
726
|
+
return check.browserChallengeToken;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (isLocalSwitchboardUrl(clientUrl)) {
|
|
730
|
+
return DEV_BROWSER_CHALLENGE_TOKEN;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return DEFAULT_BROWSER_CHALLENGE_TOKEN;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function isLocalSwitchboardUrl(url) {
|
|
737
|
+
return url instanceof URL && LOCAL_HOSTS.has(normalizedHostname(url));
|
|
738
|
+
}
|
|
739
|
+
|
|
712
740
|
function addFailure(report, checkId, message, finding) {
|
|
713
741
|
addCheck(report, { id: checkId, status: "failed", message });
|
|
714
742
|
report.findings.push({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@switchboard.spot/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Switchboard CLI — full dashboard parity for agents and testing",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
"coverage": "node --test --experimental-test-coverage --test-coverage-include='bin/**/*.js' --test-coverage-include='lib/**/*.js' test/*.test.js"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
20
21
|
"commander": "^13.1.0",
|
|
21
|
-
"playwright": "^1.61.0"
|
|
22
|
+
"playwright": "^1.61.0",
|
|
23
|
+
"zod": "^4.4.3"
|
|
22
24
|
},
|
|
23
25
|
"files": [
|
|
24
26
|
"bin/",
|