@switchboard.spot/cli 0.2.3 → 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.
@@ -4,22 +4,11 @@
4
4
 
5
5
  import fs from "fs";
6
6
  import path from "path";
7
- import { spawn } from "node:child_process";
8
7
  import { accountRequest } from "../client.js";
9
- import { resolveAccountConfig, resolveConfig } from "../config.js";
10
- import {
11
- getProjectSecretKey,
12
- setProjectSecretKey,
13
- } from "../credentialStore.js";
8
+ import { resolveConfig } from "../config.js";
14
9
  import { emit, fail, globalFlags } from "../output.js";
15
10
 
16
11
  const DEFAULT_ENV_FILE = ".env.local";
17
- const SECRET_ENV_NAMES = ["SWITCHBOARD_API_KEY"];
18
- const SECRET_PATTERNS = [
19
- /sb_test_[A-Za-z0-9_-]+/g,
20
- /sb_live_[A-Za-z0-9_-]+/g,
21
- /sb_sess_[A-Za-z0-9_-]+/g,
22
- ];
23
12
 
24
13
  export function registerEnvCommands(program) {
25
14
  const env = program.command("env").description("Agent-safe environment setup");
@@ -27,10 +16,8 @@ export function registerEnvCommands(program) {
27
16
  env
28
17
  .command("configure")
29
18
  .description("Configure Switchboard environment values without printing secrets")
30
- .option("--mode <mode>", "client, server, or both", "client")
19
+ .option("--mode <mode>", "client", "client")
31
20
  .option("--file <path>", "Local env file to update", DEFAULT_ENV_FILE)
32
- .option("--target <target>", "local or exec", "local")
33
- .option("--secret-command <command>", "Command that stores secrets from stdin JSON")
34
21
  .option("--project-id <id>", "Override selected project")
35
22
  .option("--force", "Replace existing Switchboard-managed values")
36
23
  .action(async (opts, cmd) => {
@@ -41,12 +28,12 @@ export function registerEnvCommands(program) {
41
28
 
42
29
  env
43
30
  .command("run")
44
- .description("Run a command with Switchboard managed secrets injected")
31
+ .description("Deprecated: server secret injection has been removed")
45
32
  .allowUnknownOption(true)
46
33
  .argument("[command...]", "Command to run")
47
34
  .action(async (command, _opts, cmd) => {
48
35
  const flags = globalFlags(cmd);
49
- await runWithEnvironment(command, { json: flags.json });
36
+ runWithEnvironment(command, { json: flags.json });
50
37
  });
51
38
  }
52
39
 
@@ -54,162 +41,81 @@ export async function configureEnvironment(
54
41
  opts,
55
42
  {
56
43
  request = accountRequest,
57
- getSecret = getProjectSecretKey,
58
- setSecret = setProjectSecretKey,
59
44
  cwd = process.cwd(),
60
45
  json = false,
61
- runSecretCommand = runSecretSinkCommand,
62
46
  } = {},
63
47
  ) {
64
48
  const mode = normalizeMode(opts.mode);
65
- const target = normalizeTarget(opts.target);
66
49
  const projectId = opts.projectId;
67
50
  const force = Boolean(opts.force);
68
51
 
69
- if (target === "exec" && !opts.secretCommand) {
70
- fail("--secret-command is required when --target exec is used", 1, json);
71
- }
72
-
73
- const { data: kit } = await request("GET", "/integration_kit", {
74
- json,
75
- projectId,
76
- });
77
-
78
- const selectedProjectId = String(projectId || kit.project_id || resolveConfig().projectId || "");
52
+ const project = await resolveProjectContext({ projectId, request, json });
53
+ const selectedProjectId = String(project.id || resolveConfig().projectId || "");
79
54
  if (!selectedProjectId) {
80
55
  fail("Could not determine the selected Switchboard project id", 1, json);
81
56
  }
82
57
 
83
58
  const changed = [];
84
59
  const skipped = [];
85
- const secrets = [];
86
60
  const envFile = path.resolve(cwd, opts.file || DEFAULT_ENV_FILE);
87
61
 
88
62
  const envUpdates = {};
89
63
 
90
- if (mode === "client" || mode === "both") {
91
- const clientUrl = kit.client_url || kit.virtual_microservice_url;
92
- envUpdates.SWITCHBOARD_CLIENT_URL = clientUrl;
93
- envUpdates.VITE_SWITCHBOARD_CLIENT_URL = clientUrl;
94
- }
95
-
96
- if (mode === "server" || mode === "both") {
97
- envUpdates.SWITCHBOARD_BASE_URL = kit.server_base_url || kit.base_url;
98
- }
64
+ const clientUrl = project.client_gateway_url;
65
+ envUpdates.SWITCHBOARD_CLIENT_URL = clientUrl;
66
+ envUpdates.VITE_SWITCHBOARD_CLIENT_URL = clientUrl;
99
67
 
100
68
  const fileResult = updateEnvFile(envFile, envUpdates, { force });
101
69
  changed.push(...fileResult.changed);
102
70
  skipped.push(...fileResult.skipped);
103
71
 
104
- if (mode === "server" || mode === "both") {
105
- const secretResult = await ensureServerSecret({
106
- projectId: selectedProjectId,
107
- force,
108
- request,
109
- getSecret,
110
- setSecret,
111
- target,
112
- secretCommand: opts.secretCommand,
113
- runSecretCommand,
114
- json,
115
- });
116
-
117
- changed.push(...secretResult.changed);
118
- skipped.push(...secretResult.skipped);
119
- secrets.push(secretResult.secret);
120
- }
121
-
122
72
  return {
123
73
  ok: true,
124
74
  project_id: selectedProjectId,
125
75
  mode,
126
- target,
127
76
  env_file: envFile,
128
77
  changed,
129
78
  skipped,
130
- secrets,
131
79
  };
132
80
  }
133
81
 
134
- export async function runWithEnvironment(
135
- command,
136
- {
137
- config,
138
- getSecret = getProjectSecretKey,
139
- env = process.env,
140
- spawnProcess = spawn,
141
- exitProcess = process.exit,
142
- killProcess = process.kill,
143
- json = false,
144
- } = {},
145
- ) {
146
- if (!command || command.length === 0) {
147
- fail("Usage: switchboard env run -- <command>", 1, json);
82
+ async function resolveProjectContext({ projectId, request, json }) {
83
+ if (projectId) {
84
+ const { data } = await request("GET", `/projects/${projectId}`, { json });
85
+ return data;
148
86
  }
149
87
 
150
- const cfg = await resolveAccountConfig(config || resolveConfig());
151
- if (!cfg.projectId) {
152
- fail(
153
- "Specify a project before this command. Run: switchboard projects list, then switchboard projects use <id>.",
154
- 1,
155
- json,
156
- "project_required",
157
- );
88
+ const configProjectId = resolveConfig().projectId;
89
+ if (configProjectId) {
90
+ const { data } = await request("GET", `/projects/${configProjectId}`, { json });
91
+ return data;
158
92
  }
159
93
 
160
- const secret = await getSecret(cfg.projectId, "sandbox");
161
- if (!secret) {
162
- fail(
163
- "No managed Switchboard server secret found. Run: switchboard env configure --mode server",
164
- 1,
165
- json,
166
- "secret_required",
167
- );
94
+ const { data } = await request("GET", "/me", { json });
95
+ const project = data.project || data.projects?.[0];
96
+ if (!project) {
97
+ fail("No Switchboard project is available. Run switchboard setup project first.", 1, json);
168
98
  }
99
+ return project;
100
+ }
169
101
 
170
- const child = spawnProcess(command[0], command.slice(1), {
171
- stdio: "inherit",
172
- env: {
173
- ...env,
174
- SWITCHBOARD_API_KEY: secret,
175
- SWITCHBOARD_PROJECT_ID: cfg.projectId,
176
- },
177
- });
178
-
179
- return new Promise((resolve, reject) => {
180
- child.on("exit", (code, signal) => {
181
- try {
182
- if (signal) {
183
- killProcess(process.pid, signal);
184
- return;
185
- }
186
-
187
- resolve(exitProcess(code ?? 0));
188
- } catch (error) {
189
- reject(error);
190
- }
191
- });
192
- });
102
+ export async function runWithEnvironment(
103
+ _command,
104
+ { json = false } = {},
105
+ ) {
106
+ fail("switchboard env run was removed with project secret injection", 1, json);
193
107
  }
194
108
 
195
109
  function normalizeMode(mode) {
196
- if (mode === "client" || mode === "server" || mode === "both") {
197
- return mode;
110
+ if (mode === "client" || mode === undefined) {
111
+ return "client";
198
112
  }
199
113
 
200
114
  if (mode === "virtual") {
201
115
  return "client";
202
116
  }
203
117
 
204
- fail("--mode must be client, server, or both");
205
- }
206
-
207
- function normalizeTarget(target) {
208
- if (target === "local" || target === "exec") {
209
- return target;
210
- }
211
-
212
- fail("--target must be local or exec");
118
+ fail("--mode must be client");
213
119
  }
214
120
 
215
121
  function updateEnvFile(file, updates, { force }) {
@@ -268,126 +174,6 @@ function trimTrailingBlankLines(lines) {
268
174
  return next;
269
175
  }
270
176
 
271
- async function ensureServerSecret({
272
- projectId,
273
- force,
274
- request,
275
- getSecret,
276
- setSecret,
277
- target,
278
- secretCommand,
279
- runSecretCommand,
280
- json,
281
- }) {
282
- const existing = await getSecret(projectId, "sandbox");
283
- const changed = [];
284
- const skipped = [];
285
- let plaintext = existing;
286
- let metadata = null;
287
-
288
- if (!plaintext || force) {
289
- const { data } = await request("POST", "/keys", {
290
- body: {
291
- mode: "sandbox",
292
- name: "Managed server secret",
293
- key_type: "secret",
294
- },
295
- json,
296
- projectId,
297
- });
298
-
299
- plaintext = data.plaintext;
300
- metadata = redactedKeyMetadata(data);
301
- changed.push("SWITCHBOARD_API_KEY");
302
- } else {
303
- skipped.push("SWITCHBOARD_API_KEY");
304
- }
305
-
306
- if (!plaintext) {
307
- fail("Switchboard did not return a one-time project secret key", 1, json);
308
- }
309
-
310
- if (target === "local") {
311
- await setSecret(projectId, "sandbox", plaintext);
312
- } else {
313
- await runSecretCommand(secretCommand, {
314
- secrets: SECRET_ENV_NAMES.map((name) => ({
315
- name,
316
- value: plaintext,
317
- metadata: metadata || { project_id: projectId, mode: "sandbox" },
318
- })),
319
- redactValues: [plaintext],
320
- });
321
- }
322
-
323
- return {
324
- changed,
325
- skipped,
326
- secret: {
327
- name: "SWITCHBOARD_API_KEY",
328
- stored: target,
329
- ...metadata,
330
- redacted: true,
331
- },
332
- };
333
- }
334
-
335
- function redactedKeyMetadata(data) {
336
- return {
337
- key_id: data.id,
338
- project_id: data.project_id != null ? String(data.project_id) : undefined,
339
- mode: data.mode,
340
- key_type: data.key_type,
341
- last_four: data.last_four,
342
- };
343
- }
344
-
345
- async function runSecretSinkCommand(command, { secrets, redactValues }) {
346
- const redactions = [...SECRET_PATTERNS, ...redactValues.map((value) => new RegExp(escapeRegExp(value), "g"))];
347
- const payload = JSON.stringify({ secrets });
348
-
349
- await new Promise((resolve, reject) => {
350
- const child = spawn(command, {
351
- shell: true,
352
- stdio: ["pipe", "pipe", "pipe"],
353
- });
354
-
355
- let stdout = "";
356
- let stderr = "";
357
-
358
- child.stdout.on("data", (chunk) => {
359
- stdout += redact(String(chunk), redactions);
360
- });
361
- child.stderr.on("data", (chunk) => {
362
- stderr += redact(String(chunk), redactions);
363
- });
364
- child.on("error", reject);
365
- child.on("close", (code) => {
366
- if (code === 0) {
367
- resolve();
368
- } else {
369
- const error = new Error(stderr || stdout || `Secret command exited with ${code}`);
370
- error.code = code;
371
- reject(error);
372
- }
373
- });
374
-
375
- child.stdin.end(payload);
376
- });
377
- }
378
-
379
- function redact(text, redactions) {
380
- return redactions.reduce((acc, pattern) => acc.replace(pattern, "[REDACTED]"), text);
381
- }
382
-
383
- function escapeRegExp(value) {
384
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
385
- }
386
-
387
- function appOriginFromApiBase(baseUrl) {
388
- return String(baseUrl || "").replace(/\/v1\/?$/, "");
389
- }
390
-
391
177
  function humanConfigureMessage(result) {
392
178
  const changed = result.changed.length > 0 ? result.changed.join(", ") : "none";
393
179
  const skipped = result.skipped.length > 0 ? ` Skipped existing: ${result.skipped.join(", ")}.` : "";
@@ -2,6 +2,7 @@
2
2
  * Project management commands.
3
3
  */
4
4
 
5
+ import { readFileSync } from "node:fs";
5
6
  import { accountRequest } from "../client.js";
6
7
  import { saveConfig } from "../config.js";
7
8
  import { emit, globalFlags, printList } from "../output.js";
@@ -38,8 +39,7 @@ export function registerProjectsCommands(program) {
38
39
  });
39
40
  saveConfig({
40
41
  projectId: String(data.id),
41
- apiKey: null,
42
- virtualMicroserviceUrl: data.virtual_microservice_url || null,
42
+ clientGatewayUrl: data.client_gateway_url || null,
43
43
  endUserSession: null,
44
44
  });
45
45
  emit(flags.json ? data : `Created project ${data.name} (${data.id})`, flags);
@@ -70,15 +70,22 @@ export function registerProjectsCommands(program) {
70
70
  "--allowed-origins <urls>",
71
71
  "Comma-separated browser origins for Client Gateway requests",
72
72
  )
73
- .option(
74
- "--allowed-ios-bundle-ids <ids>",
75
- "Comma-separated iOS bundle IDs",
76
- )
77
- .option(
78
- "--allowed-android-packages <packages>",
79
- "Comma-separated Android package names",
80
- )
81
- .option("--virtual-microservice-enabled <boolean>")
73
+ .option("--client-gateway-enabled <boolean>")
74
+ .option("--project-chat-limit <number>")
75
+ .option("--project-chat-window-seconds <number>")
76
+ .option("--end-user-chat-limit <number>")
77
+ .option("--end-user-chat-window-seconds <number>")
78
+ .option("--ip-chat-limit <number>")
79
+ .option("--ip-chat-window-seconds <number>")
80
+ .option("--anonymous-sessions-per-ip-limit <number>")
81
+ .option("--anonymous-sessions-per-ip-window-seconds <number>")
82
+ .option("--session-refresh-per-end-user-limit <number>")
83
+ .option("--session-refresh-per-end-user-window-seconds <number>")
84
+ .option("--allowed-models <slugs>", "Comma-separated Switchboard catalog model slugs")
85
+ .option("--clear-allowed-models", "Clear the project model allowlist")
86
+ .option("--system-prompt <text>", "Configure a private service-side system prompt")
87
+ .option("--system-prompt-file <path>", "Read private service-side system prompt from a file")
88
+ .option("--clear-system-prompt", "Clear the private service-side system prompt")
82
89
  .action(async (id, opts, cmd) => {
83
90
  const flags = globalFlags(cmd);
84
91
  const body = buildProjectUpdateBody(opts);
@@ -122,23 +129,6 @@ export function registerProjectsCommands(program) {
122
129
  emit(flags.json ? data : `Provisioned managed Turnstile for project ${id}`, flags);
123
130
  });
124
131
 
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
-
142
132
  projects
143
133
  .command("use <id>")
144
134
  .description("Set default project for subsequent commands")
@@ -147,8 +137,7 @@ export function registerProjectsCommands(program) {
147
137
  const { data } = await accountRequest("GET", `/projects/${id}`, { json: flags.json });
148
138
  saveConfig({
149
139
  projectId: String(data.id),
150
- apiKey: null,
151
- virtualMicroserviceUrl: data.virtual_microservice_url || null,
140
+ clientGatewayUrl: data.client_gateway_url || null,
152
141
  endUserSession: null,
153
142
  });
154
143
  emit(flags.json ? data : `Using project ${data.name} (${data.id})`, flags);
@@ -165,17 +154,6 @@ export function registerProjectsCommands(program) {
165
154
  });
166
155
  emit(flags.json ? data : `Deleted project ${data.name}`, flags);
167
156
  });
168
-
169
- projects
170
- .command("restore <id>")
171
- .description("Restore a deleted project")
172
- .action(async (id, _opts, cmd) => {
173
- const flags = globalFlags(cmd);
174
- const { data } = await accountRequest("POST", `/projects/${id}/restore`, {
175
- json: flags.json,
176
- });
177
- emit(flags.json ? data : `Restored project ${data.name}`, flags);
178
- });
179
157
  }
180
158
 
181
159
  /**
@@ -203,16 +181,65 @@ export function buildProjectUpdateBody(opts) {
203
181
  if (opts.allowedOrigins != null) {
204
182
  body.allowed_origins = splitList(opts.allowedOrigins);
205
183
  }
206
- if (opts.allowedIosBundleIds != null) {
207
- body.allowed_ios_bundle_ids = splitList(opts.allowedIosBundleIds);
184
+ if (opts.clientGatewayEnabled != null) {
185
+ body.client_gateway_enabled = parseBoolean(opts.clientGatewayEnabled);
208
186
  }
209
- if (opts.allowedAndroidPackages != null) {
210
- body.allowed_android_packages = splitList(opts.allowedAndroidPackages);
187
+ const abuseProtection = buildAbuseProtectionBody(opts);
188
+ if (abuseProtection) body.abuse_protection = abuseProtection;
189
+ const aiPolicy = buildAiPolicyBody(opts);
190
+ if (aiPolicy) body.ai_policy = aiPolicy;
191
+ return body;
192
+ }
193
+
194
+ function buildAiPolicyBody(opts) {
195
+ const aiPolicy = {};
196
+
197
+ if (opts.clearAllowedModels) {
198
+ aiPolicy.allowed_model_slugs = [];
199
+ } else if (opts.allowedModels != null) {
200
+ aiPolicy.allowed_model_slugs = splitList(opts.allowedModels);
211
201
  }
212
- if (opts.virtualMicroserviceEnabled != null) {
213
- body.virtual_microservice_enabled = parseBoolean(opts.virtualMicroserviceEnabled);
202
+
203
+ if (opts.clearSystemPrompt) {
204
+ aiPolicy.system_prompt = "";
205
+ } else if (opts.systemPromptFile) {
206
+ aiPolicy.system_prompt = readFileSync(opts.systemPromptFile, "utf8");
207
+ } else if (opts.systemPrompt != null) {
208
+ aiPolicy.system_prompt = opts.systemPrompt;
209
+ }
210
+
211
+ return Object.keys(aiPolicy).length ? aiPolicy : null;
212
+ }
213
+
214
+ function buildAbuseProtectionBody(opts) {
215
+ const limits = {};
216
+ addLimit(limits, "project_chat", opts.projectChatLimit, opts.projectChatWindowSeconds);
217
+ addLimit(limits, "end_user_chat", opts.endUserChatLimit, opts.endUserChatWindowSeconds);
218
+ addLimit(limits, "ip_chat", opts.ipChatLimit, opts.ipChatWindowSeconds);
219
+ addLimit(
220
+ limits,
221
+ "anonymous_sessions_per_ip",
222
+ opts.anonymousSessionsPerIpLimit,
223
+ opts.anonymousSessionsPerIpWindowSeconds,
224
+ );
225
+ addLimit(
226
+ limits,
227
+ "session_refresh_per_end_user",
228
+ opts.sessionRefreshPerEndUserLimit,
229
+ opts.sessionRefreshPerEndUserWindowSeconds,
230
+ );
231
+
232
+ return Object.keys(limits).length ? { limits } : null;
233
+ }
234
+
235
+ function addLimit(limits, scope, limit, windowSeconds) {
236
+ if (limit == null && windowSeconds == null) return;
237
+
238
+ limits[scope] = {};
239
+ if (limit != null) limits[scope].limit = parsePositiveInteger(limit, `${scope} limit`);
240
+ if (windowSeconds != null) {
241
+ limits[scope].window_seconds = parsePositiveInteger(windowSeconds, `${scope} window seconds`);
214
242
  }
215
- return body;
216
243
  }
217
244
 
218
245
  export function localhostCorsOriginWarning(project) {
@@ -238,27 +265,20 @@ export function buildTurnstileBody(opts) {
238
265
  return body;
239
266
  }
240
267
 
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
-
253
- return body;
254
- }
255
-
256
268
  function parseBoolean(value) {
257
269
  if (value === true || value === "true") return true;
258
270
  if (value === false || value === "false") return false;
259
271
  throw new Error(`Expected boolean value true or false, got ${value}`);
260
272
  }
261
273
 
274
+ function parsePositiveInteger(value, label) {
275
+ const parsed = Number(value);
276
+ if (!Number.isInteger(parsed) || parsed < 1) {
277
+ throw new Error(`Expected ${label} to be an integer greater than or equal to 1`);
278
+ }
279
+ return parsed;
280
+ }
281
+
262
282
  function warnForLocalhostCorsOrigins(projectOrProjects, flags) {
263
283
  if (flags.json || flags.quiet) return;
264
284