@switchboard.spot/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.
package/README.md CHANGED
@@ -55,32 +55,30 @@ If a first publish fails, `npm view @switchboard.spot/cli` will continue to retu
55
55
  ```bash
56
56
  switchboard auth login
57
57
  switchboard projects create --name "My App" --slug my-app
58
- switchboard setup --target client --json
59
- switchboard projects update <project-id> --allowed-origins http://localhost:5173 --virtual-microservice-enabled true
60
- switchboard projects provision-turnstile <project-id>
61
- switchboard verify setup
58
+ switchboard setup project --origin http://localhost:5173 --json
59
+ switchboard verify setup --app-url <app-url>
62
60
  switchboard launch prepare --production-origin https://app.example.com --end-user-terms-url https://app.example.com/terms --end-user-privacy-url https://app.example.com/privacy --support-email support@example.com --contact-email owner@example.com --use-case "Browser chat for signed-in customers"
63
- switchboard verify publish
61
+ switchboard verify publish --app-url <preview-url>
64
62
  ```
65
63
 
66
64
  Use `--json` for automation, CI, and coding agents.
67
65
 
68
- `setup --target client` writes public Client Gateway environment values such as `VITE_SWITCHBOARD_CLIENT_URL`; it does not prove chat or session readiness. Before SDK browser testing, configure the exact local or preview origin and provision Switchboard-managed Turnstile.
66
+ `setup project --origin <origin>` writes public Client Gateway environment values such as `VITE_SWITCHBOARD_CLIENT_URL`, configures the exact local or preview origin, enables Client Gateway, and provisions Switchboard-managed Turnstile. It reports browser chat verification as a manual SDK check.
69
67
 
70
68
  CLI account login does not create Client Gateway end-user sessions. End-user sign-up, sign-in, and refresh require the SDK-managed browser challenge; curl, Node scripts, CI, and CLI account login cannot mint browser sessions without running that real browser/mobile flow.
71
69
 
72
- For local Switchboard development, `switchboard verify setup --client-url http://localhost:4000/m/<slug>/v1` uses the built-in `dev_browser_challenge` token unless a scenario supplies an explicit `browserChallengeToken`. Hosted Switchboard should use managed Turnstile and the SDK-managed real browser challenge. A local sandbox smoke path is: configure allowed origin plus legal/support fields, create an anonymous session through the SDK or browser verification flow, then call Client Gateway chat.
70
+ For local Switchboard development, `switchboard verify setup --app-url <app-url> --client-url http://localhost:4000/m/<slug>/v1` uses the built-in `dev_browser_challenge` token unless a scenario supplies an explicit `browserChallengeToken`. Hosted Switchboard should use managed Turnstile and the SDK-managed real browser challenge. A local sandbox smoke path is: configure allowed origin plus legal/support fields, create an anonymous session through the SDK or browser verification flow, then call Client Gateway chat.
73
71
 
74
72
  Model discovery is global. Use `GET /v1/models` for OpenAI-compatible discovery or `GET /v1/catalog/models` for catalog/pricing metadata; Client Gateway chat is project-scoped at `/m/<slug>/v1/chat/completions`.
75
73
 
76
74
  Switchboard-managed Turnstile is the default production path. Developers do not paste Cloudflare secrets into the CLI or repo files:
77
75
 
78
76
  ```bash
79
- switchboard projects provision-turnstile <project-id>
77
+ switchboard setup project --origin <origin> --json
80
78
  switchboard projects turnstile <project-id> --clear
81
79
  ```
82
80
 
83
- If `switchboard projects provision-turnstile --help` is missing, upgrade with `npm install -g @switchboard.spot/cli@latest` before trying dashboard automation or manual Cloudflare keys.
81
+ `switchboard projects provision-turnstile` remains available as a low-level admin/debug command. If its help is missing, upgrade with `npm install -g @switchboard.spot/cli@latest` before trying dashboard automation or manual Cloudflare keys.
84
82
 
85
83
  ## Configuration
86
84
 
