@switchboard.spot/cli 0.2.0 → 0.2.2

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
@@ -7,13 +7,13 @@ tests.
7
7
  ## Install
8
8
 
9
9
  ```bash
10
- npm install -g @switchboard.spot/cli
10
+ npm install -g @switchboard.spot/cli@latest
11
11
  ```
12
12
 
13
13
  You can also run commands without a global install:
14
14
 
15
15
  ```bash
16
- npx @switchboard.spot/cli auth login
16
+ npx @switchboard.spot/cli@latest auth login
17
17
  ```
18
18
 
19
19
  ## Build for local testing
@@ -54,21 +54,34 @@ If a first publish fails, `npm view @switchboard.spot/cli` will continue to retu
54
54
 
55
55
  ```bash
56
56
  switchboard auth login
57
+ switchboard projects create --name "My App" --slug my-app
57
58
  switchboard setup --target client --json
58
- switchboard chat test --json
59
+ switchboard projects update <project-id> --allowed-origins http://localhost:5173 --virtual-microservice-enabled true
60
+ switchboard projects provision-turnstile <project-id>
61
+ switchboard verify setup
62
+ 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"
63
+ switchboard verify publish
59
64
  ```
60
65
 
61
66
  Use `--json` for automation, CI, and coding agents.
62
67
 
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.
68
+ `setup --target client` writes public Client Gateway environment values such as `VITE_SWITCHBOARD_CLIENT_URL`; it does not prove chat or session readiness. Before SDK browser testing, configure the exact local or preview origin and provision Switchboard-managed Turnstile.
64
69
 
65
- Project-owned browser challenge keys are managed through the CLI:
70
+ CLI account login does not create Client Gateway end-user sessions. End-user sign-up, sign-in, and refresh require the SDK-managed browser challenge; curl, Node scripts, CI, and CLI account login cannot mint browser sessions without running that real browser/mobile flow.
71
+
72
+ For local Switchboard development, `switchboard verify setup --client-url http://localhost:4000/m/<slug>/v1` uses the built-in `dev_browser_challenge` token unless a scenario supplies an explicit `browserChallengeToken`. Hosted Switchboard should use managed Turnstile and the SDK-managed real browser challenge. A local sandbox smoke path is: configure allowed origin plus legal/support fields, create an anonymous session through the SDK or browser verification flow, then call Client Gateway chat.
73
+
74
+ Model discovery is global. Use `GET /v1/models` for OpenAI-compatible discovery or `GET /v1/catalog/models` for catalog/pricing metadata; Client Gateway chat is project-scoped at `/m/<slug>/v1/chat/completions`.
75
+
76
+ Switchboard-managed Turnstile is the default production path. Developers do not paste Cloudflare secrets into the CLI or repo files:
66
77
 
67
78
  ```bash
68
- switchboard projects turnstile <project-id> --site-key <site-key> --secret-key <secret-key>
79
+ switchboard projects provision-turnstile <project-id>
69
80
  switchboard projects turnstile <project-id> --clear
70
81
  ```
71
82
 
83
+ If `switchboard projects provision-turnstile --help` is missing, upgrade with `npm install -g @switchboard.spot/cli@latest` before trying dashboard automation or manual Cloudflare keys.
84
+
72
85
  ## Configuration
73
86
 
74
87
  The CLI stores non-secret settings in `~/.switchboard/config.json` by default.
@@ -82,14 +95,21 @@ Environment variables:
82
95
  | `SWITCHBOARD_PROJECT_ID` | Project context for project-scoped commands. |
83
96
  | `SWITCHBOARD_API_KEY` | Secret project key for trusted-server gateway smoke tests. |
84
97
  | `SWITCHBOARD_CLIENT_URL` | Public Client Gateway URL for browser/mobile end-user auth and chat. |
98
+ | `VITE_SWITCHBOARD_CLIENT_URL` | Vite-safe public Client Gateway URL for browser apps. |
85
99
  | `SWITCHBOARD_END_USER_SESSION` | Existing end-user session for Client Gateway checks; the CLI cannot mint one without browser challenge execution. |
86
100
  | `SWITCHBOARD_CONFIG_DIR` | Alternate CLI config directory. |
87
101
 
88
102
  Account sessions are stored in the OS keychain and are not read from
89
103
  environment variables.
90
104
 
