@switchboard.spot/cli 0.2.4 → 0.2.5

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.
@@ -193,7 +193,13 @@ export function createReport(mode) {
193
193
  }
194
194
 
195
195
  export function evaluateEvidence(report, evidence) {
196
- const consoleErrors = evidence.consoleMessages.filter((message) => message.type === "error");
196
+ const consoleErrors = evidence.consoleMessages.filter(
197
+ (message) => message.type === "error" && !allowedSandboxGuardConsoleError(message, evidence),
198
+ );
199
+ const failedRequests = evidence.failedRequests.filter(
200
+ (request) => !allowedSandboxGuardUrls(evidence).has(request.url),
201
+ );
202
+
197
203
  addCheck(report, {
198
204
  id: CHECK_IDS.NO_CONSOLE_ERRORS,
199
205
  status: consoleErrors.length === 0 ? "passed" : "failed",
@@ -205,11 +211,11 @@ export function evaluateEvidence(report, evidence) {
205
211
 
206
212
  addCheck(report, {
207
213
  id: CHECK_IDS.NO_FAILED_REQUESTS,
208
- status: evidence.failedRequests.length === 0 ? "passed" : "failed",
214
+ status: failedRequests.length === 0 ? "passed" : "failed",
209
215
  message:
210
- evidence.failedRequests.length === 0
216
+ failedRequests.length === 0
211
217
  ? "No failed browser requests were observed."
212
- : `${evidence.failedRequests.length} failed browser request(s) were observed.`,
218
+ : `${failedRequests.length} failed browser request(s) were observed.`,
213
219
  });
214
220
 
215
221
  const authLeak = browserAuthorizationFinding(evidence.requests);
@@ -455,10 +461,16 @@ async function executeScenarioCheck({
455
461
  messages: [{ role: "user", content: prompt ?? check.prompt }],
456
462
  },
457
463
  });
464
+ const sandboxGuarded = isSandboxCompletionGuard(result);
465
+ if (sandboxGuarded) evidence.allowedSandboxGuardUrls.add(result.url);
458
466
  addCheck(report, {
459
467
  id: CHECK_IDS.CHAT_COMPLETION_SUCCEEDS,
460
- status: result.ok ? "passed" : "failed",
461
- message: result.ok ? "Chat completion succeeded through the client gateway." : result.error,
468
+ status: result.ok || sandboxGuarded ? "passed" : "failed",
469
+ message: result.ok
470
+ ? "Chat completion succeeded through the client gateway."
471
+ : sandboxGuarded
472
+ ? "Sandbox chat reached the client gateway and was blocked before any real AI completion."
473
+ : result.error,
462
474
  });
463
475
  return;
464
476
  }
@@ -504,11 +516,12 @@ async function browserFetchJson(page, clientUrl, endpointPath, init) {
504
516
  return {
505
517
  ok: response.ok,
506
518
  status: response.status,
519
+ url: endpointUrl,
507
520
  data,
508
521
  error: response.ok ? null : `HTTP ${response.status}`,
509
522
  };
510
523
  } catch (error) {
511
- return { ok: false, status: 0, data: null, error: error.message };
524
+ return { ok: false, status: 0, url: endpointUrl, data: null, error: error.message };
512
525
  }
513
526
  },
514
527
  { endpointUrl, init },
@@ -523,6 +536,32 @@ function clientEndpointUrl(clientUrl, endpointPath) {
523
536
  return url.href;
524
537
  }
525
538
 
539
+ function isSandboxCompletionGuard(result) {
540
+ const error = result?.data?.error;
541
+
542
+ return (
543
+ result?.status === 402 &&
544
+ error?.type === "sandbox_completion_requires_prepaid_balance" &&
545
+ typeof error.message === "string" &&
546
+ error.message.includes("Sandbox mode does not run real AI completions") &&
547
+ error.message.includes("No provider was called") &&
548
+ error.message.includes("funded prepaid project balance") &&
549
+ error.message.includes("live mode")
550
+ );
551
+ }
552
+
553
+ function allowedSandboxGuardConsoleError(message, evidence) {
554
+ return (
555
+ allowedSandboxGuardUrls(evidence).size > 0 &&
556
+ /Failed to load resource/i.test(message.text ?? "") &&
557
+ /\b402\b/.test(message.text ?? "")
558
+ );
559
+ }
560
+
561
+ function allowedSandboxGuardUrls(evidence) {
562
+ return evidence.allowedSandboxGuardUrls ?? new Set();
563
+ }
564
+
526
565
  function wireEvidence(page, evidence) {
527
566
  page.on("console", (message) => {
528
567
  evidence.consoleMessages.push({
@@ -592,6 +631,7 @@ function emptyEvidence() {
592
631
  return {
593
632
  consoleMessages: [],
594
633
  failedRequests: [],
634
+ allowedSandboxGuardUrls: new Set(),
595
635
  requests: [],
596
636
  responses: [],
597
637
  texts: [],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@switchboard.spot/cli",
3
- "version": "0.2.4",
4
- "description": "Switchboard CLI full dashboard parity for agents and testing",
3
+ "version": "0.2.5",
4
+ "description": "Switchboard CLI for account, project, gateway, and docs automation",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "switchboard": "bin/switchboard.js"
@@ -1,51 +0,0 @@
1
- /**
2
- * End-user management commands.
3
- */
4
-
5
- import { accountRequest } from "../client.js";
6
- import { emit, globalFlags, printList } from "../output.js";
7
-
8
- export function registerEndUsersCommands(program) {
9
- const endUsers = program
10
- .command("end-users")
11
- .description("Anonymous Client Gateway end users");
12
-
13
- endUsers
14
- .command("list")
15
- .description("List end users")
16
- .action(async (_opts, cmd) => {
17
- const flags = globalFlags(cmd);
18
- const { data } = await accountRequest("GET", "/end_users", { json: flags.json });
19
- if (flags.json) {
20
- emit(data, flags);
21
- } else {
22
- printList("End users:", data.data || [], (u) =>
23
- ` ${u.id} ${u.reference} — ${u.billing_status}`,
24
- );
25
- }
26
- });
27
-
28
- endUsers
29
- .command("block <id>")
30
- .description("Block an end user")
31
- .action(async (id, _opts, cmd) => {
32
- const flags = globalFlags(cmd);
33
- const { data } = await accountRequest("PATCH", `/end_users/${id}`, {
34
- body: { billing_status: "blocked" },
35
- json: flags.json,
36
- });
37
- emit(flags.json ? data : `Blocked ${data.reference}`, flags);
38
- });
39
-
40
- endUsers
41
- .command("unblock <id>")
42
- .description("Unblock an end user")
43
- .action(async (id, _opts, cmd) => {
44
- const flags = globalFlags(cmd);
45
- const { data } = await accountRequest("PATCH", `/end_users/${id}`, {
46
- body: { billing_status: "active" },
47
- json: flags.json,
48
- });
49
- emit(flags.json ? data : `Unblocked ${data.reference}`, flags);
50
- });
51
- }
@@ -1,78 +0,0 @@
1
- /**
2
- * Project init and agent manifest commands.
3
- */
4
-
5
- import fs from "fs";
6
- import path from "path";
7
- import { resolveConfig, gatewayApiUrl } from "../config.js";
8
- import { emit } from "../output.js";
9
-
10
- /**
11
- * Builds default frontend environment placeholders for Client Gateway apps.
12
- *
13
- * The generated Client Gateway URL is the only value a browser app needs by default.
14
- */
15
- function envBlock(clientUrl) {
16
- return `SWITCHBOARD_CLIENT_URL=${clientUrl}
17
- VITE_SWITCHBOARD_CLIENT_URL=${clientUrl}
18
- `;
19
- }
20
-
21
- /**
22
- * Builds the agent manifest for automated setup.
23
- */
24
- export function agentManifest(config) {
25
- const base = gatewayApiUrl(config);
26
- const clientUrl = `${config.baseUrl.replace(/\/$/, "")}/m/<client-gateway-slug>/v1`;
27
-
28
- return {
29
- base_url: base,
30
- client_url: clientUrl,
31
- virtual_microservice_url: clientUrl,
32
- account_api: `${config.baseUrl.replace(/\/$/, "")}/v1/account`,
33
- env: envBlock(clientUrl),
34
- checklist: [
35
- "switchboard setup project --origin <origin> --json",
36
- "export VITE_SWITCHBOARD_CLIENT_URL=<client_url from integration kit>",
37
- "Use mountSwitchboardWidget({ clientUrl, target }) in browser apps",
38
- "Use createSwitchboardClient({ clientUrl, storage }) only for custom UI",
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",
42
- ],
43
- browser_smoke_test: `import { mountSwitchboardWidget } from "@switchboard.spot/sdk";
44
-
45
- mountSwitchboardWidget({
46
- clientUrl: import.meta.env.VITE_SWITCHBOARD_CLIENT_URL ?? "${clientUrl}",
47
- target: "#switchboard",
48
- });`,
49
- automation_note:
50
- "Client Gateway auth is browser/mobile only and requires a real browser challenge. Use account/CLI APIs only for project configuration automation.",
51
- };
52
- }
53
-
54
- export function registerInitCommand(program) {
55
- program
56
- .command("init")
57
- .description("Write .env.local with Switchboard placeholders")
58
- .option("--agent", "Print agent manifest JSON")
59
- .action((opts, cmd) => {
60
- const flags = cmd.opts();
61
- const config = resolveConfig();
62
- const clientUrl = `${config.baseUrl.replace(/\/$/, "")}/m/<client-gateway-slug>/v1`;
63
- const target = path.join(process.cwd(), ".env.local");
64
-
65
- if (!fs.existsSync(target)) {
66
- fs.writeFileSync(target, envBlock(clientUrl));
67
- if (!flags.quiet && !flags.json) {
68
- console.log(`Wrote ${target}`);
69
- }
70
- } else if (!flags.quiet && !flags.json) {
71
- console.log(`${target} already exists`);
72
- }
73
-
74
- if (opts.agent || flags.json) {
75
- emit(agentManifest(config), { json: true });
76
- }
77
- });
78
- }
@@ -1,38 +0,0 @@
1
- /**
2
- * Integration kit command.
3
- */
4
-
5
- import { accountRequest } from "../client.js";
6
- import { emit, globalFlags } from "../output.js";
7
-
8
- export function registerIntegrationCommands(program) {
9
- const integrations = program.command("integrations").description("Integration helpers");
10
-
11
- integrations
12
- .command("show")
13
- .description("Fetch integration setup JSON")
14
- .option("--stack <stack>", "node or python", "node")
15
- .action(async (opts, cmd) => {
16
- await showIntegration(opts, cmd);
17
- });
18
-
19
- const integration = program.command("integration").description("Legacy integration helpers");
20
-
21
- integration
22
- .command("kit")
23
- .description("Legacy alias for integrations show")
24
- .option("--stack <stack>", "node or python", "node")
25
- .action(async (opts, cmd) => {
26
- await showIntegration(opts, cmd);
27
- });
28
- }
29
-
30
- async function showIntegration(opts, cmd) {
31
- const flags = globalFlags(cmd);
32
- const { data } = await accountRequest(
33
- "GET",
34
- `/integration_kit?stack=${opts.stack}`,
35
- { json: flags.json },
36
- );
37
- emit(data, flags);
38
- }
@@ -1,106 +0,0 @@
1
- /**
2
- * API key management commands.
3
- */
4
-
5
- import { accountRequest } from "../client.js";
6
- import { saveConfig } from "../config.js";
7
- import { emit, globalFlags, printList } from "../output.js";
8
-
9
- function saveProjectContext(data) {
10
- const updates = {};
11
-
12
- if (data.project_id != null) {
13
- updates.projectId = String(data.project_id);
14
- }
15
-
16
- updates.apiKey = null;
17
-
18
- if (Object.keys(updates).length > 0) {
19
- saveConfig(updates);
20
- }
21
- }
22
-
23
- export function registerKeysCommands(program) {
24
- const keys = program.command("keys").description("Project API keys");
25
-
26
- keys
27
- .command("list")
28
- .description("List API keys for the selected project")
29
- .action(async (_opts, cmd) => {
30
- const flags = globalFlags(cmd);
31
- const { data } = await accountRequest("GET", "/keys", { json: flags.json });
32
- if (flags.json) {
33
- emit(data, flags);
34
- } else {
35
- printList("API keys:", data.data || [], (k) =>
36
- ` ${k.id} ${k.mode} ${k.key_type || "secret"} ${k.name} …${k.last_four}${k.revoked_at ? " (revoked)" : ""}`,
37
- );
38
- }
39
- });
40
-
41
- keys
42
- .command("create")
43
- .description("Create a new API key")
44
- .option("--mode <mode>", "sandbox or live", "sandbox")
45
- .option("--name <name>", "Key label")
46
- .action(async (opts, cmd) => {
47
- const flags = globalFlags(cmd);
48
- const body = { mode: opts.mode, name: opts.name };
49
-
50
- const { data } = await accountRequest("POST", "/keys", {
51
- body,
52
- json: flags.json,
53
- });
54
-
55
- saveProjectContext(data);
56
- emit(
57
- flags.json
58
- ? data
59
- : `Created ${data.mode} key. Plaintext (shown once): ${data.plaintext}`,
60
- flags,
61
- );
62
- });
63
-
64
- keys
65
- .command("regenerate-sandbox")
66
- .description("Revoke sandbox secret keys and create a new one")
67
- .action(async (_opts, cmd) => {
68
- const flags = globalFlags(cmd);
69
- const { data } = await accountRequest("POST", "/keys/sandbox/regenerate", {
70
- json: flags.json,
71
- });
72
- saveProjectContext(data);
73
- emit(
74
- flags.json
75
- ? data
76
- : `New sandbox key: ${data.plaintext}`,
77
- flags,
78
- );
79
- });
80
-
81
- keys
82
- .command("revoke <id>")
83
- .description("Revoke an API key")
84
- .action(async (id, _opts, cmd) => {
85
- const flags = globalFlags(cmd);
86
- const { data } = await accountRequest("POST", `/keys/${id}/revoke`, {
87
- json: flags.json,
88
- });
89
- emit(flags.json ? data : `Revoked key ${id}`, flags);
90
- });
91
-
92
- keys
93
- .command("rotate <id>")
94
- .description("Rotate an API key")
95
- .action(async (id, _opts, cmd) => {
96
- const flags = globalFlags(cmd);
97
- const { data } = await accountRequest("POST", `/keys/${id}/rotate`, {
98
- json: flags.json,
99
- });
100
- saveProjectContext(data);
101
- emit(
102
- flags.json ? data : `Rotated key ${id}. New plaintext: ${data.plaintext}`,
103
- flags,
104
- );
105
- });
106
- }
@@ -1,213 +0,0 @@
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
- }
@@ -1,55 +0,0 @@
1
- /**
2
- * Organization invitation commands.
3
- */
4
-
5
- import { accountRequest } from "../client.js";
6
- import { emit, globalFlags, printList } from "../output.js";
7
-
8
- export function registerOrgCommands(program) {
9
- const org = program.command("org").description("Organization and team");
10
-
11
- org
12
- .command("invitations")
13
- .description("List pending invitations")
14
- .action(async (_opts, cmd) => {
15
- const flags = globalFlags(cmd);
16
- const { data } = await accountRequest("GET", "/invitations", { json: flags.json });
17
- if (flags.json) {
18
- emit(data, flags);
19
- } else {
20
- printList("Pending invitations:", data.data || [], (i) =>
21
- ` ${i.email} (${i.role}) token=${i.token}`,
22
- );
23
- }
24
- });
25
-
26
- org
27
- .command("invite")
28
- .description("Invite a member by email")
29
- .requiredOption("--email <email>")
30
- .action(async (opts, cmd) => {
31
- const flags = globalFlags(cmd);
32
- const { data } = await accountRequest("POST", "/invitations", {
33
- body: { email: opts.email },
34
- json: flags.json,
35
- });
36
- emit(
37
- flags.json ? data : `Invited ${data.email}. Token: ${data.token}`,
38
- flags,
39
- );
40
- });
41
-
42
- org
43
- .command("accept <token>")
44
- .description("Accept an invitation")
45
- .action(async (token, _opts, cmd) => {
46
- const flags = globalFlags(cmd);
47
- const { data } = await accountRequest("POST", `/invitations/${token}/accept`, {
48
- json: flags.json,
49
- });
50
- emit(
51
- flags.json ? data : `Joined organization ${data.name}`,
52
- flags,
53
- );
54
- });
55
- }