@switchboard.spot/cli 0.2.2 → 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.
package/README.md CHANGED
@@ -55,9 +55,7 @@ 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>
58
+ switchboard setup project --origin http://localhost:5173 --json
61
59
  switchboard verify setup
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
61
  switchboard verify publish
@@ -65,7 +63,7 @@ switchboard verify publish
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
 
@@ -76,11 +74,11 @@ Model discovery is global. Use `GET /v1/models` for OpenAI-compatible discovery
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
 
@@ -21,92 +21,109 @@ 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);
24
+ const report = await runDoctorChecks();
25
+ emit(report, flags);
26
+ if (!report.ok) process.exit(1);
27
+ process.exit(0);
28
+ });
29
+ }
26
30
 
27
- const checks = [];
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);
28
39
 
29
- checks.push(
30
- await check("health", async () => {
31
- const result = await healthCheck(config);
32
- return { status: result.status, data: result.data };
33
- }),
34
- );
40
+ const checks = [];
35
41
 
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
- );
42
+ checks.push(
43
+ await check("health", async () => {
44
+ const result = await health(config);
45
+ return { status: result.status, data: result.data };
46
+ }),
47
+ );
45
48
 
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
- }
49
+ checks.push(
50
+ await check("catalog", async () => {
51
+ const res = await fetchImpl(`${gatewayApiUrl(config)}/catalog/models`);
52
+ const data = await res.json();
53
+ const status = res.status;
54
+ if (!res.ok) throw new Error(`HTTP ${status}`);
55
+ return { status, count: Array.isArray(data?.data) ? data.data.length : 0 };
56
+ }),
57
+ );
63
58
 
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" });
68
- }
69
-
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
- }
59
+ if (accountConfig.accountToken) {
60
+ checks.push(
61
+ await check("account", async () => {
62
+ const { status, data } = await request("GET", "/me", {
63
+ config: accountConfig,
64
+ json: true,
65
+ });
66
+ return {
67
+ status,
68
+ email: data?.user?.email || null,
69
+ tokenSource: accountConfig.accountTokenSource,
70
+ };
71
+ }),
72
+ );
73
+ } else {
74
+ checks.push({ name: "account", ok: false, error: "Run switchboard auth login" });
75
+ }
80
76
 
81
- const productionSafety = data?.production_safety ?? null;
82
- const productionBlocked = productionSafety?.status === "blocked";
77
+ if (config.projectId) {
78
+ checks.push({ name: "project", ok: true, projectId: config.projectId });
79
+ } else {
80
+ checks.push({ name: "project", ok: false, error: "No project selected" });
81
+ }
83
82
 
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
- }),
83
+ if (config.virtualMicroserviceUrl) {
84
+ checks.push(
85
+ await check("client_gateway_config", async () => {
86
+ const res = await fetchImpl(
87
+ new URL("client/config", ensureTrailingSlash(config.virtualMicroserviceUrl)).href,
88
+ {
89
+ headers: { Accept: "application/json" },
90
+ },
96
91
  );
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",
102
- });
103
- }
92
+ const data = await readJson(res);
93
+ if (!res.ok) {
94
+ throw new Error(data?.error?.message || data?.message || `HTTP ${res.status}`);
95
+ }
104
96
 
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);
97
+ const productionSafety = data?.production_safety ?? null;
98
+ const productionBlocked = productionSafety?.status === "blocked";
99
+
100
+ return {
101
+ status: res.status,
102
+ clientUrl: config.virtualMicroserviceUrl,
103
+ browserChallengeProvider: data?.browser_challenge?.provider ?? null,
104
+ production_safety: productionSafety,
105
+ warning: productionBlocked,
106
+ warningCode: productionBlocked ? "production_safety_blocked" : null,
107
+ warningMessage: productionBlocked
108
+ ? "Sandbox Client Gateway config is reachable, but production launch blockers remain."
109
+ : null,
110
+ };
111
+ }),
112
+ );
113
+ } else {
114
+ checks.push({
115
+ name: "client_gateway_config",
116
+ ok: false,
117
+ error: "No SWITCHBOARD_CLIENT_URL or VITE_SWITCHBOARD_CLIENT_URL configured",
109
118
  });
119
+ }
120
+
121
+ return {
122
+ object: "doctor_report",
123
+ ok: checks.every((item) => item.ok),
124
+ baseUrl: config.baseUrl,
125
+ checks,
126
+ };
110
127
  }
111
128
 
112
129
  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,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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchboard.spot/cli",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Switchboard CLI — full dashboard parity for agents and testing",
5
5
  "type": "module",
6
6
  "bin": {