105
+ For isolated agent automation, `switchboard auth login --token-store config-dir`
106
+ stores the account session in `SWITCHBOARD_CONFIG_DIR/account-session.json` with
107
+ 0600 permissions. The default remains keychain-only. `switchboard auth logout`
108
+ clears both keychain and config-dir account sessions.
109
+
91
110
  ## Credential safety
92
111
 
93
112
  Do not paste `sb_sess_`, `sb_test_`, `sb_live_`, provider keys, private keys, or
94
113
  webhook secrets into frontend, mobile, or public code. Browser and mobile code
95
- should use `SWITCHBOARD_CLIENT_URL` plus end-user sessions.
114
+ should use `VITE_SWITCHBOARD_CLIENT_URL` in Vite apps or `SWITCHBOARD_CLIENT_URL`
115
+ in non-Vite tooling, plus end-user sessions.
@@ -18,11 +18,13 @@ import { registerBillingCommands } from "../lib/commands/billing.js";
18
18
  import { registerEnvCommands } from "../lib/commands/env.js";
19
19
  import { registerUsageCommands } from "../lib/commands/usage.js";
20
20
  import { registerIntegrationCommands } from "../lib/commands/integration.js";
21
+ import { registerDocsCommands } from "../lib/commands/docs.js";
21
22
  import { registerInitCommand } from "../lib/commands/init.js";
22
23
  import { registerSetupCommand } from "../lib/commands/setup.js";
23
24
  import { registerHealthCommand } from "../lib/commands/health.js";
24
25
  import { registerDoctorCommand } from "../lib/commands/doctor.js";
25
26
  import { registerVerifyCommands } from "../lib/commands/verify.js";
27
+ import { registerLaunchCommands } from "../lib/commands/launch.js";
26
28
 
27
29
  const program = new Command();
28
30
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -42,6 +44,7 @@ registerSetupCommand(program);
42
44
  registerHealthCommand(program);
43
45
  registerDoctorCommand(program);
44
46
  registerVerifyCommands(program);
47
+ registerLaunchCommands(program);
45
48
  registerAuth(program);
46
49
  registerAccountCommands(program);
47
50
  registerWorkspacesCommands(program);
@@ -53,8 +56,9 @@ registerBillingCommands(program);
53
56
  registerEnvCommands(program);
54
57
  registerUsageCommands(program);
55
58
  registerIntegrationCommands(program);
59
+ registerDocsCommands(program);
56
60
 
57
- program.parse();
61
+ await program.parseAsync();
58
62
 
59
63
  if (!process.argv.slice(2).length) {
60
64
  program.outputHelp();
@@ -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;
@@ -253,9 +317,11 @@ function waitForCallback(callbackServer, timeoutSeconds, json) {
253
317
  });
254
318
  }
255
319
 
