@switchboard.spot/cli 0.2.0 → 0.2.1

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
@@ -60,7 +60,7 @@ switchboard chat test --json
60
60
 
61
61
  Use `--json` for automation, CI, and coding agents.
62
62
 
63
- CLI account login does not create Client Gateway end-user sessions. End-user sign-up, sign-in, and refresh require a real browser/mobile challenge flow; use `@switchboard/sdk` in the app, or use trusted-server/account APIs for automation.
63
+ CLI account login does not create Client Gateway end-user sessions. End-user sign-up, sign-in, and refresh require a real browser/mobile challenge flow; use `@switchboard.spot/sdk` in the app, or use trusted-server/account APIs for automation.
64
64
 
65
65
  Project-owned browser challenge keys are managed through the CLI:
66
66
 
@@ -82,14 +82,21 @@ Environment variables:
82
82
  | `SWITCHBOARD_PROJECT_ID` | Project context for project-scoped commands. |
83
83
  | `SWITCHBOARD_API_KEY` | Secret project key for trusted-server gateway smoke tests. |
84
84
  | `SWITCHBOARD_CLIENT_URL` | Public Client Gateway URL for browser/mobile end-user auth and chat. |
85
+ | `VITE_SWITCHBOARD_CLIENT_URL` | Vite-safe public Client Gateway URL for browser apps. |
85
86
  | `SWITCHBOARD_END_USER_SESSION` | Existing end-user session for Client Gateway checks; the CLI cannot mint one without browser challenge execution. |
86
87
  | `SWITCHBOARD_CONFIG_DIR` | Alternate CLI config directory. |
87
88
 
88
89
  Account sessions are stored in the OS keychain and are not read from
89
90
  environment variables.
90
91
 
92
+ For isolated agent automation, `switchboard auth login --token-store config-dir`
93
+ stores the account session in `SWITCHBOARD_CONFIG_DIR/account-session.json` with
94
+ 0600 permissions. The default remains keychain-only. `switchboard auth logout`
95
+ clears both keychain and config-dir account sessions.
96
+
91
97
  ## Credential safety
92
98
 
93
99
  Do not paste `sb_sess_`, `sb_test_`, `sb_live_`, provider keys, private keys, or
94
100
  webhook secrets into frontend, mobile, or public code. Browser and mobile code
95
- should use `SWITCHBOARD_CLIENT_URL` plus end-user sessions.
101
+ should use `VITE_SWITCHBOARD_CLIENT_URL` in Vite apps or `SWITCHBOARD_CLIENT_URL`
102
+ in non-Vite tooling, plus end-user sessions.
@@ -4,7 +4,12 @@
4
4
 
5
5
  import { accountPublicRequest, accountRequest } from "../client.js";
6
6
  import { accountApiUrl, resolveAccountConfig, resolveConfig, saveConfig } from "../config.js";
7
- import { deleteAccountToken, setAccountToken } from "../credentialStore.js";
7
+ import {
8
+ deleteAccountToken,
9
+ deleteConfigDirAccountToken,
10
+ setAccountToken,
11
+ setConfigDirAccountToken,
12
+ } from "../credentialStore.js";
8
13
  import { emit, fail, globalFlags } from "../output.js";
9
14
  import crypto from "node:crypto";
10
15
  import http from "node:http";
