@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.
@@ -21,54 +21,120 @@ 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
+ }
30
+
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 = [];
26
41
 
27
- const checks = [];
42
+ checks.push(
43
+ await check("health", async () => {
44
+ const result = await health(config);
45
+ return { status: result.status, data: result.data };
46
+ }),
47
+ );
28
48
 
29
- checks.push(
30
- await check("health", async () => {
31
- const result = await healthCheck(config);
32
- return { status: result.status, data: result.data };
33
- }),
34
- );
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
+ );
35
58
 
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
- );
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
+ }
76
+
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
+ }
45
82
 
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
- }),
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
+ },
59
91
  );
60
- } else {
61
- checks.push({ name: "account", ok: false, error: "Run switchboard auth login" });
62
- }
92
+ const data = await readJson(res);
93
+ if (!res.ok) {
94
+ throw new Error(data?.error?.message || data?.message || `HTTP ${res.status}`);
95
+ }
63
96
 
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
- }
97
+ const productionSafety = data?.production_safety ?? null;
98
+ const productionBlocked = productionSafety?.status === "blocked";
69
99
 
70
- const ok = checks.every((item) => item.ok);
71
- emit({ object: "doctor_report", ok, baseUrl: config.baseUrl, checks }, flags);
72
- if (!ok) process.exit(1);
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",
73
118
  });
119
+ }
120
+
121
+ return {
122
+ object: "doctor_report",
123
+ ok: checks.every((item) => item.ok),
124
+ baseUrl: config.baseUrl,
125
+ checks,
126
+ };
127
+ }
128
+
129
+ function ensureTrailingSlash(value) {
130
+ return String(value).endsWith("/") ? String(value) : `${value}/`;
131
+ }
132
+
133
+ async function readJson(response) {
134
+ const text = await response.text();
135
+ try {
136
+ return text ? JSON.parse(text) : null;
137
+ } catch {
138
+ return { raw: text };
139
+ }
74
140
  }
@@ -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,11 +32,13 @@ 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",
36
- "export VITE_SWITCHBOARD_CLIENT_URL=<client_url from integrations show>",
35
+ "switchboard setup project --origin <origin> --json",
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",
39
- "switchboard billing top-up --amount-micros <micros>",
39
+ "switchboard verify setup",
40
+ "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\"",
41
+ "switchboard verify publish",
40
42
  ],
41
43
  browser_smoke_test: `import { mountSwitchboardWidget } from "@switchboard.spot/sdk";
42
44
 
@@ -45,7 +47,7 @@ mountSwitchboardWidget({
45
47
  target: "#switchboard",
46
48
  });`,
47
49
  automation_note:
48
- "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.",
49
51
  };
50
52
  }