256
- function callbackPage(title, message, tone) {
257
- const accent = tone === "success" ? "#28d17c" : "#ff6b6b";
258
- const mark = tone === "success" ? "" : "!";
320
+ export function callbackPage(title, message, tone) {
321
+ const success = tone === "success";
322
+ const accent = success ? "#14b8a6" : "#ef4444";
323
+ const accentSoft = success ? "#ccfbf1" : "#fee2e2";
324
+ const mark = success ? "✓" : "!";
259
325
 
260
326
  return `<!doctype html>
261
327
  <html lang="en">
@@ -265,74 +331,181 @@ function callbackPage(title, message, tone) {
265
331
  <title>${escapeHtml(title)}</title>
266
332
  <style>
267
333
  :root {
268
- color-scheme: dark;
334
+ color-scheme: light dark;
269
335
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
270
- background: #05070d;
271
- color: #f7f7fb;
336
+ background: #ffffff;
337
+ color: #1f2937;
272
338
  }
339
+ * { box-sizing: border-box; }
273
340
  body {
274
341
  min-height: 100vh;
275
342
  margin: 0;
276
- display: grid;
277
- place-items: center;
343
+ color: #1f2937;
278
344
  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%);
345
+ repeating-linear-gradient(125deg, transparent, transparent 6px, #e8e8e8 6px, #e8e8e8 7px),
346
+ #ffffff;
347
+ }
348
+ .page {
349
+ min-height: 100vh;
350
+ width: min(100%, 80rem);
351
+ margin: 0 auto;
352
+ display: flex;
353
+ flex-direction: column;
354
+ border-inline: 1px solid #e5e7eb;
355
+ background:
356
+ repeating-linear-gradient(125deg, transparent, transparent 6px, #e8e8e8 6px, #e8e8e8 7px),
357
+ #ffffff;
358
+ }
359
+ header {
360
+ height: 4.5rem;
361
+ display: flex;
362
+ align-items: center;
363
+ justify-content: space-between;
364
+ padding: 0 1.5rem;
365
+ border-bottom: 1px solid #e5e7eb;
366
+ background: rgba(255, 255, 255, 0.88);
367
+ backdrop-filter: blur(12px);
368
+ }
369
+ .logo {
370
+ color: #1f2937;
371
+ font-size: 0.95rem;
372
+ font-weight: 800;
373
+ letter-spacing: 0;
374
+ }
375
+ .pill {
376
+ border: 1px solid #e5e7eb;
377
+ border-radius: 999px;
378
+ padding: 0.45rem 0.8rem;
379
+ background: #ffffff;
380
+ color: #4b5563;
381
+ font-size: 0.82rem;
382
+ font-weight: 600;
282
383
  }
283
384
  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);
385
+ flex: 1;
386
+ display: grid;
387
+ place-items: center;
388
+ padding: 5rem 1.5rem;
389
+ }
390
+ .panel {
391
+ width: min(100%, 34rem);
392
+ border: 1px solid #e5e7eb;
393
+ border-radius: 0.5rem;
394
+ padding: clamp(1.5rem, 5vw, 2.25rem);
395
+ background: rgba(255, 255, 255, 0.94);
396
+ box-shadow:
397
+ 0 1.34368px 0.537473px -0.625px rgba(0, 0, 0, 0.09),
398
+ 0 15.5969px 6.23877px -3.125px rgba(0, 0, 0, 0.07),
399
+ 0 43.962px 17.5848px -4.375px rgba(0, 0, 0, 0.04);
290
400
  text-align: center;
291
- backdrop-filter: blur(18px);
292
401
  }
293
- .brand {
294
- margin: 0 0 1.5rem;
295
- color: rgba(247, 247, 251, 0.58);
402
+ .eyebrow {
403
+ margin: 0 0 1.25rem;
404
+ color: #4b5563;
296
405
  font-size: 0.78rem;
297
406
  font-weight: 700;
298
- letter-spacing: 0.18em;
407
+ letter-spacing: 0.12em;
299
408
  text-transform: uppercase;
300
409
  }
301
410
  .mark {
302
- width: 3.5rem;
303
- height: 3.5rem;
411
+ width: 4rem;
412
+ height: 4rem;
304
413
  margin: 0 auto 1.25rem;
305
414
  display: grid;
306
415
  place-items: center;
307
- border-radius: 999px;
416
+ border: 1px solid ${accent};
417
+ border-radius: 0.5rem;
308
418
  background: ${accent};
309
- color: #05070d;
310
- font-size: 1.8rem;
419
+ color: #111827;
420
+ font-size: 1.9rem;
311
421
  font-weight: 900;
312
- box-shadow: 0 0 2.5rem ${accent}55;
422
+ box-shadow: 0 18px 40px -20px ${accent};
313
423
  }
314
424
  h1 {
315
425
  margin: 0;
316
- font-size: clamp(1.75rem, 5vw, 2.45rem);
317
- line-height: 1.05;
318
- letter-spacing: -0.04em;
426
+ color: #1f2937;
427
+ font-size: clamp(2rem, 7vw, 3.5rem);
428
+ line-height: 0.98;
429
+ letter-spacing: 0;
319
430
  }
320
431
  p {
321
432
  margin: 1rem auto 0;
322
433
  max-width: 26rem;
323
- color: rgba(247, 247, 251, 0.66);
434
+ color: #4b5563;
324
435
  font-size: 1rem;
325
436
  line-height: 1.65;
326
437
  }
438
+ .status {
439
+ display: inline-flex;
440
+ align-items: center;
441
+ gap: 0.5rem;
442
+ margin-top: 1.5rem;
443
+ border: 1px solid ${accent};
444
+ border-radius: 999px;
445
+ padding: 0.5rem 0.8rem;
446
+ background: ${accentSoft};
447
+ color: #1f2937;
448
+ font-size: 0.86rem;
449
+ font-weight: 700;
450
+ }
451
+ @media (prefers-color-scheme: dark) {
452
+ :root {
453
+ background: #020617;
454
+ color: #f3f4f6;
455
+ }
456
+ body {
457
+ color: #f3f4f6;
458
+ background:
459
+ repeating-linear-gradient(125deg, transparent, transparent 6px, rgba(55, 65, 81, 0.6) 6px, rgba(55, 65, 81, 0.6) 7px),
460
+ #020617;
461
+ }
462
+ .page {
463
+ border-color: #1f2937;
464
+ background:
465
+ repeating-linear-gradient(125deg, transparent, transparent 6px, rgba(55, 65, 81, 0.6) 6px, rgba(55, 65, 81, 0.6) 7px),
466
+ #020617;
467
+ }
468
+ header,
469
+ .panel {
470
+ border-color: #1f2937;
471
+ background: rgba(2, 6, 23, 0.94);
472
+ }
473
+ .logo,
474
+ h1 {
475
+ color: #f3f4f6;
476
+ }
477
+ .pill,
478
+ .eyebrow,
479
+ p {
480
+ color: #d1d5db;
481
+ }
482
+ .pill {
483
+ border-color: #1f2937;
484
+ background: #111827;
485
+ }
486
+ .mark,
487
+ .status {
488
+ color: #020617;
489
+ }
490
+ }
327
491
  </style>
328
492
  </head>
329
493
  <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>
494
+ <div class="page">
495
+ <header>
496
+ <div class="logo">Switchboard</div>
497
+ <div class="pill">CLI session</div>
498
+ </header>
499
+ <main>
500
+ <section class="panel" aria-labelledby="callback-title">
501
+ <p class="eyebrow">Switchboard CLI</p>
502
+ <div class="mark" aria-hidden="true">${mark}</div>
503
+ <h1 id="callback-title">${escapeHtml(title)}</h1>
504
+ <p>${escapeHtml(message)}</p>
505
+ <div class="status">${success ? "Session approved" : "Session not approved"}</div>
506
+ </section>
507
+ </main>
508
+ </div>
336
509
  </body>
337
510
  </html>`;
338
511
  }
@@ -2,6 +2,7 @@
2
2
  * Developer billing commands.
3
3
  */
4
4
 
5
+ import { spawn } from "node:child_process";
5
6
  import { accountRequest } from "../client.js";
6
7
  import { emit, globalFlags, printList } from "../output.js";
7
8
 
@@ -51,6 +52,40 @@ export function registerBillingCommands(program) {
51
52
  emit(flags.json ? data : `Created top-up ${data.id}`, flags);
52
53
  });
