@switchboard.spot/cli 0.2.0

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.
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Authentication commands: browser login, disabled register, logout, whoami.
3
+ */
4
+
5
+ import { accountPublicRequest, accountRequest } from "../client.js";
6
+ import { accountApiUrl, resolveAccountConfig, resolveConfig, saveConfig } from "../config.js";
7
+ import { deleteAccountToken, setAccountToken } from "../credentialStore.js";
8
+ import { emit, fail, globalFlags } from "../output.js";
9
+ import crypto from "node:crypto";
10
+ import http from "node:http";
11
+ import { spawn } from "node:child_process";
12
+
13
+ const BROWSER_AUTH_MESSAGE =
14
+ "Account authentication is browser-only. Run `switchboard auth login` and complete sign-in in your browser.";
15
+
16
+ export function registerCommand(program) {
17
+ const auth = program.command("auth").description("Account authentication");
18
+
19
+ auth
20
+ .command("register")
21
+ .description("Account creation is browser-only; use auth login")
22
+ .option("--email <email>")
23
+ .option("--password <password>")
24
+ .action(async (opts, cmd) => {
25
+ const flags = globalFlags(cmd);
26
+ fail(
27
+ "Account registration is browser-only. Run `switchboard auth login` or sign up on the website.",
28
+ 1,
29
+ flags.json,
30
+ "browser_auth_required",
31
+ );
32
+ });
33
+
34
+ auth
35
+ .command("login")
36
+ .description("Sign in through the browser and save session in the OS keychain")
37
+ .option("--email <email>")
38
+ .option("--password <password>")
39
+ .option("--timeout-seconds <seconds>", "Seconds to wait for browser login", "120")
40
+ .action(async (opts, cmd) => {
41
+ const flags = globalFlags(cmd);
42
+
43
+ if (opts.email || opts.password) {
44
+ fail(BROWSER_AUTH_MESSAGE, 1, flags.json, "browser_auth_required");
45
+ }
46
+
47
+ const timeoutSeconds = Number.parseInt(opts.timeoutSeconds, 10);
48
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
49
+ fail("timeout-seconds must be a positive integer", 1, flags.json);
50
+ }
51
+
52
+ const data = await browserLogin(flags, timeoutSeconds);
53
+ emit(
54
+ flags.json ? data : `Logged in as ${data.user.email}. Session saved in the OS keychain.`,
55
+ flags,
56
+ );
57
+ });
58
+
59
+ auth
60
+ .command("logout")
61
+ .description("Revoke current session and clear saved token")
62
+ .action(async (_opts, cmd) => {
63
+ const flags = globalFlags(cmd);
64
+ await revokeKeychainSession();
65
+ await deleteAccountToken();
66
+ saveConfig({
67
+ projectId: null,
68
+ apiKey: null,
69
+ endUserSession: null,
70
+ });
71
+ emit(flags.json ? { ok: true } : "Logged out.", flags);
72
+ });
73
+
74
+ auth
75
+ .command("whoami")
76
+ .description("Show current account profile")
77
+ .action(async (_opts, cmd) => {
78
+ const flags = globalFlags(cmd);
79
+ const { data } = await accountRequest("GET", "/me", { json: flags.json });
80
+ const config = await resolveAccountConfig();
81
+ if (flags.json) {
82
+ emit({ ...data, token_source: config.accountTokenSource }, flags);
83
+ } else {
84
+ console.log(`Email: ${data.user.email}`);
85
+ console.log(`Token source: ${config.accountTokenSource || "none"}`);
86
+ console.log(`Organization: ${data.organization.name}`);
87
+ if (data.project) {
88
+ console.log(`Default project: ${data.project.name} (${data.project.slug})`);
89
+ }
90
+ }
91
+ });
92
+ }
93
+
94
+ async function revokeKeychainSession() {
95
+ const config = await resolveAccountConfig();
96
+
97
+ if (!config.accountToken) {
98
+ return;
99
+ }
100
+
101
+ try {
102
+ await fetch(`${accountApiUrl(config)}/session`, {
103
+ method: "DELETE",
104
+ headers: {
105
+ Authorization: `Bearer ${config.accountToken}`,
106
+ Accept: "application/json",
107
+ },
108
+ });
109
+ } catch {
110
+ /* Local logout should still clear stale keychain credentials. */
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Starts the loopback callback server, opens the browser handoff URL, and saves the exchanged session.
116
+ */
117
+ async function browserLogin(flags, timeoutSeconds) {
118
+ const state = randomState();
119
+ const server = await startCallbackServer();
120
+ const callbackUrl = `http://127.0.0.1:${server.port}/callback`;
121
+ const loginUrl = browserStartUrl(resolveConfig().baseUrl, callbackUrl, state);
122
+
123
+ if (!flags.quiet) {
124
+ console.error("Opening browser for Switchboard login...");
125
+ console.error(`If the browser does not open, visit: ${loginUrl}`);
126
+ }
127
+
128
+ openBrowser(loginUrl);
129
+
130
+ try {
131
+ const callback = await waitForCallback(server, timeoutSeconds, flags.json);
132
+ const data = await exchangeCliLogin({
133
+ code: callback.code,
134
+ state: callback.state,
135
+ expectedState: state,
136
+ json: flags.json,
137
+ });
138
+
139
+ return data;
140
+ } finally {
141
+ server.close();
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Exchanges a browser callback code after validating it belongs to this CLI process.
147
+ */
148
+ export async function exchangeCliLogin({
149
+ code,
150
+ state,
151
+ expectedState,
152
+ json,
153
+ request = accountPublicRequest,
154
+ save = saveConfig,
155
+ credentials = { setAccountToken },
156
+ }) {
157
+ if (!code) {
158
+ fail("Browser login did not return a code. Run `switchboard auth login` again.", 2, json);
159
+ }
160
+
161
+ if (state !== expectedState) {
162
+ fail("Browser login state did not match. Run `switchboard auth login` again.", 2, json);
163
+ }
164
+
165
+ const { data } = await request("POST", "/cli/exchange", {
166
+ body: { code, state },
167
+ json,
168
+ });
169
+
170
+ await credentials.setAccountToken(data.token);
171
+
172
+ save({
173
+ projectId: null,
174
+ apiKey: null,
175
+ endUserSession: null,
176
+ });
177
+
178
+ return sanitizeSession(data);
179
+ }
180
+
181
+ function sanitizeSession(data) {
182
+ const rest = { ...data };
183
+ delete rest.token;
184
+ return rest;
185
+ }
186
+
187
+ function randomState() {
188
+ return crypto.randomBytes(24).toString("base64url");
189
+ }
190
+
191
+ function browserStartUrl(baseUrl, callbackUrl, state) {
192
+ const url = new URL("/auth/cli/start", baseUrl.replace(/\/$/, ""));
193
+ url.searchParams.set("callback_url", callbackUrl);
194
+ url.searchParams.set("state", state);
195
+ return url.toString();
196
+ }
197
+
198
+ function startCallbackServer() {
199
+ return new Promise((resolve, reject) => {
200
+ const server = http.createServer();
201
+
202
+ server.once("error", reject);
203
+ server.listen(0, "127.0.0.1", () => {
204
+ server.removeListener("error", reject);
205
+ resolve({
206
+ server,
207
+ port: server.address().port,
208
+ close: () => server.close(),
209
+ });
210
+ });
211
+ });
212
+ }
213
+
214
+ function waitForCallback(callbackServer, timeoutSeconds, json) {
215
+ const { server } = callbackServer;
216
+
217
+ return new Promise((resolve, reject) => {
218
+ const timeout = setTimeout(() => {
219
+ reject(
220
+ new Error(
221
+ "Timed out waiting for browser login. Run `switchboard auth login` again when you are ready.",
222
+ ),
223
+ );
224
+ }, timeoutSeconds * 1000);
225
+
226
+ server.on("request", (req, res) => {
227
+ const url = new URL(req.url, "http://127.0.0.1");
228
+
229
+ if (url.pathname !== "/callback") {
230
+ res.writeHead(404, { "content-type": "text/plain" });
231
+ res.end("Not found");
232
+ return;
233
+ }
234
+
235
+ clearTimeout(timeout);
236
+ const error = url.searchParams.get("error");
237
+ const code = url.searchParams.get("code");
238
+ const state = url.searchParams.get("state");
239
+
240
+ if (error) {
241
+ res.writeHead(400, { "content-type": "text/html" });
242
+ res.end(callbackPage("Switchboard CLI login failed", "Return to your terminal and run `switchboard auth login` again.", "error"));
243
+ reject(new Error(error));
244
+ return;
245
+ }
246
+
247
+ res.writeHead(200, { "content-type": "text/html" });
248
+ res.end(callbackPage("Switchboard CLI login complete", "Your terminal is signed in. You can close this tab.", "success"));
249
+ resolve({ code, state });
250
+ });
251
+ }).catch((error) => {
252
+ fail(error.message, 2, json, "browser_login_failed");
253
+ });
254
+ }
255
+
256
+ function callbackPage(title, message, tone) {
257
+ const accent = tone === "success" ? "#28d17c" : "#ff6b6b";
258
+ const mark = tone === "success" ? "✓" : "!";
259
+
260
+ return `<!doctype html>
261
+ <html lang="en">
262
+ <head>
263
+ <meta charset="utf-8" />
264
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
265
+ <title>${escapeHtml(title)}</title>
266
+ <style>
267
+ :root {
268
+ color-scheme: dark;
269
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
270
+ background: #05070d;
271
+ color: #f7f7fb;
272
+ }
273
+ body {
274
+ min-height: 100vh;
275
+ margin: 0;
276
+ display: grid;
277
+ place-items: center;
278
+ background:
279
+ radial-gradient(circle at 20% 20%, rgba(68, 109, 255, 0.22), transparent 32rem),
280
+ radial-gradient(circle at 80% 10%, rgba(40, 209, 124, 0.16), transparent 26rem),
281
+ linear-gradient(135deg, #05070d 0%, #101526 100%);
282
+ }
283
+ main {
284
+ width: min(92vw, 34rem);
285
+ border: 1px solid rgba(255, 255, 255, 0.12);
286
+ border-radius: 1.5rem;
287
+ padding: 2rem;
288
+ background: rgba(10, 14, 27, 0.82);
289
+ box-shadow: 0 2rem 6rem rgba(0, 0, 0, 0.38);
290
+ text-align: center;
291
+ backdrop-filter: blur(18px);
292
+ }
293
+ .brand {
294
+ margin: 0 0 1.5rem;
295
+ color: rgba(247, 247, 251, 0.58);
296
+ font-size: 0.78rem;
297
+ font-weight: 700;
298
+ letter-spacing: 0.18em;
299
+ text-transform: uppercase;
300
+ }
301
+ .mark {
302
+ width: 3.5rem;
303
+ height: 3.5rem;
304
+ margin: 0 auto 1.25rem;
305
+ display: grid;
306
+ place-items: center;
307
+ border-radius: 999px;
308
+ background: ${accent};
309
+ color: #05070d;
310
+ font-size: 1.8rem;
311
+ font-weight: 900;
312
+ box-shadow: 0 0 2.5rem ${accent}55;
313
+ }
314
+ h1 {
315
+ margin: 0;
316
+ font-size: clamp(1.75rem, 5vw, 2.45rem);
317
+ line-height: 1.05;
318
+ letter-spacing: -0.04em;
319
+ }
320
+ p {
321
+ margin: 1rem auto 0;
322
+ max-width: 26rem;
323
+ color: rgba(247, 247, 251, 0.66);
324
+ font-size: 1rem;
325
+ line-height: 1.65;
326
+ }
327
+ </style>
328
+ </head>
329
+ <body>
330
+ <main>
331
+ <p class="brand">Switchboard CLI</p>
332
+ <div class="mark" aria-hidden="true">${mark}</div>
333
+ <h1>${escapeHtml(title)}</h1>
334
+ <p>${escapeHtml(message)}</p>
335
+ </main>
336
+ </body>
337
+ </html>`;
338
+ }
339
+
340
+ function escapeHtml(value) {
341
+ return String(value)
342
+ .replaceAll("&", "&amp;")
343
+ .replaceAll("<", "&lt;")
344
+ .replaceAll(">", "&gt;")
345
+ .replaceAll('"', "&quot;");
346
+ }
347
+
348
+ function openBrowser(url) {
349
+ const command = browserOpenCommand();
350
+
351
+ if (!command) {
352
+ return;
353
+ }
354
+
355
+ const child = spawn(command.cmd, [...command.args, url], {
356
+ detached: true,
357
+ stdio: "ignore",
358
+ });
359
+
360
+ child.on("error", () => undefined);
361
+ child.unref();
362
+ }
363
+
364
+ function browserOpenCommand() {
365
+ if (process.platform === "darwin") return { cmd: "open", args: [] };
366
+ if (process.platform === "win32") return { cmd: "cmd", args: ["/c", "start", ""] };
367
+ if (process.platform === "linux") return { cmd: "xdg-open", args: [] };
368
+ return null;
369
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Developer billing commands.
3
+ */
4
+
5
+ import { accountRequest } from "../client.js";
6
+ import { emit, globalFlags, printList } from "../output.js";
7
+
8
+ export function registerBillingCommands(program) {
9
+ const billing = program.command("billing").description("Developer billing");
10
+
11
+ billing
12
+ .command("status")
13
+ .description("Show developer billing status")
14
+ .action(async (_opts, cmd) => {
15
+ const flags = globalFlags(cmd);
16
+ const { data } = await accountRequest("GET", "/billing", { json: flags.json });
17
+ emit(flags.json ? data : JSON.stringify(data, null, 2), flags);
18
+ });
19
+
20
+ billing
21
+ .command("ledger")
22
+ .description("List prepaid wallet ledger entries")
23
+ .action(async (_opts, cmd) => {
24
+ const flags = globalFlags(cmd);
25
+ const { data } = await accountRequest("GET", "/billing/ledger", {
26
+ json: flags.json,
27
+ });
28
+ if (flags.json) {
29
+ emit(data, flags);
30
+ } else {
31
+ printList("Ledger:", data.data || [], (entry) =>
32
+ ` ${entry.id} ${entry.entry_type} ${entry.amount_micros}`,
33
+ );
34
+ }
35
+ });
36
+
37
+ billing
38
+ .command("top-up")
39
+ .description("Add prepaid wallet funds")
40
+ .requiredOption("--amount-micros <micros>", "Amount in micros", parseInt)
41
+ .option("--idempotency-key <key>")
42
+ .action(async (opts, cmd) => {
43
+ const flags = globalFlags(cmd);
44
+ const body = { amount_micros: opts.amountMicros };
45
+ if (opts.idempotencyKey) body.idempotency_key = opts.idempotencyKey;
46
+
47
+ const { data } = await accountRequest("POST", "/billing/top_up", {
48
+ body,
49
+ json: flags.json,
50
+ });
51
+ emit(flags.json ? data : `Created top-up ${data.id}`, flags);
52
+ });
53
+
54
+ billing
55
+ .command("prepaid")
56
+ .description("Update prepaid wallet settings")
57
+ .option("--monthly-auto-refill-micros <micros>", "Monthly auto-refill amount", parseInt)
58
+ .option("--outage-protection <enabled>", "true or false")
59
+ .option("--outage-threshold-micros <micros>", "Outage refill threshold", parseInt)
60
+ .option("--outage-refill-micros <micros>", "Outage refill amount", parseInt)
61
+ .option("--outage-monthly-cap-micros <micros>", "Outage monthly cap", parseInt)
62
+ .action(async (opts, cmd) => {
63
+ const flags = globalFlags(cmd);
64
+ const body = {};
65
+ if (opts.monthlyAutoRefillMicros != null) {
66
+ body.prepaid_monthly_auto_refill_micros = opts.monthlyAutoRefillMicros;
67
+ }
68
+ if (opts.outageProtection != null) {
69
+ body.prepaid_outage_protection_enabled = opts.outageProtection === "true";
70
+ }
71
+ if (opts.outageThresholdMicros != null) {
72
+ body.prepaid_outage_threshold_micros = opts.outageThresholdMicros;
73
+ }
74
+ if (opts.outageRefillMicros != null) {
75
+ body.prepaid_outage_refill_micros = opts.outageRefillMicros;
76
+ }
77
+ if (opts.outageMonthlyCapMicros != null) {
78
+ body.prepaid_outage_monthly_cap_micros = opts.outageMonthlyCapMicros;
79
+ }
80
+
81
+ const { data } = await accountRequest("PATCH", "/billing/prepaid", {
82
+ body,
83
+ json: flags.json,
84
+ });
85
+ emit(flags.json ? data : "Updated prepaid settings", flags);
86
+ });
87
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Production readiness checks for local or hosted Switchboard.
3
+ */
4
+
5
+ import { accountRequest, healthCheck } from "../client.js";
6
+ import { gatewayApiUrl, resolveAccountConfig, resolveConfig } from "../config.js";
7
+ import { emit, globalFlags } from "../output.js";
8
+
9
+ async function check(name, fn) {
10
+ try {
11
+ const result = await fn();
12
+ return { name, ok: true, ...result };
13
+ } catch (error) {
14
+ return { name, ok: false, error: error?.message || String(error) };
15
+ }
16
+ }
17
+
18
+ export function registerDoctorCommand(program) {
19
+ program
20
+ .command("doctor")
21
+ .description("Check health, auth, project, catalog, and gateway readiness")
22
+ .action(async (_opts, cmd) => {
23
+ const flags = globalFlags(cmd);
24
+ const config = resolveConfig();
25
+ const accountConfig = await resolveAccountConfig(config);
26
+
27
+ const checks = [];
28
+
29
+ checks.push(
30
+ await check("health", async () => {
31
+ const result = await healthCheck(config);
32
+ return { status: result.status, data: result.data };
33
+ }),
34
+ );
35
+
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
+ );
45
+
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
+ }
63
+
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
+ const ok = checks.every((item) => item.ok);
71
+ emit({ object: "doctor_report", ok, baseUrl: config.baseUrl, checks }, flags);
72
+ if (!ok) process.exit(1);
73
+ });
74
+ }
@@ -0,0 +1,51 @@
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 Pro-bono Embed 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
+ }