51
53
 
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Production launch orchestration commands.
3
+ */
4
+
5
+ import { accountRequest } from "../client.js";
6
+ import { saveConfig } from "../config.js";
7
+ import { emit, fail, globalFlags } from "../output.js";
8
+ import { buildProductionAccessRequestBody } from "./projects.js";
9
+
10
+ export function registerLaunchCommands(program) {
11
+ const launch = program.command("launch").description("Production launch workflows");
12
+
13
+ launch
14
+ .command("prepare")
15
+ .description("Prepare a project for production Client Gateway launch")
16
+ .option("--project-id <id>", "Existing project id")
17
+ .option("--project-name <name>", "Create a project when no project id is provided")
18
+ .option("--project-slug <slug>", "Slug for a project created by this command")
19
+ .requiredOption(
20
+ "--production-origin <origin>",
21
+ "Exact HTTPS production origin, for example https://app.example.com",
22
+ )
23
+ .requiredOption("--end-user-terms-url <url>")
24
+ .requiredOption("--end-user-privacy-url <url>")
25
+ .option("--support-url <url>")
26
+ .requiredOption("--support-email <email>")
27
+ .requiredOption("--contact-email <email>")
28
+ .requiredOption("--use-case <text>")
29
+ .option("--expected-monthly-volume <volume>")
30
+ .option("--needed-billing-mode <mode>", "Billing mode needed for production", "developer_paid")
31
+ .option("--notes <text>")
32
+ .option("--idempotency-key <key>")
33
+ .action(async (opts, cmd) => {
34
+ const flags = globalFlags(cmd);
35
+ validateLaunchOptions(opts, flags);
36
+
37
+ const project = await ensureProject(opts, flags);
38
+ const idempotencyKey = opts.idempotencyKey || `launch-prepare-project-${project.id}`;
39
+
40
+ const configured = await updateLaunchProject(project.id, opts, flags);
41
+ const challenge = await provisionManagedTurnstile(project.id, idempotencyKey, flags);
42
+ const access = await requestAccessIfNeeded(project.id, configured, opts, flags);
43
+ const readiness = await fetchProject(project.id, flags);
44
+
45
+ emit(
46
+ flags.json
47
+ ? launchSummary(readiness, challenge, access)
48
+ : humanLaunchSummary(readiness, access),
49
+ flags,
50
+ );
51
+ });
52
+ }
53
+
54
+ export function validateLaunchOptions(opts, flags) {
55
+ if (!productionHttpsOrigin(opts.productionOrigin)) {
56
+ fail("--production-origin must be an exact HTTPS production origin", 1, flags.json);
57
+ }
58
+ if ((opts.projectName && !opts.projectSlug) || (!opts.projectName && opts.projectSlug)) {
59
+ fail("--project-name and --project-slug must be provided together", 1, flags.json);
60
+ }
61
+ }
62
+
63
+ 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
+ }
69
+
70
+ if (opts.projectName) {
71
+ const { data } = await request("POST", "/projects", {
72
+ body: { name: opts.projectName, slug: opts.projectSlug },
73
+ json: flags.json,
74
+ });
75
+ save(data);
76
+ return data;
77
+ }
78
+
79
+ const { data } = await request("GET", "/me", { json: flags.json });
80
+ if (!data.project?.id) {
81
+ fail(
82
+ "No default project is selected. Use --project-id or --project-name with --project-slug.",
83
+ 1,
84
+ flags.json,
85
+ );
86
+ }
87
+ save(data.project);
88
+ return data.project;
89
+ }
90
+
91
+ export async function updateLaunchProject(
92
+ projectId,
93
+ opts,
94
+ flags,
95
+ { request = accountRequest, save = saveProjectConfig } = {},
96
+ ) {
97
+ const body = {
98
+ allowed_origins: [opts.productionOrigin],
99
+ end_user_terms_url: opts.endUserTermsUrl,
100
+ end_user_privacy_url: opts.endUserPrivacyUrl,
101
+ support_email: opts.supportEmail,
102
+ virtual_microservice_enabled: true,
103
+ };
104
+
105
+ if (opts.supportUrl) body.support_url = opts.supportUrl;
106
+
107
+ const { data } = await request("PATCH", `/projects/${projectId}`, {
108
+ body,
109
+ json: flags.json,
110
+ });
111
+ save(data);
112
+ return data;
113
+ }
114
+
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`, {
122
+ body: { idempotency_key: `${idempotencyKey}:turnstile` },
123
+ json: flags.json,
124
+ });
125
+ return data;
126
+ }
127
+
128
+ export async function requestAccessIfNeeded(
129
+ projectId,
130
+ project,
131
+ opts,
132
+ flags,
133
+ { request = accountRequest } = {},
134
+ ) {
135
+ if (project.production_access_status === "approved") {
136
+ return {
137
+ object: "production_access_request",
138
+ project_id: project.id,
139
+ project_production_access_status: "approved",
140
+ status: "approved",
141
+ };
142
+ }
143
+
144
+ const { data } = await request("POST", `/projects/${projectId}/production_access_request`, {
145
+ body: buildProductionAccessRequestBody(opts),
146
+ json: flags.json,
147
+ });
148
+ return data;
149
+ }
150
+
151
+ export async function fetchProject(projectId, flags, { request = accountRequest } = {}) {
152
+ const { data } = await request("GET", `/projects/${projectId}`, { json: flags.json });
153
+ return data;
154
+ }
155
+
156
+ export function saveProjectConfig(project) {
157
+ saveConfig({
158
+ projectId: String(project.id),
159
+ apiKey: null,
160
+ virtualMicroserviceUrl: project.virtual_microservice_url || null,
161
+ endUserSession: null,
162
+ });
163
+ }
164
+
165
+ export function launchSummary(project, challenge, access) {
166
+ return {
167
+ object: "launch_prepare_result",
168
+ project_id: project.id,
169
+ project_slug: project.slug,
170
+ virtual_microservice_url: project.virtual_microservice_url,
171
+ browser_challenge: challenge,
172
+ production_access: access,
173
+ production_safety: project.production_safety,
174
+ next_steps: [
175
+ "Run switchboard verify setup.",
176
+ "Use the SDK-managed browser challenge to mint browser sessions.",
177
+ "Run switchboard verify publish after approval and billing readiness are complete.",
178
+ ],
179
+ };
180
+ }
181
+
182
+ export function humanLaunchSummary(project, access) {
183
+ const blocked = (project.production_safety?.checks || [])
184
+ .filter((check) => check.status !== "ready")
185
+ .map((check) => check.id);
186
+
187
+ return [
188
+ `Prepared project ${project.name} (${project.id})`,
189
+ `Client Gateway: ${project.virtual_microservice_url}`,
190
+ `Production access: ${access.project_production_access_status || access.status}`,
191
+ `Readiness: ${project.production_safety?.status || "unknown"}`,
192
+ blocked.length ? `Blocked checks: ${blocked.join(", ")}` : "Blocked checks: none",
193
+ "Next: run switchboard verify setup, then switchboard verify publish after approval and billing readiness.",
194
+ ].join("\n");
195
+ }
196
+
197
+ export function productionHttpsOrigin(origin) {
198
+ try {
199
+ const url = new URL(origin);
200
+ return (
201
+ url.protocol === "https:" &&
202
+ url.username === "" &&
203
+ url.password === "" &&
204
+ url.pathname === "/" &&
205
+ url.search === "" &&
206
+ url.hash === "" &&
207
+ !["localhost", "127.0.0.1", "::1"].includes(url.hostname) &&
208
+ !url.hostname.includes("*")
209
+ );
210
+ } catch {
211
+ return false;
212
+ }
213
+ }
@@ -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,14 +88,14 @@ 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
92
95
  .command("turnstile <id>")
93
- .description("Configure project-owned Turnstile keys")
96
+ .description("Configure project-owned public Turnstile metadata")
94
97
  .option("--site-key <key>")
95
- .option("--secret-key <key>")
96
- .option("--clear", "Remove project-owned Turnstile keys")
98
+ .option("--clear", "Remove project-owned Turnstile metadata")
97
99
  .action(async (id, opts, cmd) => {
98
100
  const flags = globalFlags(cmd);
99
101
  const body = buildTurnstileBody(opts);
@@ -104,6 +106,39 @@ export function registerProjectsCommands(program) {
104
106
  emit(flags.json ? data : `Updated Turnstile settings for ${data.name}`, flags);
105
107
  });
106
108
 
109
+ projects
110
+ .command("provision-turnstile <id>")
111
+ .description("Provision Switchboard-managed Turnstile for a project")
112
+ .option("--idempotency-key <key>")
113
+ .action(async (id, opts, cmd) => {
114
+ const flags = globalFlags(cmd);
115
+ const body = {};
116
+ if (opts.idempotencyKey) body.idempotency_key = opts.idempotencyKey;
117
+
118
+ const { data } = await accountRequest("POST", `/projects/${id}/turnstile/provision`, {
119
+ body,
120
+ json: flags.json,
121
+ });
122
+ emit(flags.json ? data : `Provisioned managed Turnstile for project ${id}`, flags);
123
+ });
124
+
125
+ projects
126
+ .command("request-production-access <id>")
127
+ .description("Submit a production access request")
128
+ .requiredOption("--contact-email <email>")
129
+ .requiredOption("--use-case <text>")
130
+ .option("--expected-monthly-volume <volume>")
131
+ .option("--needed-billing-mode <mode>", "Billing mode needed for production", "developer_paid")
132
+ .option("--notes <text>")
133
+ .action(async (id, opts, cmd) => {
134
+ const flags = globalFlags(cmd);
135
+ const { data } = await accountRequest("POST", `/projects/${id}/production_access_request`, {
136
+ body: buildProductionAccessRequestBody(opts),
137
+ json: flags.json,
138
+ });
139
+ emit(flags.json ? data : `Submitted production access request for project ${id}`, flags);
140
+ });
141
+
107
142
  projects
108
143
  .command("use <id>")
109
144
  .description("Set default project for subsequent commands")
@@ -117,6 +152,7 @@ export function registerProjectsCommands(program) {
117
152
  endUserSession: null,
118
153
  });
119
154
  emit(flags.json ? data : `Using project ${data.name} (${data.id})`, flags);
155
+ warnForLocalhostCorsOrigins(data, flags);
120
156
  });
121
157
 
122
158
  projects
@@ -179,6 +215,19 @@ export function buildProjectUpdateBody(opts) {
179
215
  return body;
180
216
  }
181
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
+
182
231
  export function buildTurnstileBody(opts) {
183
232
  if (opts.clear) {
184
233
  return { turnstile_site_key: "", turnstile_secret_key: "" };
@@ -186,7 +235,21 @@ export function buildTurnstileBody(opts) {
186
235
 
187
236
  const body = {};
188
237
  if (opts.siteKey) body.turnstile_site_key = opts.siteKey;
189
- if (opts.secretKey) body.turnstile_secret_key = opts.secretKey;
238
+ return body;
239
+ }
240
+
241
+ export function buildProductionAccessRequestBody(opts) {
242
+ const body = {
243
+ contact_email: opts.contactEmail,
244
+ use_case: opts.useCase,
245
+ needed_billing_mode: opts.neededBillingMode || "developer_paid",
246
+ };
247
+
248
+ if (opts.expectedMonthlyVolume) {
249
+ body.expected_monthly_volume = opts.expectedMonthlyVolume;
250
+ }
251
+ if (opts.notes) body.notes = opts.notes;
252
+
190
253
  return body;
191
254
  }
192
255
 
@@ -195,3 +258,28 @@ function parseBoolean(value) {
195
258
  if (value === false || value === "false") return false;
196
259
  throw new Error(`Expected boolean value true or false, got ${value}`);
197
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
+ }