@@ -33,10 +38,15 @@ export function registerCommand(program) {
33
38
 
34
39
  auth
35
40
  .command("login")
36
- .description("Sign in through the browser and save session in the OS keychain")
41
+ .description("Sign in through the browser and save an account session")
37
42
  .option("--email <email>")
38
43
  .option("--password <password>")
39
44
  .option("--timeout-seconds <seconds>", "Seconds to wait for browser login", "120")
45
+ .option(
46
+ "--token-store <store>",
47
+ "Where to save the session: keychain, config-dir, or both",
48
+ "keychain",
49
+ )
40
50
  .action(async (opts, cmd) => {
41
51
  const flags = globalFlags(cmd);
42
52
 
@@ -49,9 +59,18 @@ export function registerCommand(program) {
49
59
  fail("timeout-seconds must be a positive integer", 1, flags.json);
50
60
  }
51
61
 
52
- const data = await browserLogin(flags, timeoutSeconds);
62
+ const tokenStore = normalizeTokenStore(opts.tokenStore, flags.json);
63
+ const data = await browserLogin(flags, timeoutSeconds, tokenStore);
64
+ const sessionLocation =
65
+ tokenStore === "keychain"
66
+ ? "the OS keychain"
67
+ : tokenStore === "config-dir"
68
+ ? "the Switchboard config directory"
69
+ : "the OS keychain and Switchboard config directory";
53
70
  emit(
54
- flags.json ? data : `Logged in as ${data.user.email}. Session saved in the OS keychain.`,
71
+ flags.json
72
+ ? data
73
+ : `Logged in as ${data.user.email}. Session saved in ${sessionLocation}.`,
55
74
  flags,
56
75
  );
57
76
  });
@@ -61,8 +80,9 @@ export function registerCommand(program) {
61
80
  .description("Revoke current session and clear saved token")
62
81
  .action(async (_opts, cmd) => {
63
82
  const flags = globalFlags(cmd);
64
- await revokeKeychainSession();
65
- await deleteAccountToken();
83
+ await revokeSavedSession();
84
+ await deleteKeychainTokenIfAvailable();
85
+ deleteConfigDirAccountToken();
66
86
  saveConfig({
67
87
  projectId: null,
68
88
  apiKey: null,
@@ -91,8 +111,30 @@ export function registerCommand(program) {
91
111
  });
92
112
  }
93
113
 
94
- async function revokeKeychainSession() {
95
- const config = await resolveAccountConfig();
114
+ async function deleteKeychainTokenIfAvailable() {
115
+ try {
116
+ await deleteAccountToken();
117
+ } catch {
118
+ /* Local logout should still clear config-dir credentials without a keychain backend. */
119
+ }
120
+ }
121
+
122
+ function normalizeTokenStore(tokenStore, json) {
123
+ if (tokenStore === "keychain" || tokenStore === "config-dir" || tokenStore === "both") {
124
+ return tokenStore;
125
+ }
126
+
127
+ fail("--token-store must be keychain, config-dir, or both", 1, json);
128
+ }
129
+
130
+ async function revokeSavedSession() {
131
+ let config;
132
+
133
+ try {
134
+ config = await resolveAccountConfig();
135
+ } catch {
136
+ return;
137
+ }
96
138
 
97
139
  if (!config.accountToken) {
98
140
  return;
@@ -114,7 +156,7 @@ async function revokeKeychainSession() {
114
156
  /**
115
157
  * Starts the loopback callback server, opens the browser handoff URL, and saves the exchanged session.
116
158
  */
117
- async function browserLogin(flags, timeoutSeconds) {
159
+ async function browserLogin(flags, timeoutSeconds, tokenStore) {
118
160
  const state = randomState();
119
161
  const server = await startCallbackServer();
120
162
  const callbackUrl = `http://127.0.0.1:${server.port}/callback`;
@@ -133,6 +175,7 @@ async function browserLogin(flags, timeoutSeconds) {
133
175
  code: callback.code,
134
176
  state: callback.state,
135
177
  expectedState: state,
178
+ tokenStore,
136
179
  json: flags.json,
137
180
  });
138
181
 
@@ -149,10 +192,11 @@ export async function exchangeCliLogin({
149
192
  code,
150
193
  state,
151
194
  expectedState,
195
+ tokenStore = "keychain",
152
196
  json,
153
197
  request = accountPublicRequest,
154
198
  save = saveConfig,
155
- credentials = { setAccountToken },
199
+ credentials = { setAccountToken, setConfigDirAccountToken },
156
200
  }) {
157
201
  if (!code) {
158
202
  fail("Browser login did not return a code. Run `switchboard auth login` again.", 2, json);
@@ -167,7 +211,7 @@ export async function exchangeCliLogin({
167
211
  json,
168
212
  });
169
213
 
170
- await credentials.setAccountToken(data.token);
214
+ await storeAccountToken(data.token, tokenStore, credentials);
171
215
 
172
216
  save({
173
217
  projectId: null,
@@ -178,6 +222,26 @@ export async function exchangeCliLogin({
178
222
  return sanitizeSession(data);
179
223
  }
180
224
 
225
+ async function storeAccountToken(token, tokenStore, credentials) {
226
+ if (tokenStore === "keychain") {
227
+ await credentials.setAccountToken(token);
228
+ return;
229
+ }
230
+
231
+ if (tokenStore === "config-dir") {
232
+ await credentials.setConfigDirAccountToken(token);
233
+ return;
234
+ }
235
+
236
+ if (tokenStore === "both") {
237
+ await credentials.setAccountToken(token);
238
+ await credentials.setConfigDirAccountToken(token);
239
+ return;
240
+ }
241
+
242
+ throw new Error("Unsupported Switchboard account token store");
243
+ }
244
+
181
245
  function sanitizeSession(data) {
182
246
  const rest = { ...data };
183
247
  delete rest.token;
@@ -88,7 +88,9 @@ export async function configureEnvironment(
88
88
  const envUpdates = {};
89
89
 
90
90
  if (mode === "client" || mode === "both") {
91
- envUpdates.SWITCHBOARD_CLIENT_URL = kit.client_url || kit.virtual_microservice_url;
91
+ const clientUrl = kit.client_url || kit.virtual_microservice_url;
92
+ envUpdates.SWITCHBOARD_CLIENT_URL = clientUrl;
93
+ envUpdates.VITE_SWITCHBOARD_CLIENT_URL = clientUrl;
92
94
  }
93
95
 
94
96
  if (mode === "server" || mode === "both") {
@@ -14,6 +14,7 @@ import { emit } from "../output.js";
14
14
  */
15
15
  function envBlock(clientUrl) {
16
16
  return `SWITCHBOARD_CLIENT_URL=${clientUrl}
17
+ VITE_SWITCHBOARD_CLIENT_URL=${clientUrl}
17
18
  `;
18
19
  }
19
20
 
@@ -32,15 +33,15 @@ export function agentManifest(config) {
32
33
  env: envBlock(clientUrl),
33
34
  checklist: [
34
35
  "switchboard setup --target client --json",
35
- "export SWITCHBOARD_CLIENT_URL=<client_url from integrations show>",
36
+ "export VITE_SWITCHBOARD_CLIENT_URL=<client_url from integrations show>",
36
37
  "Use mountSwitchboardWidget({ clientUrl, target }) in browser apps",
37
38
  "Use createSwitchboardClient({ clientUrl, storage }) only for custom UI",
38
39
  "switchboard billing top-up --amount-micros <micros>",
39
40
  ],
40
- browser_smoke_test: `import { mountSwitchboardWidget } from "@switchboard/sdk";
41
+ browser_smoke_test: `import { mountSwitchboardWidget } from "@switchboard.spot/sdk";
41
42
 
42
43
  mountSwitchboardWidget({
43
- clientUrl: process.env.SWITCHBOARD_CLIENT_URL ?? "${clientUrl}",
44
+ clientUrl: import.meta.env.VITE_SWITCHBOARD_CLIENT_URL ?? "${clientUrl}",
44
45
  target: "#switchboard",
45
46
  });`,
46
47
  automation_note:
@@ -53,13 +53,16 @@ function normalizeSetupTarget(target, json) {
53
53
  }
54
54
 
55
55
  function smokeCheck(result) {
56
+ const clientEnvConfigured =
57
+ result.changed.includes("VITE_SWITCHBOARD_CLIENT_URL") ||
58
+ result.skipped.includes("VITE_SWITCHBOARD_CLIENT_URL") ||
59
+ result.changed.includes("SWITCHBOARD_CLIENT_URL") ||
60
+ result.skipped.includes("SWITCHBOARD_CLIENT_URL");
61
+
56
62
  return {
57
63
  ok: true,
58
64
  checks: [
59
- result.changed.includes("SWITCHBOARD_CLIENT_URL") ||
60
- result.skipped.includes("SWITCHBOARD_CLIENT_URL")
61
- ? "client_env"
62
- : null,
65
+ clientEnvConfigured ? "client_env" : null,
63
66
  result.changed.includes("SWITCHBOARD_BASE_URL") ||
64
67
  result.skipped.includes("SWITCHBOARD_BASE_URL")
65
68
  ? "server_env"
package/lib/config.js CHANGED
@@ -6,7 +6,7 @@
6
6
  import fs from "fs";
7
7
  import os from "os";
8
8
  import path from "path";
9
- import { getAccountToken } from "./credentialStore.js";
9
+ import { getAccountToken, getConfigDirAccountToken } from "./credentialStore.js";
10
10
 
11
11
  const CONFIG_DIR =
12
12
  process.env.SWITCHBOARD_CONFIG_DIR || path.join(os.homedir(), ".switchboard");
@@ -106,15 +106,43 @@ export async function resolveAccountConfig(config) {
106
106
  }
107
107
 
108
108
  const base = config || resolveConfig();
109
- const token = await getAccountToken();
109
+ const keychain = await readKeychainAccountToken();
110
+ if (keychain.token) {
111
+ return {
112
+ ...base,
113
+ accountToken: keychain.token,
114
+ accountTokenSource: "keychain",
115
+ };
116
+ }
117
+
118
+ const configDirToken = getConfigDirAccountToken();
119
+ if (configDirToken) {
120
+ return {
121
+ ...base,
122
+ accountToken: configDirToken,
123
+ accountTokenSource: "config-dir",
124
+ };
125
+ }
126
+
127
+ if (keychain.error) {
128
+ throw keychain.error;
129
+ }
110
130
 
111
131
  return {
112
132
  ...base,
113
- accountToken: token,
114
- accountTokenSource: token ? "keychain" : null,
133
+ accountToken: null,
134
+ accountTokenSource: null,
115
135
  };
116
136
  }
117
137
 
138
+ async function readKeychainAccountToken() {
139
+ try {
140
+ return { token: await getAccountToken(), error: null };
141
+ } catch (error) {
142
+ return { token: null, error };
143
+ }
144
+ }
145
+
118
146
  /**
119
147
  * Account API base URL (host + /v1/account).
120
148
  */
@@ -9,12 +9,16 @@
9
9
 
10
10
  import { execFile } from "node:child_process";
11
11
  import { spawn } from "node:child_process";
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
12
15
  import { promisify } from "node:util";
13
16
 
14
17
  const execFileAsync = promisify(execFile);
15
18
  const SERVICE = "Switchboard CLI";
16
19
  const ACCOUNT = "account-session";
17
20
  const PROJECT_SECRET_PREFIX = "project-secret";
21
+ const ACCOUNT_SESSION_FILE = "account-session.json";
18
22
 
19
23
  /**
20
24
  * Reads the current account session token from the OS keychain.
@@ -54,6 +58,30 @@ export async function getAccountToken() {
54
58
  }
55
59
  }
56
60
 
61
+ /**
62
+ * Reads the current account session token from the isolated CLI config dir.
63
+ */
64
+ export function getConfigDirAccountToken(configDir = switchboardConfigDir()) {
65
+ const file = accountSessionFile(configDir);
66
+
67
+ if (!fs.existsSync(file)) {
68
+ return null;
69
+ }
70
+
71
+ let data;
72
+ try {
73
+ data = JSON.parse(fs.readFileSync(file, "utf8"));
74
+ } catch (error) {
75
+ throw new Error(`Could not read Switchboard config-dir account session: ${error.message}`);
76
+ }
77
+
78
+ if (!data || typeof data.token !== "string" || data.token.trim() === "") {
79
+ throw new Error("Switchboard config-dir account session is invalid");
80
+ }
81
+
82
+ return data.token;
83
+ }
84
+
57
85
  /**
58
86
  * Writes the current account session token to the OS keychain.
59
87
  */
@@ -92,6 +120,23 @@ export async function setAccountToken(token) {
92
120
  }
93
121
  }
94
122
 
123
+ /**
124
+ * Writes the current account session token to the isolated CLI config dir.
125
+ */
126
+ export function setConfigDirAccountToken(token, configDir = switchboardConfigDir()) {
127
+ if (!token) {
128
+ throw new Error("Cannot store an empty Switchboard account token");
129
+ }
130
+
131
+ fs.mkdirSync(configDir, { recursive: true });
132
+ fs.writeFileSync(
133
+ accountSessionFile(configDir),
134
+ `${JSON.stringify({ token }, null, 2)}\n`,
135
+ { mode: 0o600 },
136
+ );
137
+ chmodAccountSessionFile(configDir);
138
+ }
139
+
95
140
  /**
96
141
  * Deletes the current account session token from the OS keychain.
97
142
  */
@@ -129,6 +174,29 @@ export async function deleteAccountToken() {
129
174
  }
130
175
  }
131
176
 
177
+ /**
178
+ * Deletes the current account session token from the isolated CLI config dir.
179
+ */
180
+ export function deleteConfigDirAccountToken(configDir = switchboardConfigDir()) {
181
+ fs.rmSync(accountSessionFile(configDir), { force: true });
182
+ }
183
+
184
+ export function accountSessionFile(configDir = switchboardConfigDir()) {
185
+ return path.join(configDir, ACCOUNT_SESSION_FILE);
186
+ }
187
+
188
+ function switchboardConfigDir() {
189
+ return process.env.SWITCHBOARD_CONFIG_DIR || path.join(os.homedir(), ".switchboard");
190
+ }
191
+
192
+ function chmodAccountSessionFile(configDir) {
193
+ try {
194
+ fs.chmodSync(accountSessionFile(configDir), 0o600);
195
+ } catch {
196
+ /* best effort on platforms that do not support chmod */
197
+ }
198
+ }
199
+
132
200
  /**
133
201
  * Reads a stored project secret key from the OS keychain.
134
202
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchboard.spot/cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Switchboard CLI — full dashboard parity for agents and testing",
5
5
  "type": "module",
6
6
  "bin": {