@@ -320,8 +320,8 @@ function waitForCallback(callbackServer, timeoutSeconds, json) {
320
320
  export function callbackPage(title, message, tone) {
321
321
  const success = tone === "success";
322
322
  const accent = success ? "#14b8a6" : "#ef4444";
323
- const accentSoft = success ? "#ccfbf1" : "#fee2e2";
324
- const mark = success ? "" : "!";
323
+ const mark = success ? "" : "×";
324
+ const ariaLabel = success ? "Switchboard CLI login complete" : "Switchboard CLI login failed";
325
325
 
326
326
  return `<!doctype html>
327
327
  <html lang="en">
@@ -340,114 +340,41 @@ export function callbackPage(title, message, tone) {
340
340
  body {
341
341
  min-height: 100vh;
342
342
  margin: 0;
343
+ display: grid;
344
+ place-items: center;
343
345
  color: #1f2937;
344
346
  background:
345
347
  repeating-linear-gradient(125deg, transparent, transparent 6px, #e8e8e8 6px, #e8e8e8 7px),
346
348
  #ffffff;
347
349
  }
348
- .page {
349
- min-height: 100vh;
350
- width: min(100%, 80rem);
351
- margin: 0 auto;
352
- display: flex;
353
- flex-direction: column;
354
- border-inline: 1px solid #e5e7eb;
355
- background:
356
- repeating-linear-gradient(125deg, transparent, transparent 6px, #e8e8e8 6px, #e8e8e8 7px),
357
- #ffffff;
358
- }
359
- header {
360
- height: 4.5rem;
361
- display: flex;
362
- align-items: center;
363
- justify-content: space-between;
364
- padding: 0 1.5rem;
365
- border-bottom: 1px solid #e5e7eb;
366
- background: rgba(255, 255, 255, 0.88);
367
- backdrop-filter: blur(12px);
368
- }
369
- .logo {
370
- color: #1f2937;
371
- font-size: 0.95rem;
372
- font-weight: 800;
373
- letter-spacing: 0;
374
- }
375
- .pill {
376
- border: 1px solid #e5e7eb;
377
- border-radius: 999px;
378
- padding: 0.45rem 0.8rem;
379
- background: #ffffff;
380
- color: #4b5563;
381
- font-size: 0.82rem;
382
- font-weight: 600;
383
- }
384
- main {
385
- flex: 1;
350
+ .panel {
351
+ width: min(100%, 12rem);
352
+ aspect-ratio: 1;
386
353
  display: grid;
387
354
  place-items: center;
388
- padding: 5rem 1.5rem;
389
- }
390
- .panel {
391
- width: min(100%, 34rem);
392
355
  border: 1px solid #e5e7eb;
393
356
  border-radius: 0.5rem;
394
- padding: clamp(1.5rem, 5vw, 2.25rem);
357
+ padding: 1.5rem;
395
358
  background: rgba(255, 255, 255, 0.94);
396
359
  box-shadow:
397
360
  0 1.34368px 0.537473px -0.625px rgba(0, 0, 0, 0.09),
398
361
  0 15.5969px 6.23877px -3.125px rgba(0, 0, 0, 0.07),
399
362
  0 43.962px 17.5848px -4.375px rgba(0, 0, 0, 0.04);
400
- text-align: center;
401
- }
402
- .eyebrow {
403
- margin: 0 0 1.25rem;
404
- color: #4b5563;
405
- font-size: 0.78rem;
406
- font-weight: 700;
407
- letter-spacing: 0.12em;
408
- text-transform: uppercase;
409
363
  }
410
364
  .mark {
411
- width: 4rem;
412
- height: 4rem;
413
- margin: 0 auto 1.25rem;
365
+ width: 5rem;
366
+ height: 5rem;
414
367
  display: grid;
415
368
  place-items: center;
416
369
  border: 1px solid ${accent};
417
370
  border-radius: 0.5rem;
418
371
  background: ${accent};
419
372
  color: #111827;
420
- font-size: 1.9rem;
373
+ font-size: 3rem;
374
+ line-height: 1;
421
375
  font-weight: 900;
422
376
  box-shadow: 0 18px 40px -20px ${accent};
423
377
  }
424
- h1 {
425
- margin: 0;
426
- color: #1f2937;
427
- font-size: clamp(2rem, 7vw, 3.5rem);
428
- line-height: 0.98;
429
- letter-spacing: 0;
430
- }
431
- p {
432
- margin: 1rem auto 0;
433
- max-width: 26rem;
434
- color: #4b5563;
435
- font-size: 1rem;
436
- line-height: 1.65;
437
- }
438
- .status {
439
- display: inline-flex;
440
- align-items: center;
441
- gap: 0.5rem;
442
- margin-top: 1.5rem;
443
- border: 1px solid ${accent};
444
- border-radius: 999px;
445
- padding: 0.5rem 0.8rem;
446
- background: ${accentSoft};
447
- color: #1f2937;
448
- font-size: 0.86rem;
449
- font-weight: 700;
450
- }
451
378
  @media (prefers-color-scheme: dark) {
452
379
  :root {
453
380
  background: #020617;
@@ -459,53 +386,20 @@ export function callbackPage(title, message, tone) {
459
386
  repeating-linear-gradient(125deg, transparent, transparent 6px, rgba(55, 65, 81, 0.6) 6px, rgba(55, 65, 81, 0.6) 7px),
460
387
  #020617;
461
388
  }
462
- .page {
463
- border-color: #1f2937;
464
- background:
465
- repeating-linear-gradient(125deg, transparent, transparent 6px, rgba(55, 65, 81, 0.6) 6px, rgba(55, 65, 81, 0.6) 7px),
466
- #020617;
467
- }
468
- header,
469
389
  .panel {
470
390
  border-color: #1f2937;
471
391
  background: rgba(2, 6, 23, 0.94);
472
392
  }
473
- .logo,
474
- h1 {
475
- color: #f3f4f6;
476
- }
477
- .pill,
478
- .eyebrow,
479
- p {
480
- color: #d1d5db;
481
- }
482
- .pill {
483
- border-color: #1f2937;
484
- background: #111827;
485
- }
486
- .mark,
487
- .status {
393
+ .mark {
488
394
  color: #020617;
489
395
  }
490
396
  }
491
397
  </style>
492
398
  </head>
493
399
  <body>
494
- <div class="page">
495
- <header>
496
- <div class="logo">Switchboard</div>
497
- <div class="pill">CLI session</div>
498
- </header>
499
- <main>
500
- <section class="panel" aria-labelledby="callback-title">
501
- <p class="eyebrow">Switchboard CLI</p>
502
- <div class="mark" aria-hidden="true">${mark}</div>
503
- <h1 id="callback-title">${escapeHtml(title)}</h1>
504
- <p>${escapeHtml(message)}</p>
505
- <div class="status">${success ? "Session approved" : "Session not approved"}</div>
506
- </section>
507
- </main>
508
- </div>
400
+ <main class="panel" aria-label="${ariaLabel}">
401
+ <div class="mark" aria-hidden="true">${mark}</div>
402
+ </main>
509
403
  </body>
510
404
  </html>`;
511
405
  }
@@ -21,92 +21,113 @@ export function registerDoctorCommand(program) {
21
21
  .description("Check health, auth, project, catalog, and gateway readiness")
22
22
  .action(async (_opts, cmd) => {
23
23
  const flags = globalFlags(cmd);
24
- const config = resolveConfig();
25
- const accountConfig = await resolveAccountConfig(config);
26
-
27
- const checks = [];
28
-
29
- checks.push(
30
- await check("health", async () => {
31
- const result = await healthCheck(config);
32
- return { status: result.status, data: result.data };
33
- }),
34
- );
35
-
36
- checks.push(
37
- await check("catalog", async () => {
38
- const res = await fetch(`${gatewayApiUrl(config)}/catalog/models`);
39
- const data = await res.json();
40
- const status = res.status;
41
- if (!res.ok) throw new Error(`HTTP ${status}`);
42
- return { status, count: Array.isArray(data?.data) ? data.data.length : 0 };
43
- }),
44
- );
45
-
46
- if (accountConfig.accountToken) {
47
- checks.push(
48
- await check("account", async () => {
49
- const { status, data } = await accountRequest("GET", "/me", {
50
- config: accountConfig,
51
- json: true,
52
- });
53
- return {
54
- status,
55
- email: data?.user?.email || null,
56
- tokenSource: accountConfig.accountTokenSource,
57
- };
58
- }),
59
- );
60
- } else {
61
- checks.push({ name: "account", ok: false, error: "Run switchboard auth login" });
62
- }
24
+ const report = await runDoctorChecks();
25
+ emit(report, flags);
26
+ if (!report.ok) process.exit(1);
27
+ process.exit(0);
28
+ });
29
+ }
63
30
 
64
- if (config.projectId) {
65
- checks.push({ name: "project", ok: true, projectId: config.projectId });
66
- } else {
67
- checks.push({ name: "project", ok: false, error: "No project selected" });
31
+ export async function runDoctorChecks({
32
+ config = resolveConfig(),
33
+ request = accountRequest,
34
+ health = healthCheck,
35
+ fetchImpl = fetch,
36
+ resolveAccount = resolveAccountConfig,
37
+ } = {}) {
38
+ const accountConfig = await resolveAccount(config);
39
+
40
+ const checks = [];
41
+
42
+ checks.push(
43
+ await check("health", async () => {
44
+ const result = await health(config);
45
+ if (!result.ok) {
46
+ throw new Error(result.data?.status || result.data?.message || `HTTP ${result.status}`);
68
47
  }
69
48
 
70
- if (config.virtualMicroserviceUrl) {
71
- checks.push(
72
- await check("client_gateway_config", async () => {
73
- const res = await fetch(new URL("client/config", ensureTrailingSlash(config.virtualMicroserviceUrl)).href, {
74
- headers: { Accept: "application/json" },
75
- });
76
- const data = await readJson(res);
77
- if (!res.ok) {
78
- throw new Error(data?.error?.message || data?.message || `HTTP ${res.status}`);
79
- }
80
-
81
- const productionSafety = data?.production_safety ?? null;
82
- const productionBlocked = productionSafety?.status === "blocked";
83
-
84
- return {
85
- status: res.status,
86
- clientUrl: config.virtualMicroserviceUrl,
87
- browserChallengeProvider: data?.browser_challenge?.provider ?? null,
88
- production_safety: productionSafety,
89
- warning: productionBlocked,
90
- warningCode: productionBlocked ? "production_safety_blocked" : null,
91
- warningMessage: productionBlocked
92
- ? "Sandbox Client Gateway config is reachable, but production launch blockers remain."
93
- : null,
94
- };
95
- }),
96
- );
97
- } else {
98
- checks.push({
99
- name: "client_gateway_config",
100
- ok: false,
101
- error: "No SWITCHBOARD_CLIENT_URL or VITE_SWITCHBOARD_CLIENT_URL configured",
49
+ return { status: result.status, data: result.data };
50
+ }),
51
+ );
52
+
53
+ checks.push(
54
+ await check("catalog", async () => {
55
+ const res = await fetchImpl(`${gatewayApiUrl(config)}/catalog/models`);
56
+ const data = await res.json();
57
+ const status = res.status;
58
+ if (!res.ok) throw new Error(`HTTP ${status}`);
59
+ return { status, count: Array.isArray(data?.data) ? data.data.length : 0 };
60
+ }),
61
+ );
62
+
63
+ if (accountConfig.accountToken) {
64
+ checks.push(
65
+ await check("account", async () => {
66
+ const { status, data } = await request("GET", "/me", {
67
+ config: accountConfig,
68
+ json: true,
102
69
  });
103
- }
70
+ return {
71
+ status,
72
+ email: data?.user?.email || null,
73
+ tokenSource: accountConfig.accountTokenSource,
74
+ };
75
+ }),
76
+ );
77
+ } else {
78
+ checks.push({ name: "account", ok: false, error: "Run switchboard auth login" });
79
+ }
104
80
 
105
- const ok = checks.every((item) => item.ok);
106
- emit({ object: "doctor_report", ok, baseUrl: config.baseUrl, checks }, flags);
107
- if (!ok) process.exit(1);
108
- process.exit(0);
81
+ if (config.projectId) {
82
+ checks.push({ name: "project", ok: true, projectId: config.projectId });
83
+ } else {
84
+ checks.push({ name: "project", ok: false, error: "No project selected" });
85
+ }
86
+
87
+ if (config.virtualMicroserviceUrl) {
88
+ checks.push(
89
+ await check("client_gateway_config", async () => {
90
+ const res = await fetchImpl(
91
+ new URL("client/config", ensureTrailingSlash(config.virtualMicroserviceUrl)).href,
92
+ {
93
+ headers: { Accept: "application/json" },
94
+ },
95
+ );
96
+ const data = await readJson(res);
97
+ if (!res.ok) {
98
+ throw new Error(data?.error?.message || data?.message || `HTTP ${res.status}`);
99
+ }
100
+
101
+ const productionSafety = data?.production_safety ?? null;
102
+ const productionBlocked = productionSafety?.status === "blocked";
103
+
104
+ return {
105
+ status: res.status,
106
+ clientUrl: config.virtualMicroserviceUrl,
107
+ browserChallengeProvider: data?.browser_challenge?.provider ?? null,
108
+ production_safety: productionSafety,
109
+ warning: productionBlocked,
110
+ warningCode: productionBlocked ? "production_safety_blocked" : null,
111
+ warningMessage: productionBlocked
112
+ ? "Sandbox Client Gateway config is reachable, but production launch blockers remain."
113
+ : null,
114
+ };
115
+ }),
116
+ );
117
+ } else {
118
+ checks.push({
119
+ name: "client_gateway_config",
120
+ ok: false,
121
+ error: "No SWITCHBOARD_CLIENT_URL or VITE_SWITCHBOARD_CLIENT_URL configured",
109
122
  });
123
+ }
124
+
125
+ return {
126
+ object: "doctor_report",
127
+ ok: checks.every((item) => item.ok),
128
+ baseUrl: config.baseUrl,
129
+ checks,
130
+ };
110
131
  }
111
132
 
112
133
  function ensureTrailingSlash(value) {
@@ -8,7 +8,7 @@ import { emit, globalFlags, printList } from "../output.js";
8
8
  export function registerEndUsersCommands(program) {
9
9
  const endUsers = program
10
10
  .command("end-users")
11
- .description("Anonymous Pro-bono Embed end users");
11
+ .description("Anonymous Client Gateway end users");
12
12
 
13
13
  endUsers
14
14
  .command("list")
@@ -8,9 +8,9 @@ import { resolveConfig, gatewayApiUrl } from "../config.js";
8
8
  import { emit } from "../output.js";
9
9
 
10
10
  /**
11
- * Builds default frontend environment placeholders for Pro-bono Embed apps.
11
+ * Builds default frontend environment placeholders for Client Gateway apps.
12
12
  *
13
- * The generated Pro-bono Embed URL is the only value a browser app needs by default.
13
+ * The generated Client Gateway URL is the only value a browser app needs by default.
14
14
  */
15
15
  function envBlock(clientUrl) {
16
16
  return `SWITCHBOARD_CLIENT_URL=${clientUrl}
@@ -32,7 +32,7 @@ export function agentManifest(config) {
32
32
  account_api: `${config.baseUrl.replace(/\/$/, "")}/v1/account`,
33
33
  env: envBlock(clientUrl),
34
34
  checklist: [
35
- "switchboard setup --target client --json",
35
+ "switchboard setup project --origin <origin> --json",
36
36
  "export VITE_SWITCHBOARD_CLIENT_URL=<client_url from integration kit>",
37
37
  "Use mountSwitchboardWidget({ clientUrl, target }) in browser apps",
38
38
  "Use createSwitchboardClient({ clientUrl, storage }) only for custom UI",
@@ -47,7 +47,7 @@ mountSwitchboardWidget({
47
47
  target: "#switchboard",
48
48
  });`,
49
49
  automation_note:
50
- "Pro-bono Embed auth is browser/mobile only and requires a real browser challenge. Use account/CLI APIs only for project configuration automation.",
50
+ "Client Gateway auth is browser/mobile only and requires a real browser challenge. Use account/CLI APIs only for project configuration automation.",
51
51
  };
52
52
  }
53
53
 
@@ -51,7 +51,7 @@ export function registerLaunchCommands(program) {
51
51
  });
52
52
  }
53
53
 
54
- function validateLaunchOptions(opts, flags) {
54
+ export function validateLaunchOptions(opts, flags) {
55
55
  if (!productionHttpsOrigin(opts.productionOrigin)) {
56
56
  fail("--production-origin must be an exact HTTPS production origin", 1, flags.json);
57
57
  }
@@ -60,19 +60,23 @@ function validateLaunchOptions(opts, flags) {
60
60
  }
61
61
  }
62
62
 
63
- async function ensureProject(opts, flags) {
64
- if (opts.projectId) return fetchProject(opts.projectId, flags);
63
+ export async function ensureProject(opts, flags, { request = accountRequest, save = saveProjectConfig } = {}) {
64
+ if (opts.projectId) {
65
+ const project = await fetchProject(opts.projectId, flags, { request });
66
+ save(project);
67
+ return project;
68
+ }
65
69
 
66
70
  if (opts.projectName) {
67
- const { data } = await accountRequest("POST", "/projects", {
71
+ const { data } = await request("POST", "/projects", {
68
72
  body: { name: opts.projectName, slug: opts.projectSlug },
69
73
  json: flags.json,
70
74
  });
71
- saveProjectConfig(data);
75
+ save(data);
72
76
  return data;
73
77
  }
74
78
 
75
- const { data } = await accountRequest("GET", "/me", { json: flags.json });
79
+ const { data } = await request("GET", "/me", { json: flags.json });
76
80
  if (!data.project?.id) {
77
81
  fail(
78
82
  "No default project is selected. Use --project-id or --project-name with --project-slug.",
@@ -80,10 +84,16 @@ async function ensureProject(opts, flags) {
80
84
  flags.json,
81
85
  );
82
86
  }
87
+ save(data.project);
83
88
  return data.project;
84
89
  }
85
90
 
86
- async function updateLaunchProject(projectId, opts, flags) {
91
+ export async function updateLaunchProject(
92
+ projectId,
93
+ opts,
94
+ flags,
95
+ { request = accountRequest, save = saveProjectConfig } = {},
96
+ ) {
87
97
  const body = {
88
98
  allowed_origins: [opts.productionOrigin],
89
99
  end_user_terms_url: opts.endUserTermsUrl,
@@ -94,23 +104,34 @@ async function updateLaunchProject(projectId, opts, flags) {
94
104
 
95
105
  if (opts.supportUrl) body.support_url = opts.supportUrl;
96
106
 
97
- const { data } = await accountRequest("PATCH", `/projects/${projectId}`, {
107
+ const { data } = await request("PATCH", `/projects/${projectId}`, {
98
108
  body,
99
109
  json: flags.json,
100
110
  });
101
- saveProjectConfig(data);
111
+ save(data);
102
112
  return data;
103
113
  }
104
114
 
105
- async function provisionManagedTurnstile(projectId, idempotencyKey, flags) {
106
- const { data } = await accountRequest("POST", `/projects/${projectId}/turnstile/provision`, {
115
+ export async function provisionManagedTurnstile(
116
+ projectId,
117
+ idempotencyKey,
118
+ flags,
119
+ { request = accountRequest } = {},
120
+ ) {
121
+ const { data } = await request("POST", `/projects/${projectId}/turnstile/provision`, {
107
122
  body: { idempotency_key: `${idempotencyKey}:turnstile` },
108
123
  json: flags.json,
109
124
  });
110
125
  return data;
111
126
  }
112
127
 
113
- async function requestAccessIfNeeded(projectId, project, opts, flags) {
128
+ export async function requestAccessIfNeeded(
129
+ projectId,
130
+ project,
131
+ opts,
132
+ flags,
133
+ { request = accountRequest } = {},
134
+ ) {
114
135
  if (project.production_access_status === "approved") {
115
136
  return {
116
137
  object: "production_access_request",
@@ -120,19 +141,19 @@ async function requestAccessIfNeeded(projectId, project, opts, flags) {
120
141
  };
121
142
  }
122
143
 
123
- const { data } = await accountRequest("POST", `/projects/${projectId}/production_access_request`, {
144
+ const { data } = await request("POST", `/projects/${projectId}/production_access_request`, {
124
145
  body: buildProductionAccessRequestBody(opts),
125
146
  json: flags.json,
126
147
  });
127
148
  return data;
128
149
  }
129
150
 
130
- async function fetchProject(projectId, flags) {
131
- const { data } = await accountRequest("GET", `/projects/${projectId}`, { json: flags.json });
151
+ export async function fetchProject(projectId, flags, { request = accountRequest } = {}) {
152
+ const { data } = await request("GET", `/projects/${projectId}`, { json: flags.json });
132
153
  return data;
133
154
  }
134
155
 
135
- function saveProjectConfig(project) {
156
+ export function saveProjectConfig(project) {
136
157
  saveConfig({
137
158
  projectId: String(project.id),
138
159
  apiKey: null,
@@ -141,7 +162,7 @@ function saveProjectConfig(project) {
141
162
  });
142
163
  }
143
164
 
144
- function launchSummary(project, challenge, access) {
165
+ export function launchSummary(project, challenge, access) {
145
166
  return {
146
167
  object: "launch_prepare_result",
147
168
  project_id: project.id,
@@ -158,7 +179,7 @@ function launchSummary(project, challenge, access) {
158
179
  };
159
180
  }
160
181
 
161
- function humanLaunchSummary(project, access) {
182
+ export function humanLaunchSummary(project, access) {
162
183
  const blocked = (project.production_safety?.checks || [])
163
184
  .filter((check) => check.status !== "ready")
164
185
  .map((check) => check.id);
@@ -173,7 +194,7 @@ function humanLaunchSummary(project, access) {
173
194
  ].join("\n");
174
195
  }
175
196
 
176
- function productionHttpsOrigin(origin) {
197
+ export function productionHttpsOrigin(origin) {
177
198
  try {
178
199
  const url = new URL(origin);
179
200
  return (
@@ -21,6 +21,7 @@ export function registerProjectsCommands(program) {
21
21
  printList("Projects:", data.data || [], (p) =>
22
22
  ` ${p.id} ${p.name} (${p.slug}) — ${p.api_key_count} keys`,
23
23
  );
24
+ warnForLocalhostCorsOrigins(data.data || [], flags);
24
25
  }
25
26
  });
26
27
 
@@ -51,6 +52,7 @@ export function registerProjectsCommands(program) {
51
52
  const flags = globalFlags(cmd);
52
53
  const { data } = await accountRequest("GET", `/projects/${id}`, { json: flags.json });
53
54
  emit(flags.json ? data : JSON.stringify(data, null, 2), flags);
55
+ warnForLocalhostCorsOrigins(data, flags);
54
56
  });
55
57
 
56
58
  projects
@@ -66,7 +68,7 @@ export function registerProjectsCommands(program) {
66
68
  .option("--support-email <email>")
67
69
  .option(
68
70
  "--allowed-origins <urls>",
69
- "Comma-separated browser origins for Pro-bono Embed requests",
71
+ "Comma-separated browser origins for Client Gateway requests",
70
72
  )
71
73
  .option(
72
74
  "--allowed-ios-bundle-ids <ids>",
@@ -86,6 +88,7 @@ export function registerProjectsCommands(program) {
86
88
  json: flags.json,
87
89
  });
88
90
  emit(flags.json ? data : `Updated project ${data.name}`, flags);
91
+ warnForLocalhostCorsOrigins(data, flags);
89
92
  });
90
93
 
91
94
  projects
@@ -149,6 +152,7 @@ export function registerProjectsCommands(program) {
149
152
  endUserSession: null,
150
153
  });
151
154
  emit(flags.json ? data : `Using project ${data.name} (${data.id})`, flags);
155
+ warnForLocalhostCorsOrigins(data, flags);
152
156
  });
153
157
 
154
158
  projects
@@ -211,6 +215,19 @@ export function buildProjectUpdateBody(opts) {
211
215
  return body;
212
216
  }
213
217
 
218
+ export function localhostCorsOriginWarning(project) {
219
+ const origins = Array.isArray(project?.allowed_origins) ? project.allowed_origins : [];
220
+ const localOrigins = origins.filter(isLocalhostOrigin);
221
+
222
+ if (!localOrigins.length) return null;
223
+
224
+ return [
225
+ `Warning: project ${project.name || project.id || "unknown"} allows localhost CORS origins`,
226
+ `(${localOrigins.join(", ")}).`,
227
+ "Use them only for development and remove them before production launch.",
228
+ ].join(" ");
229
+ }
230
+
214
231
  export function buildTurnstileBody(opts) {
215
232
  if (opts.clear) {
216
233
  return { turnstile_site_key: "", turnstile_secret_key: "" };
@@ -241,3 +258,28 @@ function parseBoolean(value) {
241
258
  if (value === false || value === "false") return false;
242
259
  throw new Error(`Expected boolean value true or false, got ${value}`);
243
260
  }
261
+
262
+ function warnForLocalhostCorsOrigins(projectOrProjects, flags) {
263
+ if (flags.json || flags.quiet) return;
264
+
265
+ const projects = Array.isArray(projectOrProjects) ? projectOrProjects : [projectOrProjects];
266
+ for (const project of projects) {
267
+ const warning = localhostCorsOriginWarning(project);
268
+ if (warning) console.error(warning);
269
+ }
270
+ }
271
+
272
+ function isLocalhostOrigin(origin) {
273
+ try {
274
+ const hostname = new URL(origin).hostname.toLowerCase();
275
+ return (
276
+ hostname === "localhost" ||
277
+ hostname === "127.0.0.1" ||
278
+ hostname === "0.0.0.0" ||
279
+ hostname === "::1" ||
280
+ hostname === "[::1]"
281
+ );
282
+ } catch {
283
+ return false;
284
+ }
285
+ }
@@ -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,8 @@ 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("--app-url <app-url>", "Alias for --url")
19
+ .option("--client-url <switchboard-client-url>", "Switchboard Client Gateway client URL"),
19
20
  ).action(async (opts, cmd) => {
20
21
  await runVerifyCommand("setup", opts, cmd);
21
22
  });
@@ -25,7 +26,8 @@ export function registerVerifyCommands(program) {
25
26
  .command("publish")
26
27
  .description("Verify an HTTPS preview is safe to publish")
27
28
  .option("--url <preview-url>", "HTTPS preview URL to test")
28
- .option("--client-url <switchboard-client-url>", "Switchboard Pro-bono Embed client URL")
29
+ .option("--app-url <preview-url>", "Alias for --url")
30
+ .option("--client-url <switchboard-client-url>", "Switchboard Client Gateway client URL")
29
31
  .option("--production-origin <origin>", "Production origin that must be allowed"),
30
32
  )
31
33
  .option("--project-id <id>", "Switchboard project id for production safety checks")
@@ -38,7 +40,8 @@ export function registerVerifyCommands(program) {
38
40
  .command("browser")
39
41
  .description("Run a declarative browser verification scenario")
40
42
  .option("--url <app-url>", "Application URL to test")
41
- .option("--client-url <switchboard-client-url>", "Switchboard Pro-bono Embed client URL"),
43
+ .option("--app-url <app-url>", "Alias for --url")
44
+ .option("--client-url <switchboard-client-url>", "Switchboard Client Gateway client URL"),
42
45
  ).action(async (opts, cmd) => {
43
46
  await runVerifyCommand("browser", opts, cmd);
44
47
  });
@@ -60,6 +63,7 @@ async function runVerifyCommand(mode, opts, cmd) {
60
63
  const project = mode === "publish" ? await loadProject(opts, flags) : null;
61
64
  const report = await verifierForMode(mode)({
62
65
  url: opts.url,
66
+ appUrl: opts.appUrl,
63
67
  clientUrl: opts.clientUrl,
64
68
  productionOrigin: opts.productionOrigin,
65
69
  scenarioPath: opts.scenario,
@@ -48,7 +48,6 @@ const CHECK_TYPE_TO_ID = new Map([
48
48
  ]);
49
49
 
50
50
  const SERVER_SECRET_PATTERNS = [
51
- { name: "SWITCHBOARD_API_KEY", pattern: /\bSWITCHBOARD_API_KEY\b/ },
52
51
  { name: "switchboard live key", pattern: /\bsb_live_[A-Za-z0-9_-]{6,}\b/ },
53
52
  { name: "switchboard test key", pattern: /\bsb_test_[A-Za-z0-9_-]{6,}\b/ },
54
53
  { name: "account session token", pattern: /\bsb_sess_[A-Za-z0-9_-]{6,}\b/ },
@@ -125,7 +124,7 @@ export async function runVerification(options = {}) {
125
124
  const scenario = validateScenario(options.scenario ?? loadScenario(options.scenarioPath));
126
125
  const report = createReport(mode);
127
126
  const artifactsDir = options.artifactsDir ?? DEFAULT_ARTIFACTS_DIR;
128
- const appUrl = parseRequiredUrl(options.url, "--url");
127
+ const appUrl = parseRequiredUrl(options.appUrl ?? options.url, options.appUrl ? "--app-url" : "--url");
129
128
  const clientUrl = options.clientUrl ? parseRequiredUrl(options.clientUrl, "--client-url") : null;
130
129
  const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
131
130
  let productionOrigin = options.productionOrigin;
@@ -723,6 +722,7 @@ function normalizedHostname(url) {
723
722
 
724
723
  function browserChallengeTokenFor(check, clientUrl) {
725
724
  if (Object.prototype.hasOwnProperty.call(check, "browserChallengeToken")) {
725
+ validateBrowserChallengeToken(check.browserChallengeToken, clientUrl);
726
726
  return check.browserChallengeToken;
727
727
  }
728
728
 
@@ -730,13 +730,25 @@ function browserChallengeTokenFor(check, clientUrl) {
730
730
  return DEV_BROWSER_CHALLENGE_TOKEN;
731
731
  }
732
732
 
733
- return DEFAULT_BROWSER_CHALLENGE_TOKEN;
733
+ throw new VerificationInputError(
734
+ "Hosted Switchboard verification cannot submit synthetic browser challenge tokens. Run the SDK-managed browser challenge in the real app, or target a localhost Switchboard URL for dev verification.",
735
+ );
734
736
  }
735
737
 
736
738
  function isLocalSwitchboardUrl(url) {
737
739
  return url instanceof URL && LOCAL_HOSTS.has(normalizedHostname(url));
738
740
  }
739
741
 
742
+ export function validateBrowserChallengeToken(token, clientUrl) {
743
+ if (isLocalSwitchboardUrl(clientUrl)) return;
744
+
745
+ if (token === DEV_BROWSER_CHALLENGE_TOKEN || token === DEFAULT_BROWSER_CHALLENGE_TOKEN) {
746
+ throw new VerificationInputError(
747
+ "Synthetic browser challenge tokens are allowed only against localhost Switchboard URLs.",
748
+ );
749
+ }
750
+ }
751
+
740
752
  function addFailure(report, checkId, message, finding) {
741
753
  addCheck(report, { id: checkId, status: "failed", message });
742
754
  report.findings.push({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchboard.spot/cli",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Switchboard CLI — full dashboard parity for agents and testing",
5
5
  "type": "module",
6
6
  "bin": {