@switchboard.spot/cli 0.2.1 → 0.2.3

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.
@@ -3,22 +3,53 @@
3
3
  */
4
4
 
5
5
  import { configureEnvironment } from "./env.js";
6
- import { emit, fail, globalFlags } from "../output.js";
6
+ import { accountRequest } from "../client.js";
7
+ import { emit, fail, globalFlags, redactSecrets } from "../output.js";
8
+ import { resolveConfig } from "../config.js";
9
+ import { runDoctorChecks } from "./doctor.js";
10
+ import {
11
+ ensureProject,
12
+ fetchProject,
13
+ provisionManagedTurnstile,
14
+ requestAccessIfNeeded,
15
+ saveProjectConfig,
16
+ validateLaunchOptions,
17
+ } from "./launch.js";
7
18
 
8
19
  export function registerSetupCommand(program) {
9
- program
20
+ const setup = program
10
21
  .command("setup")
11
- .description("Configure Switchboard for client apps, server apps, or both")
22
+ .description("Configure browser-ready Switchboard projects");
23
+
24
+ setup
25
+ .command("project")
26
+ .description("Create or configure a browser-ready Switchboard project")
27
+ .option("--project-id <id>", "Existing project id")
28
+ .option("--project-name <name>", "Create a project when no project id is provided")
29
+ .option("--project-slug <slug>", "Slug for a project created by this command")
30
+ .requiredOption("--origin <origin>", "Exact local, preview, or sandbox browser origin")
12
31
  .option("--target <target>", "client, server, or both", "client")
13
32
  .option("--file <path>", "Local env file to update", ".env.local")
14
33
  .option("--secret-target <target>", "local or exec", "local")
15
34
  .option("--secret-command <command>", "Command that stores secrets from stdin JSON")
16
- .option("--project-id <id>", "Override selected project")
17
- .option("--force", "Replace existing Switchboard-managed values")
35
+ .option("--force", "Replace existing Switchboard-managed env values and project origins")
36
+ .option(
37
+ "--production-origin <origin>",
38
+ "Exact HTTPS production origin, for example https://app.example.com",
39
+ )
40
+ .option("--end-user-terms-url <url>")
41
+ .option("--end-user-privacy-url <url>")
42
+ .option("--support-url <url>")
43
+ .option("--support-email <email>")
44
+ .option("--contact-email <email>")
45
+ .option("--use-case <text>")
46
+ .option("--expected-monthly-volume <volume>")
47
+ .option("--needed-billing-mode <mode>", "Billing mode needed for production")
48
+ .option("--notes <text>")
18
49
  .action(async (opts, cmd) => {
19
50
  const flags = globalFlags(cmd);
20
- const result = await setupSwitchboard(opts, { json: flags.json });
21
- emit(flags.json ? result : humanSetupMessage(result), flags);
51
+ const result = await setupProject(opts, { json: flags.json });
52
+ emit(flags.json ? result : humanProjectSetupMessage(result), flags);
22
53
  });
23
54
  }
24
55
 
@@ -77,3 +108,247 @@ function humanSetupMessage(result) {
77
108
  const skipped = result.skipped.length > 0 ? ` Skipped existing: ${result.skipped.join(", ")}.` : "";
78
109
  return `Configured Switchboard setup (${result.target}) for project ${result.project_id}. Changed: ${changed}.${skipped}`;
79
110
  }
