@mushi-mushi/cli 0.19.0 → 0.20.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,6 @@
1
+ // src/version.ts
2
+ var MUSHI_CLI_VERSION = true ? "0.20.0" : "0.0.0-dev";
3
+
4
+ export {
5
+ MUSHI_CLI_VERSION
6
+ };
package/dist/index.js CHANGED
@@ -110,7 +110,7 @@ function getAbortSignal(external) {
110
110
  }
111
111
 
112
112
  // src/version.ts
113
- var MUSHI_CLI_VERSION = true ? "0.19.0" : "0.0.0-dev";
113
+ var MUSHI_CLI_VERSION = true ? "0.20.0" : "0.0.0-dev";
114
114
 
115
115
  // src/init.ts
116
116
  import * as p from "@clack/prompts";
@@ -126,7 +126,10 @@ var HOSTED_CONSOLE_BASE = "https://kensaur.us/mushi-mushi/admin";
126
126
  var LOCAL_ADMIN_URL = "http://localhost:6464";
127
127
  var LOCAL_PROBE_MS = 800;
128
128
  function normalizeConsoleBase(url) {
129
- return url.trim().replace(/\/+$/, "");
129
+ const trimmed = url.trim();
130
+ let end = trimmed.length;
131
+ while (end > 0 && trimmed.charCodeAt(end - 1) === 47) end--;
132
+ return trimmed.slice(0, end);
130
133
  }
131
134
  function consoleUrl(base, route) {
132
135
  const normalizedRoute = route.startsWith("/") ? route : `/${route}`;
@@ -201,10 +204,34 @@ function reportsUrl(base, reportId) {
201
204
  return reportId ? consoleUrl(base, `/reports/${reportId}`) : consoleUrl(base, "/reports");
202
205
  }
203
206
  async function openInBrowser(url) {
204
- const { exec } = await import("child_process");
205
- const openCmd = process.platform === "win32" ? `start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
207
+ let parsed;
208
+ try {
209
+ parsed = new URL(url);
210
+ } catch {
211
+ return;
212
+ }
213
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
214
+ return;
215
+ }
216
+ const safeUrl = parsed.toString();
217
+ const { spawn: spawn2 } = await import("child_process");
218
+ const [command, args] = process.platform === "win32" ? (
219
+ // Open via rundll32's FileProtocolHandler rather than `cmd /c start`.
220
+ // `start` is a cmd builtin, so the URL would be re-parsed by cmd.exe and
221
+ // characters like `&` / `|` / `^` could still break out — exactly the
222
+ // injection surface CodeQL flags. rundll32 never invokes a shell: the URL
223
+ // is handed to the default browser as one opaque argument.
224
+ ["rundll32", ["url.dll,FileProtocolHandler", safeUrl]]
225
+ ) : process.platform === "darwin" ? ["open", [safeUrl]] : ["xdg-open", [safeUrl]];
206
226
  await new Promise((resolve3) => {
207
- exec(openCmd, () => resolve3());
227
+ try {
228
+ const child = spawn2(command, [...args], { stdio: "ignore", shell: false });
229
+ child.on("error", () => resolve3());
230
+ child.on("exit", () => resolve3());
231
+ child.unref();
232
+ } catch {
233
+ resolve3();
234
+ }
208
235
  });
209
236
  }
210
237
 
@@ -329,6 +356,147 @@ function pad(s, width) {
329
356
  return s.length >= width ? s : s + " ".repeat(width - s.length);
330
357
  }
331
358
 
359
+ // src/device-auth.ts
360
+ var DEVICE_FETCH_TIMEOUT_MS = 15e3;
361
+ function trimTrailingSlash(endpoint) {
362
+ let end = endpoint.length;
363
+ while (end > 0 && endpoint.charCodeAt(end - 1) === 47) end--;
364
+ return endpoint.slice(0, end);
365
+ }
366
+ async function deviceFetch(url, init = {}) {
367
+ const controller = new AbortController();
368
+ const timer = setTimeout(() => controller.abort(), DEVICE_FETCH_TIMEOUT_MS);
369
+ try {
370
+ return await fetch(url, { ...init, signal: controller.signal });
371
+ } finally {
372
+ clearTimeout(timer);
373
+ }
374
+ }
375
+ async function startDeviceAuth(endpoint) {
376
+ const base = trimTrailingSlash(endpoint);
377
+ const res = await deviceFetch(`${base}/v1/cli/auth/device/start`, {
378
+ method: "POST",
379
+ headers: { "Content-Type": "application/json" }
380
+ });
381
+ const json = await res.json().catch(() => null);
382
+ if (!res.ok || !json?.ok || !json.data) {
383
+ throw new Error(json?.error?.message ?? `Could not start browser sign-in (HTTP ${res.status}).`);
384
+ }
385
+ return json.data;
386
+ }
387
+ async function pollDeviceToken(endpoint, deviceCode) {
388
+ const base = trimTrailingSlash(endpoint);
389
+ try {
390
+ const res = await deviceFetch(`${base}/v1/cli/auth/device/token`, {
391
+ method: "POST",
392
+ headers: { "Content-Type": "application/json" },
393
+ body: JSON.stringify({ device_code: deviceCode })
394
+ });
395
+ const json = await res.json().catch(() => ({}));
396
+ if (res.ok && json.ok && json.data?.cli_token) {
397
+ return { status: "approved", cliToken: json.data.cli_token, userId: json.data.user_id };
398
+ }
399
+ switch (json.error) {
400
+ case "authorization_pending":
401
+ return { status: "pending" };
402
+ case "access_denied":
403
+ return { status: "denied" };
404
+ case "expired_token":
405
+ return { status: "expired" };
406
+ default:
407
+ return {
408
+ status: "error",
409
+ message: json.error_description ?? json.error ?? `HTTP ${res.status}`,
410
+ // 5xx is a server hiccup the next poll may clear; a 4xx is a definitive
411
+ // rejection (already claimed / invalid_grant) that won't fix itself.
412
+ retryable: res.status >= 500
413
+ };
414
+ }
415
+ } catch (err) {
416
+ return { status: "error", message: err instanceof Error ? err.message : String(err), retryable: true };
417
+ }
418
+ }
419
+ var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
420
+ async function waitForCliToken(endpoint, session, opts = {}) {
421
+ const sleep = opts.sleep ?? defaultSleep;
422
+ const now = opts.now ?? Date.now;
423
+ const intervalMs = (session.interval || 5) * 1e3;
424
+ const deadline = now() + (session.expires_in || 600) * 1e3;
425
+ const maxConsecutiveErrors = opts.maxConsecutiveErrors ?? 5;
426
+ let consecutiveErrors = 0;
427
+ while (now() < deadline) {
428
+ await sleep(intervalMs);
429
+ const outcome = await pollDeviceToken(endpoint, session.device_code);
430
+ switch (outcome.status) {
431
+ case "approved":
432
+ return outcome.cliToken;
433
+ case "pending":
434
+ consecutiveErrors = 0;
435
+ opts.onPending?.();
436
+ continue;
437
+ case "denied":
438
+ throw new Error("Login was denied in the browser.");
439
+ case "expired":
440
+ throw new Error("The login code expired. Run sign-in again.");
441
+ case "error":
442
+ if (!outcome.retryable) {
443
+ throw new Error(outcome.message);
444
+ }
445
+ consecutiveErrors += 1;
446
+ if (consecutiveErrors >= maxConsecutiveErrors) {
447
+ throw new Error(outcome.message);
448
+ }
449
+ opts.onTransientError?.(outcome.message, consecutiveErrors);
450
+ continue;
451
+ }
452
+ }
453
+ throw new Error("Login timed out before approval. Run sign-in again.");
454
+ }
455
+ function cliAuthHeaders(cliToken) {
456
+ return {
457
+ "Content-Type": "application/json",
458
+ Authorization: `Bearer ${cliToken}`
459
+ };
460
+ }
461
+ async function listProjects(endpoint, cliToken) {
462
+ const base = trimTrailingSlash(endpoint);
463
+ try {
464
+ const res = await deviceFetch(`${base}/v1/cli/projects`, { headers: cliAuthHeaders(cliToken) });
465
+ const json = await res.json().catch(() => null);
466
+ if (res.ok && json?.ok) return json.data?.projects ?? [];
467
+ } catch {
468
+ }
469
+ return [];
470
+ }
471
+ async function createProject(endpoint, cliToken, name) {
472
+ const base = trimTrailingSlash(endpoint);
473
+ const res = await deviceFetch(`${base}/v1/cli/projects`, {
474
+ method: "POST",
475
+ headers: cliAuthHeaders(cliToken),
476
+ body: JSON.stringify({ name })
477
+ });
478
+ const json = await res.json().catch(() => null);
479
+ if (!res.ok || !json?.ok || !json.data) {
480
+ throw new Error(json?.error?.message ?? `Could not create project (HTTP ${res.status}).`);
481
+ }
482
+ const { id, name: created, slug, apiKey } = json.data;
483
+ return { id, name: created, slug, apiKey: apiKey ?? null };
484
+ }
485
+ async function mintProjectKey(endpoint, cliToken, projectId) {
486
+ const base = trimTrailingSlash(endpoint);
487
+ try {
488
+ const res = await deviceFetch(`${base}/v1/cli/projects/${projectId}/keys`, {
489
+ method: "POST",
490
+ headers: cliAuthHeaders(cliToken),
491
+ body: JSON.stringify({})
492
+ });
493
+ const json = await res.json().catch(() => null);
494
+ if (res.ok && json?.ok) return json.data?.key ?? null;
495
+ } catch {
496
+ }
497
+ return null;
498
+ }
499
+
332
500
  // src/freshness.ts
333
501
  var REGISTRY = "https://registry.npmjs.org";
334
502
  var DEFAULT_TIMEOUT_MS = 2e3;
@@ -510,8 +678,8 @@ async function runInit(options = {}) {
510
678
  const detected = detectFramework(cwd, pkg);
511
679
  const framework = await chooseFramework(detected, options);
512
680
  const consoleBase = await resolveConsoleUrl({ cwd });
513
- await maybeGuideConsoleSetup(options, consoleBase);
514
- const credentials = await collectCredentials(options, consoleBase);
681
+ const endpoint = resolveCloudEndpoint(options.endpoint);
682
+ const credentials = await acquireCredentials(options, consoleBase, endpoint);
515
683
  await verifyCredentials(credentials, options, consoleBase);
516
684
  const pm = detectPackageManager(cwd);
517
685
  const packagesToInstall = framework.needsWebPackage ? [framework.packageName, "@mushi-mushi/web"] : [framework.packageName];
@@ -520,7 +688,6 @@ async function runInit(options = {}) {
520
688
  } else {
521
689
  p.log.info(`Skipped install. Run \`${installCommand(pm, packagesToInstall)}\` yourself.`);
522
690
  }