53
54
 
55
+ billing
56
+ .command("checkout")
57
+ .description("Create a Stripe-hosted developer billing checkout URL")
58
+ .requiredOption("--success-url <url>")
59
+ .requiredOption("--cancel-url <url>")
60
+ .option("--open", "Open checkout URL in the default browser")
61
+ .action(async (opts, cmd) => {
62
+ const flags = globalFlags(cmd);
63
+ const { data } = await accountRequest("POST", "/billing/checkout", {
64
+ body: {
65
+ success_url: opts.successUrl,
66
+ cancel_url: opts.cancelUrl,
67
+ },
68
+ json: flags.json,
69
+ });
70
+ if (opts.open) openUrl(data.checkout_url);
71
+ emit(flags.json ? data : data.checkout_url, flags);
72
+ });
73
+
74
+ billing
75
+ .command("portal")
76
+ .description("Create a Stripe-hosted developer billing portal URL")
77
+ .requiredOption("--return-url <url>")
78
+ .option("--open", "Open portal URL in the default browser")
79
+ .action(async (opts, cmd) => {
80
+ const flags = globalFlags(cmd);
81
+ const { data } = await accountRequest("POST", "/billing/portal", {
82
+ body: { return_url: opts.returnUrl },
83
+ json: flags.json,
84
+ });
85
+ if (opts.open) openUrl(data.url);
86
+ emit(flags.json ? data : data.url, flags);
87
+ });
88
+
54
89
  billing
55
90
  .command("prepaid")
56
91
  .description("Update prepaid wallet settings")
@@ -85,3 +120,11 @@ export function registerBillingCommands(program) {
85
120
  emit(flags.json ? data : "Updated prepaid settings", flags);
86
121
  });
87
122
  }
123
+
124
+ function openUrl(url) {
125
+ const command =
126
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
127
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
128
+ const child = spawn(command, args, { stdio: "ignore", detached: true });
129
+ child.unref();
130
+ }