111
+
112
+ export async function setupProject(
113
+ opts,
114
+ {
115
+ request = accountRequest,
116
+ configureEnv = configureEnvironment,
117
+ doctor = runDoctorChecks,
118
+ saveProject = saveProjectConfig,
119
+ config = resolveConfig(),
120
+ cwd,
121
+ json = false,
122
+ getSecret,
123
+ setSecret,
124
+ runSecretCommand,
125
+ } = {},
126
+ ) {
127
+ validateProjectSetupOptions(opts, { json });
128
+
129
+ const flags = { json };
130
+ const project = await ensureProject(opts, flags, { request, save: saveProject });
131
+ saveProject(project);
132
+
133
+ const origins = configuredOrigins(project, opts);
134
+ const configured = await updateSetupProject(project.id, opts, origins, flags, {
135
+ request,
136
+ save: saveProject,
137
+ });
138
+
139
+ const env = await configureEnv(
140
+ {
141
+ mode: normalizeSetupTarget(opts.target, json),
142
+ file: opts.file,
143
+ target: opts.secretTarget,
144
+ secretCommand: opts.secretCommand,
145
+ projectId: String(configured.id),
146
+ force: opts.force,
147
+ },
148
+ { request, cwd, json, getSecret, setSecret, runSecretCommand },
149
+ );
150
+
151
+ const idempotencyKey = `setup-project-${configured.id}`;
152
+ const challenge = await provisionManagedTurnstile(configured.id, idempotencyKey, flags, {
153
+ request,
154
+ });
155
+
156
+ let access = null;
157
+ if (hasProductionOptions(opts)) {
158
+ access = await requestAccessIfNeeded(configured.id, configured, opts, flags, { request });
159
+ }
160
+
161
+ const latest = await fetchProject(configured.id, flags, { request });
162
+ saveProject(latest);
163
+
164
+ const doctorConfig = {
165
+ ...config,
166
+ projectId: String(latest.id),
167
+ virtualMicroserviceUrl: latest.virtual_microservice_url || config.virtualMicroserviceUrl,
168
+ };
169
+ const readiness = await doctor({ config: doctorConfig, request });
170
+
171
+ return projectSetupSummary({
172
+ project: latest,
173
+ env,
174
+ challenge,
175
+ readiness,
176
+ access,
177
+ origin: opts.origin,
178
+ origins,
179
+ });
180
+ }
181
+
182
+ function validateProjectSetupOptions(opts, flags) {
183
+ if ((opts.projectName && !opts.projectSlug) || (!opts.projectName && opts.projectSlug)) {
184
+ fail("--project-name and --project-slug must be provided together", 1, flags.json);
185
+ }
186
+
187
+ if (!exactOrigin(opts.origin)) {
188
+ fail("--origin must be an exact browser origin such as http://127.0.0.1:4173", 1, flags.json);
189
+ }
190
+
191
+ if (!hasProductionOptions(opts)) return;
192
+
193
+ validateLaunchOptions(opts, flags);
194
+
195
+ for (const name of [
196
+ "endUserTermsUrl",
197
+ "endUserPrivacyUrl",
198
+ "supportEmail",
199
+ "contactEmail",
200
+ "useCase",
201
+ ]) {
202
+ if (!opts[name]) {
203
+ fail(`--${dasherize(name)} is required when production launch fields are provided`, 1, flags.json);
204
+ }
205
+ }
206
+ }
207
+
208
+ function hasProductionOptions(opts) {
209
+ return [
210
+ "productionOrigin",
211
+ "endUserTermsUrl",
212
+ "endUserPrivacyUrl",
213
+ "supportUrl",
214
+ "supportEmail",
215
+ "contactEmail",
216
+ "useCase",
217
+ "expectedMonthlyVolume",
218
+ "neededBillingMode",
219
+ "notes",
220
+ ].some((key) => opts[key] != null);
221
+ }
222
+
223
+ function configuredOrigins(project, opts) {
224
+ const requested = [opts.origin];
225
+ if (opts.productionOrigin) requested.push(opts.productionOrigin);
226
+
227
+ if (opts.force) return uniqueStrings(requested);
228
+
229
+ const existing = Array.isArray(project.allowed_origins) ? project.allowed_origins : [];
230
+ return uniqueStrings([...existing, ...requested]);
231
+ }
232
+
233
+ async function updateSetupProject(projectId, opts, origins, flags, { request, save }) {
234
+ const body = {
235
+ allowed_origins: origins,
236
+ virtual_microservice_enabled: true,
237
+ };
238
+
239
+ if (hasProductionOptions(opts)) {
240
+ body.end_user_terms_url = opts.endUserTermsUrl;
241
+ body.end_user_privacy_url = opts.endUserPrivacyUrl;
242
+ body.support_email = opts.supportEmail;
243
+ if (opts.supportUrl) body.support_url = opts.supportUrl;
244
+ }
245
+
246
+ const { data } = await request("PATCH", `/projects/${projectId}`, {
247
+ body,
248
+ json: flags.json,
249
+ });
250
+ save(data);
251
+ return data;
252
+ }
253
+
254
+ function projectSetupSummary({ project, env, challenge, readiness, access, origin, origins }) {
255
+ const hardFailures = readiness.checks.filter((check) => !check.ok).map((check) => check.name);
256
+ const configured = hardFailures.length === 0;
257
+ const productionRequested = Boolean(access);
258
+
259
+ return {
260
+ object: "setup_project_result",
261
+ ok: configured,
262
+ status: !configured
263
+ ? "failed"
264
+ : productionRequested
265
+ ? "production_requested"
266
+ : "ready_for_browser_manual_check",
267
+ configured,
268
+ ready_for_browser_manual_check: configured,
269
+ production_requested: productionRequested,
270
+ project_id: project.id,
271
+ project_slug: project.slug,
272
+ client_gateway_url: project.virtual_microservice_url,
273
+ allowed_origins: origins,
274
+ local_origin: origin,
275
+ turnstile: challenge,
276
+ env_file: env.env_file,
277
+ env,
278
+ doctor: readiness,
279
+ hard_failures: hardFailures,
280
+ production_access: access,
281
+ next_steps: [
282
+ "Use the SDK-managed browser challenge in your app to confirm browser chat manually.",
283
+ "Run switchboard verify setup after the app integration is wired.",
284
+ productionRequested
285
+ ? "Run switchboard verify publish after approval and billing readiness are complete."
286
+ : "Use switchboard launch prepare when you are ready for production approval.",
287
+ ],
288
+ };
289
+ }
290
+
291
+ export function humanProjectSetupMessage(result) {
292
+ const lines = [
293
+ `Configured project ${result.project_slug || result.project_id} (${result.project_id})`,
294
+ `Client Gateway: ${result.client_gateway_url}`,
295
+ `Allowed origins: ${result.allowed_origins.join(", ")}`,
296
+ `Turnstile: ${turnstileStatus(result.turnstile)}`,
297
+ `Env file: ${result.env_file}`,
298
+ `Readiness: ${result.configured ? "ready for browser manual check" : "failed"}`,
299
+ ];
300
+
301
+ if (result.production_requested) {
302
+ lines.push(
303
+ `Production access: ${
304
+ result.production_access?.project_production_access_status ||
305
+ result.production_access?.status ||
306
+ "requested"
307
+ }`,
308
+ );
309
+ }
310
+
311
+ if (result.hard_failures.length > 0) {
312
+ lines.push(`Hard failures: ${result.hard_failures.join(", ")}`);
313
+ }
314
+
315
+ lines.push(
316
+ "Next: integrate the SDK browser challenge in your app, then run switchboard verify setup.",
317
+ );
318
+
319
+ if (result.production_requested) {
320
+ lines.push("Next: run switchboard verify publish after production approval and billing readiness.");
321
+ }
322
+
323
+ return lines.join("\n");
324
+ }
325
+
326
+ function turnstileStatus(challenge) {
327
+ if (!challenge) return "unknown";
328
+ return redactSecrets(challenge.status || challenge.provider || challenge.object || "provisioned");
329
+ }
330
+
331
+ function exactOrigin(origin) {
332
+ try {
333
+ const url = new URL(origin);
334
+ return (
335
+ (url.protocol === "http:" || url.protocol === "https:") &&
336
+ url.username === "" &&
337
+ url.password === "" &&
338
+ url.pathname === "/" &&
339
+ url.search === "" &&
340
+ url.hash === "" &&
341
+ !url.hostname.includes("*")
342
+ );
343
+ } catch {
344
+ return false;
345
+ }
346
+ }
347
+
348
+ function uniqueStrings(values) {
349
+ return [...new Set(values.filter(Boolean))];
350
+ }
351
+
352
+ function dasherize(value) {
353
+ return value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
354
+ }
@@ -15,7 +15,7 @@ export function registerVerifyCommands(program) {
15
15
  .command("setup")
16
16
  .description("Verify a local or preview Switchboard integration setup")
17
17
  .option("--url <app-url>", "Application URL to test")
18
- .option("--client-url <switchboard-client-url>", "Switchboard Pro-bono Embed client URL"),
18
+ .option("--client-url <switchboard-client-url>", "Switchboard Client Gateway client URL"),
19
19
  ).action(async (opts, cmd) => {
20
20
  await runVerifyCommand("setup", opts, cmd);
21
21
  });
@@ -25,7 +25,7 @@ export function registerVerifyCommands(program) {
25
25
  .command("publish")
26
26
  .description("Verify an HTTPS preview is safe to publish")
27
27
  .option("--url <preview-url>", "HTTPS preview URL to test")
28
- .option("--client-url <switchboard-client-url>", "Switchboard Pro-bono Embed client URL")
28
+ .option("--client-url <switchboard-client-url>", "Switchboard Client Gateway client URL")
29
29
  .option("--production-origin <origin>", "Production origin that must be allowed"),
30
30
  )
31
31
  .option("--project-id <id>", "Switchboard project id for production safety checks")
@@ -38,7 +38,7 @@ export function registerVerifyCommands(program) {
38
38
  .command("browser")
39
39
  .description("Run a declarative browser verification scenario")
40
40
  .option("--url <app-url>", "Application URL to test")
41
- .option("--client-url <switchboard-client-url>", "Switchboard Pro-bono Embed client URL"),
41
+ .option("--client-url <switchboard-client-url>", "Switchboard Client Gateway client URL"),
42
42
  ).action(async (opts, cmd) => {
43
43
  await runVerifyCommand("browser", opts, cmd);
44
44
  });
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
+ }