@switchboard.spot/cli 0.2.1 → 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.
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Production launch orchestration commands.
3
+ */
4
+
5
+ import { accountRequest } from "../client.js";
6
+ import { saveConfig } from "../config.js";
7
+ import { emit, fail, globalFlags } from "../output.js";
8
+ import { buildProductionAccessRequestBody } from "./projects.js";
9
+
10
+ export function registerLaunchCommands(program) {
11
+ const launch = program.command("launch").description("Production launch workflows");
12
+
13
+ launch
14
+ .command("prepare")
15
+ .description("Prepare a project for production Client Gateway launch")
16
+ .option("--project-id <id>", "Existing project id")
17
+ .option("--project-name <name>", "Create a project when no project id is provided")
18
+ .option("--project-slug <slug>", "Slug for a project created by this command")
19
+ .requiredOption(
20
+ "--production-origin <origin>",
21
+ "Exact HTTPS production origin, for example https://app.example.com",
22
+ )
23
+ .requiredOption("--end-user-terms-url <url>")
24
+ .requiredOption("--end-user-privacy-url <url>")
25
+ .option("--support-url <url>")
26
+ .requiredOption("--support-email <email>")
27
+ .requiredOption("--contact-email <email>")
28
+ .requiredOption("--use-case <text>")
29
+ .option("--expected-monthly-volume <volume>")
30
+ .option("--needed-billing-mode <mode>", "Billing mode needed for production", "developer_paid")
31
+ .option("--notes <text>")
32
+ .option("--idempotency-key <key>")
33
+ .action(async (opts, cmd) => {
34
+ const flags = globalFlags(cmd);
35
+ validateLaunchOptions(opts, flags);
36
+
37
+ const project = await ensureProject(opts, flags);
38
+ const idempotencyKey = opts.idempotencyKey || `launch-prepare-project-${project.id}`;
39
+
40
+ const configured = await updateLaunchProject(project.id, opts, flags);
41
+ const challenge = await provisionManagedTurnstile(project.id, idempotencyKey, flags);
42
+ const access = await requestAccessIfNeeded(project.id, configured, opts, flags);
43
+ const readiness = await fetchProject(project.id, flags);
44
+
45
+ emit(
46
+ flags.json
47
+ ? launchSummary(readiness, challenge, access)
48
+ : humanLaunchSummary(readiness, access),
49
+ flags,
50
+ );
51
+ });
52
+ }
53
+
54
+ function validateLaunchOptions(opts, flags) {
55
+ if (!productionHttpsOrigin(opts.productionOrigin)) {
56
+ fail("--production-origin must be an exact HTTPS production origin", 1, flags.json);
57
+ }
58
+ if ((opts.projectName && !opts.projectSlug) || (!opts.projectName && opts.projectSlug)) {
59
+ fail("--project-name and --project-slug must be provided together", 1, flags.json);
60
+ }
61
+ }
62
+
63
+ async function ensureProject(opts, flags) {
64
+ if (opts.projectId) return fetchProject(opts.projectId, flags);
65
+
66
+ if (opts.projectName) {
67
+ const { data } = await accountRequest("POST", "/projects", {
68
+ body: { name: opts.projectName, slug: opts.projectSlug },
69
+ json: flags.json,
70
+ });
71
+ saveProjectConfig(data);
72
+ return data;
73
+ }
74
+
75
+ const { data } = await accountRequest("GET", "/me", { json: flags.json });
76
+ if (!data.project?.id) {
77
+ fail(
78
+ "No default project is selected. Use --project-id or --project-name with --project-slug.",
79
+ 1,
80
+ flags.json,
81
+ );
82
+ }
83
+ return data.project;
84
+ }
85
+
86
+ async function updateLaunchProject(projectId, opts, flags) {
87
+ const body = {
88
+ allowed_origins: [opts.productionOrigin],
89
+ end_user_terms_url: opts.endUserTermsUrl,
90
+ end_user_privacy_url: opts.endUserPrivacyUrl,
91
+ support_email: opts.supportEmail,
92
+ virtual_microservice_enabled: true,
93
+ };
94
+
95
+ if (opts.supportUrl) body.support_url = opts.supportUrl;
96
+
97
+ const { data } = await accountRequest("PATCH", `/projects/${projectId}`, {
98
+ body,
99
+ json: flags.json,
100
+ });
101
+ saveProjectConfig(data);
102
+ return data;
103
+ }
104
+
105
+ async function provisionManagedTurnstile(projectId, idempotencyKey, flags) {
106
+ const { data } = await accountRequest("POST", `/projects/${projectId}/turnstile/provision`, {
107
+ body: { idempotency_key: `${idempotencyKey}:turnstile` },
108
+ json: flags.json,
109
+ });
110
+ return data;
111
+ }
112
+
113
+ async function requestAccessIfNeeded(projectId, project, opts, flags) {
114
+ if (project.production_access_status === "approved") {
115
+ return {
116
+ object: "production_access_request",
117
+ project_id: project.id,
118
+ project_production_access_status: "approved",
119
+ status: "approved",
120
+ };
121
+ }
122
+
123
+ const { data } = await accountRequest("POST", `/projects/${projectId}/production_access_request`, {
124
+ body: buildProductionAccessRequestBody(opts),
125
+ json: flags.json,
126
+ });
127
+ return data;
128
+ }
129
+
130
+ async function fetchProject(projectId, flags) {
131
+ const { data } = await accountRequest("GET", `/projects/${projectId}`, { json: flags.json });
132
+ return data;
133
+ }
134
+
135
+ function saveProjectConfig(project) {
136
+ saveConfig({
137
+ projectId: String(project.id),
138
+ apiKey: null,
139
+ virtualMicroserviceUrl: project.virtual_microservice_url || null,
140
+ endUserSession: null,
141
+ });
142
+ }
143
+
144
+ function launchSummary(project, challenge, access) {
145
+ return {
146
+ object: "launch_prepare_result",
147
+ project_id: project.id,
148
+ project_slug: project.slug,
149
+ virtual_microservice_url: project.virtual_microservice_url,
150
+ browser_challenge: challenge,
151
+ production_access: access,
152
+ production_safety: project.production_safety,
153
+ next_steps: [
154
+ "Run switchboard verify setup.",
155
+ "Use the SDK-managed browser challenge to mint browser sessions.",
156
+ "Run switchboard verify publish after approval and billing readiness are complete.",
157
+ ],
158
+ };
159
+ }
160
+
161
+ function humanLaunchSummary(project, access) {
162
+ const blocked = (project.production_safety?.checks || [])
163
+ .filter((check) => check.status !== "ready")
164
+ .map((check) => check.id);
165
+
166
+ return [
167
+ `Prepared project ${project.name} (${project.id})`,
168
+ `Client Gateway: ${project.virtual_microservice_url}`,
169
+ `Production access: ${access.project_production_access_status || access.status}`,
170
+ `Readiness: ${project.production_safety?.status || "unknown"}`,
171
+ blocked.length ? `Blocked checks: ${blocked.join(", ")}` : "Blocked checks: none",
172
+ "Next: run switchboard verify setup, then switchboard verify publish after approval and billing readiness.",
173
+ ].join("\n");
174
+ }
175
+
176
+ function productionHttpsOrigin(origin) {
177
+ try {
178
+ const url = new URL(origin);
179
+ return (
180
+ url.protocol === "https:" &&
181
+ url.username === "" &&
182
+ url.password === "" &&
183
+ url.pathname === "/" &&
184
+ url.search === "" &&
185
+ url.hash === "" &&
186
+ !["localhost", "127.0.0.1", "::1"].includes(url.hostname) &&
187
+ !url.hostname.includes("*")
188
+ );
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
@@ -90,10 +90,9 @@ export function registerProjectsCommands(program) {
90
90
 
91
91
  projects
92
92
  .command("turnstile <id>")
93
- .description("Configure project-owned Turnstile keys")
93
+ .description("Configure project-owned public Turnstile metadata")
94
94
  .option("--site-key <key>")
95
- .option("--secret-key <key>")
96
- .option("--clear", "Remove project-owned Turnstile keys")
95
+ .option("--clear", "Remove project-owned Turnstile metadata")
97
96
  .action(async (id, opts, cmd) => {
98
97
  const flags = globalFlags(cmd);
99
98
  const body = buildTurnstileBody(opts);
@@ -104,6 +103,39 @@ export function registerProjectsCommands(program) {
104
103
  emit(flags.json ? data : `Updated Turnstile settings for ${data.name}`, flags);
105
104
  });
106
105
 
106
+ projects
107
+ .command("provision-turnstile <id>")
108
+ .description("Provision Switchboard-managed Turnstile for a project")
109
+ .option("--idempotency-key <key>")
110
+ .action(async (id, opts, cmd) => {
111
+ const flags = globalFlags(cmd);
112
+ const body = {};
113
+ if (opts.idempotencyKey) body.idempotency_key = opts.idempotencyKey;
114
+
115
+ const { data } = await accountRequest("POST", `/projects/${id}/turnstile/provision`, {
116
+ body,
117
+ json: flags.json,
118
+ });
119
+ emit(flags.json ? data : `Provisioned managed Turnstile for project ${id}`, flags);
120
+ });
121
+
122
+ projects
123
+ .command("request-production-access <id>")
124
+ .description("Submit a production access request")
125
+ .requiredOption("--contact-email <email>")
126
+ .requiredOption("--use-case <text>")
127
+ .option("--expected-monthly-volume <volume>")
128
+ .option("--needed-billing-mode <mode>", "Billing mode needed for production", "developer_paid")
129
+ .option("--notes <text>")
130
+ .action(async (id, opts, cmd) => {
131
+ const flags = globalFlags(cmd);
132
+ const { data } = await accountRequest("POST", `/projects/${id}/production_access_request`, {
133
+ body: buildProductionAccessRequestBody(opts),
134
+ json: flags.json,
135
+ });
136
+ emit(flags.json ? data : `Submitted production access request for project ${id}`, flags);
137
+ });
138
+
107
139
  projects
108
140
  .command("use <id>")
109
141
  .description("Set default project for subsequent commands")
@@ -186,7 +218,21 @@ export function buildTurnstileBody(opts) {
186
218
 
187
219
  const body = {};
188
220
  if (opts.siteKey) body.turnstile_site_key = opts.siteKey;
189
- if (opts.secretKey) body.turnstile_secret_key = opts.secretKey;
221
+ return body;
222
+ }
223
+
224
+ export function buildProductionAccessRequestBody(opts) {
225
+ const body = {
226
+ contact_email: opts.contactEmail,
227
+ use_case: opts.useCase,
228
+ needed_billing_mode: opts.neededBillingMode || "developer_paid",
229
+ };
230
+
231
+ if (opts.expectedMonthlyVolume) {
232
+ body.expected_monthly_volume = opts.expectedMonthlyVolume;
233
+ }
234
+ if (opts.notes) body.notes = opts.notes;
235
+
190
236
  return body;
191
237
  }
192
238
 
package/lib/config.js CHANGED
@@ -74,7 +74,10 @@ export function resolveConfig() {
74
74
  return {
75
75
  baseUrl: process.env.SWITCHBOARD_BASE_URL || file.baseUrl || DEFAULT_BASE_URL,
76
76
  virtualMicroserviceUrl:
77
- process.env.SWITCHBOARD_CLIENT_URL || file.virtualMicroserviceUrl || null,
77
+ process.env.SWITCHBOARD_CLIENT_URL ||
78
+ process.env.VITE_SWITCHBOARD_CLIENT_URL ||
79
+ file.virtualMicroserviceUrl ||
80
+ null,
78
81
  accountToken: null,
79
82
  accountTokenSource: null,
80
83
  apiKey: process.env.SWITCHBOARD_API_KEY || null,
@@ -106,21 +109,21 @@ export async function resolveAccountConfig(config) {
106
109
  }
107
110
 
108
111
  const base = config || resolveConfig();
109
- const keychain = await readKeychainAccountToken();
110
- if (keychain.token) {
112
+ const configDirToken = getConfigDirAccountToken();
113
+ if (configDirToken) {
111
114
  return {
112
115
  ...base,
113
- accountToken: keychain.token,
114
- accountTokenSource: "keychain",
116
+ accountToken: configDirToken,
117
+ accountTokenSource: "config-dir",
115
118
  };
116
119
  }
117
120
 
118
- const configDirToken = getConfigDirAccountToken();
119
- if (configDirToken) {
121
+ const keychain = await readKeychainAccountToken();
122
+ if (keychain.token) {
120
123
  return {
121
124
  ...base,
122
- accountToken: configDirToken,
123
- accountTokenSource: "config-dir",
125
+ accountToken: keychain.token,
126
+ accountTokenSource: "keychain",
124
127
  };
125
128
  }
126
129
 
@@ -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
+ }
@@ -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
+ }