523
- const endpoint = resolveCloudEndpoint(options.endpoint);
524
691
  writeEnvFile(cwd, credentials.apiKey, credentials.projectId, framework);
525
692
  persistCliConfig(credentials.apiKey, credentials.projectId, endpoint);
526
693
  const enableRewards = await maybeEnableRewards(options);
@@ -567,63 +734,17 @@ async function chooseFramework(detected, options) {
567
734
  }
568
735
  return FRAMEWORKS[confirmed];
569
736
  }
570
- async function maybeGuideConsoleSetup(options, consoleBase) {
571
- if (options.yes) return;
572
- if (options.projectId && options.apiKey) return;
573
- const existing = loadConfig();
574
- if (existing.projectId && existing.apiKey) return;
575
- const choice = await p.select({
576
- message: "Do you already have a Mushi project?",
577
- options: [
578
- {
579
- value: "create",
580
- label: "No \u2014 open the console to create one",
581
- hint: "Recommended for first-time setup"
582
- },
583
- {
584
- value: "have",
585
- label: "Yes \u2014 I have Project ID + API key",
586
- hint: "Paste values from the admin console"
587
- },
588
- {
589
- value: "login",
590
- label: "Use mushi login first",
591
- hint: "Run `mushi login` in another terminal, then re-run this wizard"
592
- }
593
- ]
594
- });
595
- if (p.isCancel(choice)) {
596
- p.cancel("Aborted.");
597
- process.exit(0);
598
- }
599
- if (choice === "login") {
600
- p.log.info("Run `mushi login` to save credentials, then re-run `mushi init`.");
601
- p.cancel("Aborted.");
602
- process.exit(0);
603
- }
604
- if (choice !== "create") return;
605
- const setupUrl = cliSetupDeepLink(consoleBase);
606
- p.log.info(`Opening the Mushi console:
607
- ${setupUrl}`);
608
- await openInBrowser(setupUrl);
609
- p.log.message("In the console:");
610
- p.log.message(" 1. Enter a project name and click Create");
611
- p.log.message(" 2. Copy the Project ID from the success panel");
612
- p.log.message(" 3. Generate an API key (Verify tab \u2014 report:write scope)");
613
- p.log.message(" 4. Return here and paste both values");
614
- const ready = await p.confirm({
615
- message: "Created your project and ready to paste credentials?",
616
- initialValue: false
617
- });
618
- if (p.isCancel(ready) || !ready) {
619
- p.log.warn(`Finish in the console first: ${setupUrl}`);
737
+ async function acquireCredentials(options, consoleBase, endpoint) {
738
+ if (options.projectId && options.apiKey) {
739
+ return {
740
+ projectId: sanitizeSecret(options.projectId),
741
+ apiKey: sanitizeSecret(options.apiKey)
742
+ };
620
743
  }
621
- }
622
- async function collectCredentials(options, consoleBase) {
623
744
  const existing = loadConfig();
624
745
  if (!options.projectId && !options.apiKey && existing.projectId && existing.apiKey) {
625
746
  const reuse = options.yes ? true : await p.confirm({
626
- message: "Use Project ID + API key from ~/.config/mushi/config.json?",
747
+ message: "Use the Mushi credentials saved from your last sign-in?",
627
748
  initialValue: true
628
749
  });
629
750
  if (p.isCancel(reuse)) {
@@ -637,6 +758,124 @@ async function collectCredentials(options, consoleBase) {
637
758
  };
638
759
  }
639
760
  }
761
+ if (options.yes) {
762
+ const creds = await runBrowserSignIn(options, endpoint);
763
+ if (creds) return creds;
764
+ p.log.warn("Browser sign-in didn't complete \u2014 switching to manual entry.");
765
+ } else {
766
+ const method = await p.select({
767
+ message: "Connect this app to Mushi",
768
+ initialValue: "browser",
769
+ options: [
770
+ {
771
+ value: "browser",
772
+ label: "Sign in with your browser",
773
+ hint: "Recommended \u2014 no copy-paste, creates the project + key for you"
774
+ },
775
+ {
776
+ value: "manual",
777
+ label: "Paste a Project ID + API key",
778
+ hint: "Self-hosted or expert setup"
779
+ }
780
+ ]
781
+ });
782
+ if (p.isCancel(method)) {
783
+ p.cancel("Aborted.");
784
+ process.exit(0);
785
+ }
786
+ if (method === "browser") {
787
+ const creds = await runBrowserSignIn(options, endpoint);
788
+ if (creds) return creds;
789
+ p.log.warn("Browser sign-in didn't complete \u2014 switching to manual entry.");
790
+ }
791
+ }
792
+ return collectCredentialsManually(options, consoleBase);
793
+ }
794
+ async function runBrowserSignIn(options, endpoint) {
795
+ const startSpin = p.spinner();
796
+ startSpin.start("Starting secure browser sign-in\u2026");
797
+ let session;
798
+ try {
799
+ session = await startDeviceAuth(endpoint);
800
+ } catch (err) {
801
+ startSpin.stop("Could not start browser sign-in.");
802
+ p.log.warn(err instanceof Error ? err.message : String(err));
803
+ return null;
804
+ }
805
+ startSpin.stop("Browser sign-in ready.");
806
+ p.log.step(`Confirmation code: ${session.user_code}`);
807
+ p.log.info(`Opening ${session.verification_uri}`);
808
+ try {
809
+ await openInBrowser(session.verification_uri);
810
+ } catch {
811
+ }
812
+ p.log.message("Approve the request in your browser to continue. (Ctrl+C to cancel)");
813
+ const waitSpin = p.spinner();
814
+ waitSpin.start("Waiting for you to approve in the browser\u2026");
815
+ let cliToken;
816
+ try {
817
+ cliToken = await waitForCliToken(endpoint, session);
818
+ } catch (err) {
819
+ waitSpin.stop("Browser sign-in didn't complete.");
820
+ p.log.warn(err instanceof Error ? err.message : String(err));
821
+ return null;
822
+ }
823
+ waitSpin.stop("Approved.");
824
+ let projectId = options.projectId ? sanitizeSecret(options.projectId) : void 0;
825
+ let apiKey;
826
+ if (!projectId) {
827
+ let projects = [];
828
+ try {
829
+ projects = await listProjects(endpoint, cliToken);
830
+ } catch {
831
+ }
832
+ const NEW = "__new__";
833
+ const choice = await p.select({
834
+ message: "Choose a project",
835
+ initialValue: projects[0]?.id ?? NEW,
836
+ options: [
837
+ ...projects.map((pr) => ({ value: pr.id, label: pr.name, hint: pr.id.slice(0, 8) })),
838
+ { value: NEW, label: "Create a new project", hint: "mints an SDK key automatically" }
839
+ ]
840
+ });
841
+ if (p.isCancel(choice)) {
842
+ p.cancel("Aborted.");
843
+ process.exit(0);
844
+ }
845
+ if (choice === NEW) {
846
+ const name = await p.text({
847
+ message: "Project name",
848
+ placeholder: "My app",
849
+ validate: (v) => v && v.trim().length > 0 ? void 0 : "Required"
850
+ });
851
+ if (p.isCancel(name)) {
852
+ p.cancel("Aborted.");
853
+ process.exit(0);
854
+ }
855
+ const createSpin = p.spinner();
856
+ createSpin.start(`Creating "${name.trim()}"\u2026`);
857
+ try {
858
+ const created = await createProject(endpoint, cliToken, name.trim());
859
+ projectId = created.id;
860
+ apiKey = created.apiKey ?? void 0;
861
+ createSpin.stop(`Created project "${created.name}".`);
862
+ } catch (err) {
863
+ createSpin.stop("Could not create the project.");
864
+ p.log.warn(err instanceof Error ? err.message : String(err));
865
+ return null;
866
+ }
867
+ } else {
868
+ projectId = choice;
869
+ }
870
+ }
871
+ if (projectId && !apiKey) {
872
+ apiKey = await mintProjectKey(endpoint, cliToken, projectId) ?? void 0;
873
+ }
874
+ if (!projectId || !apiKey) return null;
875
+ return { apiKey, projectId };
876
+ }
877
+ async function collectCredentialsManually(options, consoleBase) {
878
+ const existing = loadConfig();
640
879
  const rawProjectId = options.projectId ?? existing.projectId ?? await promptText({
641
880
  message: "Project ID",
642
881
  placeholder: "e.g. bdafa28d-b153-482f-bd4f-42981f3fd3a4",
@@ -1151,15 +1390,7 @@ Examples:
1151
1390
  console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
1152
1391
  let deviceData;
1153
1392
  try {
1154
- const res = await fetch(`${endpoint}/v1/cli/auth/device/start`, {
1155
- method: "POST",
1156
- headers: { "Content-Type": "application/json" }
1157
- });
1158
- const json = await res.json();
1159
- if (!res.ok || !json.ok || !json.data) {
1160
- throw new Error(json.error?.message ?? `HTTP ${res.status}`);
1161
- }
1162
- deviceData = json.data;
1393
+ deviceData = await startDeviceAuth(endpoint);
1163
1394
  } catch (err) {
1164
1395
  process.stderr.write(`
1165
1396
  error: Could not start login session: ${err instanceof Error ? err.message : String(err)}
@@ -1185,42 +1416,41 @@ error: Could not start login session: ${err instanceof Error ? err.message : Str
1185
1416
  let cliToken = null;
1186
1417
  const pollIntervalMs = (deviceData.interval ?? 5) * 1e3;
1187
1418
  const deadline = Date.now() + (deviceData.expires_in ?? 600) * 1e3;
1419
+ const MAX_CONSECUTIVE_ERRORS = 5;
1420
+ let consecutiveErrors = 0;
1188
1421
  while (Date.now() < deadline) {
1189
1422
  await new Promise((r) => setTimeout(r, pollIntervalMs));
1190
- try {
1191
- const pollRes = await fetch(`${endpoint}/v1/cli/auth/device/token`, {
1192
- method: "POST",
1193
- headers: { "Content-Type": "application/json" },
1194
- body: JSON.stringify({ device_code: deviceData.device_code })
1195
- });
1196
- const pollJson = await pollRes.json();
1197
- if (pollRes.ok && pollJson.ok && pollJson.data?.cli_token) {
1198
- cliToken = pollJson.data.cli_token;
1199
- break;
1200
- }
1201
- const errCode = pollJson.error ?? "";
1202
- if (errCode === "authorization_pending") {
1203
- process.stdout.write(".");
1204
- continue;
1205
- }
1206
- if (errCode === "access_denied") {
1207
- console.log("");
1208
- process.stderr.write("\nerror: Login denied in the browser.\n");
1209
- process.exit(1);
1210
- }
1211
- if (errCode === "expired_token") {
1212
- console.log("");
1213
- process.stderr.write("\nerror: Login code expired. Run mushi login again.\n");
1214
- process.exit(1);
1423
+ const outcome = await pollDeviceToken(endpoint, deviceData.device_code);
1424
+ if (outcome.status === "approved") {
1425
+ cliToken = outcome.cliToken;
1426
+ break;
1427
+ }
1428
+ if (outcome.status === "pending") {
1429
+ consecutiveErrors = 0;
1430
+ process.stdout.write(".");
1431
+ continue;
1432
+ }
1433
+ if (outcome.status === "error") {
1434
+ if (outcome.retryable) {
1435
+ consecutiveErrors += 1;
1436
+ if (consecutiveErrors < MAX_CONSECUTIVE_ERRORS) {
1437
+ process.stdout.write("\xB7");
1438
+ continue;
1439
+ }
1215
1440
  }
1216
- throw new Error(pollJson.error_description ?? errCode ?? `HTTP ${pollRes.status}`);
1217
- } catch (err) {
1218
1441
  console.log("");
1219
1442
  process.stderr.write(`
1220
- error: Poll failed: ${err instanceof Error ? err.message : String(err)}
1443
+ error: ${outcome.message}
1221
1444
  `);
1222
1445
  process.exit(1);
1223
1446
  }
1447
+ console.log("");
1448
+ if (outcome.status === "denied") {
1449
+ process.stderr.write("\nerror: Login denied in the browser.\n");
1450
+ } else if (outcome.status === "expired") {
1451
+ process.stderr.write("\nerror: Login code expired. Run mushi login again.\n");
1452
+ }
1453
+ process.exit(1);
1224
1454
  }
1225
1455
  if (!cliToken) {
1226
1456
  console.log("");
@@ -1229,17 +1459,7 @@ error: Poll failed: ${err instanceof Error ? err.message : String(err)}
1229
1459
  }
1230
1460
  console.log("");
1231
1461
  console.log(" \u2713 Approved!");
1232
- const cliHeaders = {
1233
- "Content-Type": "application/json",
1234
- "Authorization": `Bearer ${cliToken}`
1235
- };
1236
- let projectsList = [];
1237
- try {
1238
- const listRes = await fetch(`${endpoint}/v1/cli/projects`, { headers: cliHeaders });
1239
- const listJson = await listRes.json();
1240
- if (listRes.ok && listJson.ok) projectsList = listJson.data?.projects ?? [];
1241
- } catch {
1242
- }
1462
+ const projectsList = await listProjects(endpoint, cliToken);
1243
1463
  let chosenProjectId = projectId;
1244
1464
  let chosenProjectName;
1245
1465
  if (!chosenProjectId) {
@@ -1271,18 +1491,10 @@ error: Poll failed: ${err instanceof Error ? err.message : String(err)}
1271
1491
  process.exit(2);
1272
1492
  }
1273
1493
  try {
1274
- const createRes = await fetch(`${endpoint}/v1/cli/projects`, {
1275
- method: "POST",
1276
- headers: cliHeaders,
1277
- body: JSON.stringify({ name: newName.trim() })
1278
- });
1279
- const createJson = await createRes.json();
1280
- if (!createRes.ok || !createJson.ok || !createJson.data) {
1281
- throw new Error(createJson.error?.message ?? `HTTP ${createRes.status}`);
1282
- }
1283
- chosenProjectId = createJson.data.id;
1284
- chosenProjectName = createJson.data.name;
1285
- apiKey = createJson.data.apiKey ?? void 0;
1494
+ const created = await createProject(endpoint, cliToken, newName.trim());
1495
+ chosenProjectId = created.id;
1496
+ chosenProjectName = created.name;
1497
+ apiKey = created.apiKey ?? void 0;
1286
1498
  console.log(` \u2713 Created project "${chosenProjectName}"`);
1287
1499
  } catch (err) {
1288
1500
  process.stderr.write(`
@@ -1296,15 +1508,7 @@ error: Could not create project: ${err instanceof Error ? err.message : String(e
1296
1508
  }
1297
1509
  if (!apiKey && chosenProjectId) {
1298
1510
  try {
1299
- const keyRes = await fetch(`${endpoint}/v1/cli/projects/${chosenProjectId}/keys`, {
1300
- method: "POST",
1301
- headers: cliHeaders,
1302
- body: JSON.stringify({})
1303
- });
1304
- const keyJson = await keyRes.json();
1305
- if (keyRes.ok && keyJson.ok) {
1306
- apiKey = keyJson.data?.key ?? void 0;
1307
- }
1511
+ apiKey = await mintProjectKey(endpoint, cliToken, chosenProjectId) ?? void 0;
1308
1512
  } catch {
1309
1513
  }
1310
1514
  }
@@ -2145,56 +2349,124 @@ Examples:
2145
2349
  // src/commands/project.ts
2146
2350
  function registerProjectCommands(program2) {
2147
2351
  const project = program2.command("project").description("Project management");
2148
- project.command("create").description("Create a new Mushi project, mint an API key, and write config files").option("--name <name>", "Project name (skip the prompt)").option("--no-browser", "Skip opening the browser for the sign-up / magic-link step").option("--endpoint <url>", "Override API endpoint (self-hosted)").addHelpText("after", `
2149
- Creates a project on the Mushi console, mints an API key with mcp:read+write scope,
2150
- and writes the following to the current directory:
2352
+ project.command("create").description("Create or select a Mushi project via browser sign-in, then write config files").option("--name <name>", "Project name for a new project (skips the prompt)").option("--no-browser", "Print the verification URL instead of opening the browser").option("--endpoint <url>", "Override API endpoint (self-hosted)").addHelpText("after", `
2353
+ Signs you in through the browser (no copy-paste), creates or selects a project,
2354
+ mints a report:write SDK key, and writes the following to the current directory:
2151
2355
  .env.local \u2014 MUSHI_API_KEY, MUSHI_PROJECT_ID, MUSHI_API_ENDPOINT
2152
2356
  .cursor/mcp.json \u2014 pre-filled mcpServers.mushi block for Cursor
2153
2357
 
2154
2358
  Typical first-time flow:
2155
2359
  npx mushi-mushi project create
2156
- # Browser opens \u2192 sign up / magic-link \u2192 come back to terminal
2360
+ # Browser opens \u2192 click Approve \u2192 pick or create a project
2157
2361
  # CLI writes .env.local and .cursor/mcp.json
2158
2362
  # mushi whoami to confirm`).action(async (opts) => {
2159
2363
  const { writeFile } = await import("fs/promises");
2160
2364
  const { existsSync: existsSync4 } = await import("fs");
2161
2365
  const nodePath = await import("path");
2162
- const endpoint = opts.endpoint ?? loadConfig().endpoint ?? "https://api.mushimushi.dev";
2163
- const consoleBase = resolveConsoleUrlSync();
2164
- const signUpUrl = consoleUrl(consoleBase, "/sign-up");
2366
+ const savedConfig = loadConfig();
2367
+ const endpoint = resolveCloudEndpoint(
2368
+ opts.endpoint ?? process.env.MUSHI_API_ENDPOINT?.trim() ?? savedConfig.endpoint
2369
+ );
2370
+ const consoleBase = await resolveConsoleUrl();
2165
2371
  console.log("");
2166
2372
  console.log(" Mushi project create");
2167
2373
  console.log(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
2168
2374
  console.log("");
2375
+ let session;
2376
+ try {
2377
+ session = await startDeviceAuth(endpoint);
2378
+ } catch (err) {
2379
+ process.stderr.write(`
2380
+ error: Could not start browser sign-in: ${err instanceof Error ? err.message : String(err)}
2381
+ `);
2382
+ process.stderr.write(" Fallback: mushi login --api-key <key> --project-id <uuid>\n");
2383
+ process.exit(1);
2384
+ }
2385
+ console.log(` Confirmation code: ${session.user_code}`);
2386
+ console.log("");
2169
2387
  if (opts.browser !== false) {
2170
- console.log(" 1. Opening the Mushi sign-up page in your browser...");
2388
+ console.log(" Opening the Mushi console in your browser\u2026");
2171
2389
  try {
2172
- const { exec } = await import("child_process");
2173
- const openCmd = process.platform === "win32" ? `start "" "${signUpUrl}"` : process.platform === "darwin" ? `open "${signUpUrl}"` : `xdg-open "${signUpUrl}"`;
2174
- exec(openCmd);
2390
+ await openInBrowser(session.verification_uri);
2175
2391
  } catch {
2176
2392
  }
2177
- } else {
2178
- console.log(` 1. Sign up or log in at: ${signUpUrl}`);
2179
2393
  }
2394
+ console.log(` If the browser didn't open: ${session.verification_uri}`);
2180
2395
  console.log("");
2181
- console.log(" 2. Create a project in the console, then paste your credentials below.");
2182
- console.log(" (Settings \u2192 API Keys \u2192 New key \u2192 Copy as .env.local)");
2396
+ console.log(" Waiting for you to approve in the browser\u2026 (Ctrl+C to cancel)");
2397
+ let cliToken;
2398
+ try {
2399
+ cliToken = await waitForCliToken(endpoint, session, {
2400
+ onPending: () => process.stdout.write("."),
2401
+ onTransientError: () => process.stdout.write("\xB7")
2402
+ });
2403
+ } catch (err) {
2404
+ console.log("");
2405
+ process.stderr.write(`
2406
+ error: ${err instanceof Error ? err.message : String(err)}
2407
+ `);
2408
+ process.exit(1);
2409
+ }
2183
2410
  console.log("");
2411
+ console.log(" \u2713 Approved!");
2412
+ const projectsList = await listProjects(endpoint, cliToken);
2184
2413
  const { createInterface } = await import("readline");
2185
2414
  const rl = createInterface({ input: process.stdin, output: process.stdout });
2186
2415
  const ask = (q) => new Promise((resolve3) => rl.question(q, (a) => resolve3(a.trim())));
2187
- const projectId = await ask(" Project ID (uuid): ");
2188
- const apiKey = await ask(" API key (mushi_...): ");
2416
+ let chosen;
2417
+ let projectId;
2418
+ let projectName;
2419
+ let apiKey;
2420
+ if (!opts.name && projectsList.length > 0) {
2421
+ console.log("");
2422
+ console.log(" Your projects:");
2423
+ projectsList.forEach((pr, i) => {
2424
+ console.log(` ${i + 1}. ${pr.name} (${pr.id})`);
2425
+ });
2426
+ console.log(` ${projectsList.length + 1}. Create a new project`);
2427
+ console.log("");
2428
+ const choice = await ask(` Pick a project [1-${projectsList.length + 1}]: `);
2429
+ const num = parseInt(choice, 10);
2430
+ if (num >= 1 && num <= projectsList.length) {
2431
+ chosen = projectsList[num - 1];
2432
+ projectId = chosen.id;
2433
+ projectName = chosen.name;
2434
+ }
2435
+ }
2436
+ if (!projectId) {
2437
+ const newName = opts.name?.trim() || await ask(" Project name: ");
2438
+ if (!newName) {
2439
+ rl.close();
2440
+ process.stderr.write("\nerror: Project name is required.\n");
2441
+ process.exit(2);
2442
+ }
2443
+ try {
2444
+ const created = await createProject(endpoint, cliToken, newName);
2445
+ projectId = created.id;
2446
+ projectName = created.name;
2447
+ apiKey = created.apiKey ?? void 0;
2448
+ console.log(` \u2713 Created project "${projectName}"`);
2449
+ } catch (err) {
2450
+ rl.close();
2451
+ process.stderr.write(`
2452
+ error: Could not create project: ${err instanceof Error ? err.message : String(err)}
2453
+ `);
2454
+ process.exit(1);
2455
+ }
2456
+ }
2189
2457
  rl.close();
2458
+ if (projectId && !apiKey) {
2459
+ apiKey = await mintProjectKey(endpoint, cliToken, projectId) ?? void 0;
2460
+ }
2190
2461
  if (!projectId || !apiKey) {
2191
- process.stderr.write("\nerror: Project ID and API key are required.\n");
2192
- process.exit(2);
2462
+ process.stderr.write("\nerror: Could not obtain an SDK key for the project. Run `mushi login` and try again.\n");
2463
+ process.exit(1);
2193
2464
  }
2194
2465
  const config = loadConfig();
2195
2466
  config.apiKey = apiKey;
2196
2467
  config.endpoint = endpoint;
2197
2468
  config.projectId = projectId;
2469
+ config.consoleUrl = consoleBase;
2198
2470
  saveConfig(config);
2199
2471
  const cwd = process.cwd();
2200
2472
  const envPath = nodePath.join(cwd, ".env.local");
@@ -3240,7 +3512,7 @@ Examples:
3240
3512
  }
3241
3513
  if (result.plan.entries.length === 0) process.exit(1);
3242
3514
  });
3243
- program2.command("connect").description("Save credentials, merge env vars, wire Cursor MCP, optionally wait for SDK heartbeat").option("--api-key <key>", "Mushi API key (mushi_\u2026) \u2014 or set MUSHI_API_KEY to keep it out of shell history").requiredOption("--project-id <id>", "Project UUID").requiredOption("--endpoint <url>", "Supabase edge function URL").option("--cwd <path>", "Target repo").option("--write-env", "Write SDK env vars to .env.local (default: enabled)").option("--wire-ide", "Wire Cursor MCP into .cursor/mcp.json (default: enabled)").option("--no-env", "Skip writing .env.local").option("--no-ide", "Skip writing .cursor/mcp.json").option("--wait", "Poll ingest-setup until SDK heartbeat lands").option("--wait-timeout <sec>", "Max seconds for --wait", "120").option("--json", "Machine-readable output").addHelpText("after", `
3515
+ program2.command("connect").description("Save credentials, merge env vars, wire Cursor MCP, optionally wait for SDK heartbeat").option("--api-key <key>", "Mushi API key (mushi_\u2026) \u2014 or set MUSHI_API_KEY to keep it out of shell history").requiredOption("--project-id <id>", "Project UUID").requiredOption("--endpoint <url>", "Supabase edge function URL").option("--cwd <path>", "Target repo").option("--write-env", "Force-write SDK env vars to .env.local (default; overrides --no-env)").option("--wire-ide", "Force-wire Cursor MCP into .cursor/mcp.json (default; overrides --no-ide)").option("--no-env", "Skip writing .env.local").option("--no-ide", "Skip writing .cursor/mcp.json").option("--wait", "Poll ingest-setup until SDK heartbeat lands").option("--wait-timeout <sec>", "Max seconds for --wait", "120").option("--json", "Machine-readable output").addHelpText("after", `
3244
3516
  Examples:
3245
3517
  MUSHI_API_KEY=mushi_xxx mushi connect --project-id <uuid> --endpoint https://<ref>.supabase.co/functions/v1/api --wait
3246
3518
  mushi connect --api-key mushi_xxx --project-id <uuid> --endpoint <url> --no-ide`).action(async (opts) => {
@@ -3254,8 +3526,10 @@ Examples:
3254
3526
  projectId: opts.projectId,
3255
3527
  endpoint: opts.endpoint,
3256
3528
  cwd: opts.cwd,
3257
- writeEnv: opts.env !== false,
3258
- wireIde: opts.ide !== false,
3529
+ // Explicit --write-env / --wire-ide force the action on even if --no-env /
3530
+ // --no-ide was also passed; otherwise writing is the default.
3531
+ writeEnv: opts.writeEnv === true ? true : opts.env !== false,
3532
+ wireIde: opts.wireIde === true ? true : opts.ide !== false,
3259
3533
  wait: opts.wait,
3260
3534
  waitTimeoutSec: parseInt(opts.waitTimeout, 10) || 120,
3261
3535
  json: opts.json
package/dist/init.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  MUSHI_CLI_VERSION
3
- } from "./chunk-T4XX5FMH.js";
3
+ } from "./chunk-6LM2KW4S.js";
4
4
  import {
5
5
  TEST_REPORT_FETCH_TIMEOUT_MS,
6
6
  loadConfig,
@@ -31,7 +31,10 @@ var HOSTED_CONSOLE_BASE = "https://kensaur.us/mushi-mushi/admin";
31
31
  var LOCAL_ADMIN_URL = "http://localhost:6464";
32
32
  var LOCAL_PROBE_MS = 800;
33
33
  function normalizeConsoleBase(url) {
34
- return url.trim().replace(/\/+$/, "");
34
+ const trimmed = url.trim();
35
+ let end = trimmed.length;
36
+ while (end > 0 && trimmed.charCodeAt(end - 1) === 47) end--;
37
+ return trimmed.slice(0, end);
35
38
  }
36
39
  function consoleUrl(base, route) {
37
40
  const normalizedRoute = route.startsWith("/") ? route : `/${route}`;
@@ -106,10 +109,34 @@ function reportsUrl(base, reportId) {
106
109
  return reportId ? consoleUrl(base, `/reports/${reportId}`) : consoleUrl(base, "/reports");
107
110
  }
108
111
  async function openInBrowser(url) {
109
- const { exec } = await import("child_process");
110
- const openCmd = process.platform === "win32" ? `start "" "${url}"` : process.platform === "darwin" ? `open "${url}"` : `xdg-open "${url}"`;
112
+ let parsed;
113
+ try {
114
+ parsed = new URL(url);
115
+ } catch {
116
+ return;
117
+ }
118
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
119
+ return;
120
+ }
121
+ const safeUrl = parsed.toString();
122
+ const { spawn: spawn2 } = await import("child_process");
123
+ const [command, args] = process.platform === "win32" ? (
124
+ // Open via rundll32's FileProtocolHandler rather than `cmd /c start`.
125
+ // `start` is a cmd builtin, so the URL would be re-parsed by cmd.exe and
126
+ // characters like `&` / `|` / `^` could still break out — exactly the
127
+ // injection surface CodeQL flags. rundll32 never invokes a shell: the URL
128
+ // is handed to the default browser as one opaque argument.
129
+ ["rundll32", ["url.dll,FileProtocolHandler", safeUrl]]
130
+ ) : process.platform === "darwin" ? ["open", [safeUrl]] : ["xdg-open", [safeUrl]];
111
131
  await new Promise((resolve2) => {
112
- exec(openCmd, () => resolve2());
132
+ try {
133
+ const child = spawn2(command, [...args], { stdio: "ignore", shell: false });
134
+ child.on("error", () => resolve2());
135
+ child.on("exit", () => resolve2());
136
+ child.unref();
137
+ } catch {
138
+ resolve2();
139
+ }
113
140
  });
114
141
  }
115
142
 
@@ -211,6 +238,147 @@ async function apiCall(path, config, options = {}) {
211
238
  }
212
239
  }
213
240
 
241
+ // src/device-auth.ts
242
+ var DEVICE_FETCH_TIMEOUT_MS = 15e3;
243
+ function trimTrailingSlash(endpoint) {
244
+ let end = endpoint.length;
245
+ while (end > 0 && endpoint.charCodeAt(end - 1) === 47) end--;
246
+ return endpoint.slice(0, end);
247
+ }
248
+ async function deviceFetch(url, init = {}) {
249
+ const controller = new AbortController();
250
+ const timer = setTimeout(() => controller.abort(), DEVICE_FETCH_TIMEOUT_MS);
251
+ try {
252
+ return await fetch(url, { ...init, signal: controller.signal });
253
+ } finally {
254
+ clearTimeout(timer);
255
+ }
256
+ }
257
+ async function startDeviceAuth(endpoint) {
258
+ const base = trimTrailingSlash(endpoint);
259
+ const res = await deviceFetch(`${base}/v1/cli/auth/device/start`, {
260
+ method: "POST",
261
+ headers: { "Content-Type": "application/json" }
262
+ });
263
+ const json = await res.json().catch(() => null);
264
+ if (!res.ok || !json?.ok || !json.data) {
265
+ throw new Error(json?.error?.message ?? `Could not start browser sign-in (HTTP ${res.status}).`);
266
+ }
267
+ return json.data;
268
+ }
269
+ async function pollDeviceToken(endpoint, deviceCode) {
270
+ const base = trimTrailingSlash(endpoint);
271
+ try {
272
+ const res = await deviceFetch(`${base}/v1/cli/auth/device/token`, {
273
+ method: "POST",
274
+ headers: { "Content-Type": "application/json" },
275
+ body: JSON.stringify({ device_code: deviceCode })
276
+ });
277
+ const json = await res.json().catch(() => ({}));
278
+ if (res.ok && json.ok && json.data?.cli_token) {
279
+ return { status: "approved", cliToken: json.data.cli_token, userId: json.data.user_id };
280
+ }
281
+ switch (json.error) {
282
+ case "authorization_pending":
283
+ return { status: "pending" };
284
+ case "access_denied":
285
+ return { status: "denied" };
286
+ case "expired_token":
287
+ return { status: "expired" };
288
+ default:
289
+ return {
290
+ status: "error",
291
+ message: json.error_description ?? json.error ?? `HTTP ${res.status}`,
292
+ // 5xx is a server hiccup the next poll may clear; a 4xx is a definitive
293
+ // rejection (already claimed / invalid_grant) that won't fix itself.
294
+ retryable: res.status >= 500
295
+ };
296
+ }
297
+ } catch (err) {
298
+ return { status: "error", message: err instanceof Error ? err.message : String(err), retryable: true };
299
+ }
300
+ }
301
+ var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
302
+ async function waitForCliToken(endpoint, session, opts = {}) {
303
+ const sleep = opts.sleep ?? defaultSleep;
304
+ const now = opts.now ?? Date.now;
305
+ const intervalMs = (session.interval || 5) * 1e3;
306
+ const deadline = now() + (session.expires_in || 600) * 1e3;
307
+ const maxConsecutiveErrors = opts.maxConsecutiveErrors ?? 5;
308
+ let consecutiveErrors = 0;
309
+ while (now() < deadline) {
310
+ await sleep(intervalMs);
311
+ const outcome = await pollDeviceToken(endpoint, session.device_code);
312
+ switch (outcome.status) {
313
+ case "approved":
314
+ return outcome.cliToken;
315
+ case "pending":
316
+ consecutiveErrors = 0;
317
+ opts.onPending?.();
318
+ continue;
319
+ case "denied":
320
+ throw new Error("Login was denied in the browser.");
321
+ case "expired":
322
+ throw new Error("The login code expired. Run sign-in again.");
323
+ case "error":
324
+ if (!outcome.retryable) {
325
+ throw new Error(outcome.message);
326
+ }
327
+ consecutiveErrors += 1;
328
+ if (consecutiveErrors >= maxConsecutiveErrors) {
329
+ throw new Error(outcome.message);
330
+ }
331
+ opts.onTransientError?.(outcome.message, consecutiveErrors);
332
+ continue;
333
+ }
334
+ }
335
+ throw new Error("Login timed out before approval. Run sign-in again.");
336
+ }
337
+ function cliAuthHeaders(cliToken) {
338
+ return {
339
+ "Content-Type": "application/json",
340
+ Authorization: `Bearer ${cliToken}`
341
+ };
342
+ }
343
+ async function listProjects(endpoint, cliToken) {
344
+ const base = trimTrailingSlash(endpoint);
345
+ try {
346
+ const res = await deviceFetch(`${base}/v1/cli/projects`, { headers: cliAuthHeaders(cliToken) });
347
+ const json = await res.json().catch(() => null);
348
+ if (res.ok && json?.ok) return json.data?.projects ?? [];
349
+ } catch {
350
+ }
351
+ return [];
352
+ }
353
+ async function createProject(endpoint, cliToken, name) {
354
+ const base = trimTrailingSlash(endpoint);
355
+ const res = await deviceFetch(`${base}/v1/cli/projects`, {
356
+ method: "POST",
357
+ headers: cliAuthHeaders(cliToken),
358
+ body: JSON.stringify({ name })
359
+ });
360
+ const json = await res.json().catch(() => null);
361
+ if (!res.ok || !json?.ok || !json.data) {
362
+ throw new Error(json?.error?.message ?? `Could not create project (HTTP ${res.status}).`);
363
+ }
364
+ const { id, name: created, slug, apiKey } = json.data;
365
+ return { id, name: created, slug, apiKey: apiKey ?? null };
366
+ }
367
+ async function mintProjectKey(endpoint, cliToken, projectId) {
368
+ const base = trimTrailingSlash(endpoint);
369
+ try {
370
+ const res = await deviceFetch(`${base}/v1/cli/projects/${projectId}/keys`, {
371
+ method: "POST",
372
+ headers: cliAuthHeaders(cliToken),
373
+ body: JSON.stringify({})
374
+ });
375
+ const json = await res.json().catch(() => null);
376
+ if (res.ok && json?.ok) return json.data?.key ?? null;
377
+ } catch {
378
+ }
379
+ return null;
380
+ }
381
+
214
382
  // src/freshness.ts
215
383
  var REGISTRY = "https://registry.npmjs.org";
216
384
  var DEFAULT_TIMEOUT_MS = 2e3;
@@ -392,8 +560,8 @@ async function runInit(options = {}) {
392
560
  const detected = detectFramework(cwd, pkg);
393
561
  const framework = await chooseFramework(detected, options);
394
562
  const consoleBase = await resolveConsoleUrl({ cwd });
395
- await maybeGuideConsoleSetup(options, consoleBase);
396
- const credentials = await collectCredentials(options, consoleBase);
563
+ const endpoint = resolveCloudEndpoint(options.endpoint);
564
+ const credentials = await acquireCredentials(options, consoleBase, endpoint);
397
565
  await verifyCredentials(credentials, options, consoleBase);
398
566
  const pm = detectPackageManager(cwd);
399
567
  const packagesToInstall = framework.needsWebPackage ? [framework.packageName, "@mushi-mushi/web"] : [framework.packageName];
@@ -402,7 +570,6 @@ async function runInit(options = {}) {
402
570
  } else {
403
571
  p.log.info(`Skipped install. Run \`${installCommand(pm, packagesToInstall)}\` yourself.`);
404
572
  }
405
- const endpoint = resolveCloudEndpoint(options.endpoint);
406
573
  writeEnvFile(cwd, credentials.apiKey, credentials.projectId, framework);
407
574
  persistCliConfig(credentials.apiKey, credentials.projectId, endpoint);
408
575
  const enableRewards = await maybeEnableRewards(options);
@@ -449,63 +616,17 @@ async function chooseFramework(detected, options) {
449
616
  }
450
617
  return FRAMEWORKS[confirmed];
451
618
  }
452
- async function maybeGuideConsoleSetup(options, consoleBase) {
453
- if (options.yes) return;
454
- if (options.projectId && options.apiKey) return;
455
- const existing = loadConfig();
456
- if (existing.projectId && existing.apiKey) return;
457
- const choice = await p.select({
458
- message: "Do you already have a Mushi project?",
459
- options: [
460
- {
461
- value: "create",
462
- label: "No \u2014 open the console to create one",
463
- hint: "Recommended for first-time setup"
464
- },
465
- {
466
- value: "have",
467
- label: "Yes \u2014 I have Project ID + API key",
468
- hint: "Paste values from the admin console"
469
- },
470
- {
471
- value: "login",
472
- label: "Use mushi login first",
473
- hint: "Run `mushi login` in another terminal, then re-run this wizard"
474
- }
475
- ]
476
- });
477
- if (p.isCancel(choice)) {
478
- p.cancel("Aborted.");
479
- process.exit(0);
480
- }
481
- if (choice === "login") {
482
- p.log.info("Run `mushi login` to save credentials, then re-run `mushi init`.");
483
- p.cancel("Aborted.");
484
- process.exit(0);
485
- }
486
- if (choice !== "create") return;
487
- const setupUrl = cliSetupDeepLink(consoleBase);
488
- p.log.info(`Opening the Mushi console:
489
- ${setupUrl}`);
490
- await openInBrowser(setupUrl);
491
- p.log.message("In the console:");
492
- p.log.message(" 1. Enter a project name and click Create");
493
- p.log.message(" 2. Copy the Project ID from the success panel");
494
- p.log.message(" 3. Generate an API key (Verify tab \u2014 report:write scope)");
495
- p.log.message(" 4. Return here and paste both values");
496
- const ready = await p.confirm({
497
- message: "Created your project and ready to paste credentials?",
498
- initialValue: false
499
- });
500
- if (p.isCancel(ready) || !ready) {
501
- p.log.warn(`Finish in the console first: ${setupUrl}`);
619
+ async function acquireCredentials(options, consoleBase, endpoint) {
620
+ if (options.projectId && options.apiKey) {
621
+ return {
622
+ projectId: sanitizeSecret(options.projectId),
623
+ apiKey: sanitizeSecret(options.apiKey)
624
+ };
502
625
  }
503
- }
504
- async function collectCredentials(options, consoleBase) {
505
626
  const existing = loadConfig();
506
627
  if (!options.projectId && !options.apiKey && existing.projectId && existing.apiKey) {
507
628
  const reuse = options.yes ? true : await p.confirm({
508
- message: "Use Project ID + API key from ~/.config/mushi/config.json?",
629
+ message: "Use the Mushi credentials saved from your last sign-in?",
509
630
  initialValue: true
510
631
  });
511
632
  if (p.isCancel(reuse)) {
@@ -519,6 +640,124 @@ async function collectCredentials(options, consoleBase) {
519
640
  };
520
641
  }
521
642
  }
643
+ if (options.yes) {
644
+ const creds = await runBrowserSignIn(options, endpoint);
645
+ if (creds) return creds;
646
+ p.log.warn("Browser sign-in didn't complete \u2014 switching to manual entry.");
647
+ } else {
648
+ const method = await p.select({
649
+ message: "Connect this app to Mushi",
650
+ initialValue: "browser",
651
+ options: [
652
+ {
653
+ value: "browser",
654
+ label: "Sign in with your browser",
655
+ hint: "Recommended \u2014 no copy-paste, creates the project + key for you"
656
+ },
657
+ {
658
+ value: "manual",
659
+ label: "Paste a Project ID + API key",
660
+ hint: "Self-hosted or expert setup"
661
+ }
662
+ ]
663
+ });
664
+ if (p.isCancel(method)) {
665
+ p.cancel("Aborted.");
666
+ process.exit(0);
667
+ }
668
+ if (method === "browser") {
669
+ const creds = await runBrowserSignIn(options, endpoint);
670
+ if (creds) return creds;
671
+ p.log.warn("Browser sign-in didn't complete \u2014 switching to manual entry.");
672
+ }
673
+ }
674
+ return collectCredentialsManually(options, consoleBase);
675
+ }
676
+ async function runBrowserSignIn(options, endpoint) {
677
+ const startSpin = p.spinner();
678
+ startSpin.start("Starting secure browser sign-in\u2026");
679
+ let session;
680
+ try {
681
+ session = await startDeviceAuth(endpoint);
682
+ } catch (err) {
683
+ startSpin.stop("Could not start browser sign-in.");
684
+ p.log.warn(err instanceof Error ? err.message : String(err));
685
+ return null;
686
+ }
687
+ startSpin.stop("Browser sign-in ready.");
688
+ p.log.step(`Confirmation code: ${session.user_code}`);
689
+ p.log.info(`Opening ${session.verification_uri}`);
690
+ try {
691
+ await openInBrowser(session.verification_uri);
692
+ } catch {
693
+ }
694
+ p.log.message("Approve the request in your browser to continue. (Ctrl+C to cancel)");
695
+ const waitSpin = p.spinner();
696
+ waitSpin.start("Waiting for you to approve in the browser\u2026");
697
+ let cliToken;
698
+ try {
699
+ cliToken = await waitForCliToken(endpoint, session);
700
+ } catch (err) {
701
+ waitSpin.stop("Browser sign-in didn't complete.");
702
+ p.log.warn(err instanceof Error ? err.message : String(err));
703
+ return null;
704
+ }
705
+ waitSpin.stop("Approved.");
706
+ let projectId = options.projectId ? sanitizeSecret(options.projectId) : void 0;
707
+ let apiKey;
708
+ if (!projectId) {
709
+ let projects = [];
710
+ try {
711
+ projects = await listProjects(endpoint, cliToken);
712
+ } catch {
713
+ }
714
+ const NEW = "__new__";
715
+ const choice = await p.select({
716
+ message: "Choose a project",
717
+ initialValue: projects[0]?.id ?? NEW,
718
+ options: [
719
+ ...projects.map((pr) => ({ value: pr.id, label: pr.name, hint: pr.id.slice(0, 8) })),
720
+ { value: NEW, label: "Create a new project", hint: "mints an SDK key automatically" }
721
+ ]
722
+ });
723
+ if (p.isCancel(choice)) {
724
+ p.cancel("Aborted.");
725
+ process.exit(0);
726
+ }
727
+ if (choice === NEW) {
728
+ const name = await p.text({
729
+ message: "Project name",
730
+ placeholder: "My app",
731
+ validate: (v) => v && v.trim().length > 0 ? void 0 : "Required"
732
+ });
733
+ if (p.isCancel(name)) {
734
+ p.cancel("Aborted.");
735
+ process.exit(0);
736
+ }
737
+ const createSpin = p.spinner();
738
+ createSpin.start(`Creating "${name.trim()}"\u2026`);
739
+ try {
740
+ const created = await createProject(endpoint, cliToken, name.trim());
741
+ projectId = created.id;
742
+ apiKey = created.apiKey ?? void 0;
743
+ createSpin.stop(`Created project "${created.name}".`);
744
+ } catch (err) {
745
+ createSpin.stop("Could not create the project.");
746
+ p.log.warn(err instanceof Error ? err.message : String(err));
747
+ return null;
748
+ }
749
+ } else {
750
+ projectId = choice;
751
+ }
752
+ }
753
+ if (projectId && !apiKey) {
754
+ apiKey = await mintProjectKey(endpoint, cliToken, projectId) ?? void 0;
755
+ }
756
+ if (!projectId || !apiKey) return null;
757
+ return { apiKey, projectId };
758
+ }
759
+ async function collectCredentialsManually(options, consoleBase) {
760
+ const existing = loadConfig();
522
761
  const rawProjectId = options.projectId ?? existing.projectId ?? await promptText({
523
762
  message: "Project ID",
524
763
  placeholder: "e.g. bdafa28d-b153-482f-bd4f-42981f3fd3a4",
package/dist/version.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  MUSHI_CLI_VERSION
3
- } from "./chunk-T4XX5FMH.js";
3
+ } from "./chunk-6LM2KW4S.js";
4
4
  export {
5
5
  MUSHI_CLI_VERSION
6
6
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mushi-mushi/cli",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "license": "MIT",
5
5
  "description": "CLI for Mushi Mushi — `mushi init` wizard installs the right SDK for your framework, plus report triage and pipeline health commands",
6
6
  "bin": {
@@ -1,6 +0,0 @@
1
- // src/version.ts
2
- var MUSHI_CLI_VERSION = true ? "0.19.0" : "0.0.0-dev";
3
-
4
- export {
5
- MUSHI_CLI_VERSION
6
- };