@mushi-mushi/cli 0.23.0 → 0.24.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.
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  detectFramework,
13
13
  detectPackageManager,
14
14
  die,
15
+ ensureClientId,
15
16
  envVarsToWrite,
16
17
  fetchIngestSetup,
17
18
  fmtDate,
@@ -36,7 +37,7 @@ import {
36
37
  sanitizeProjectId,
37
38
  saveConfig,
38
39
  writeMcpServerEntry
39
- } from "./chunk-GSQO35YD.js";
40
+ } from "./chunk-BWASDQI6.js";
40
41
  import {
41
42
  TEST_REPORT_FETCH_TIMEOUT_MS,
42
43
  assertEndpoint,
@@ -56,6 +57,7 @@ import { join as join2 } from "path";
56
57
 
57
58
  // src/device-auth.ts
58
59
  var DEVICE_FETCH_TIMEOUT_MS = 15e3;
60
+ var DEFAULT_SLOW_DOWN_MS = 5e3;
59
61
  function trimTrailingSlash(endpoint) {
60
62
  let end = endpoint.length;
61
63
  while (end > 0 && endpoint.charCodeAt(end - 1) === 47) end--;
@@ -70,15 +72,18 @@ async function deviceFetch(url, init = {}) {
70
72
  clearTimeout(timer);
71
73
  }
72
74
  }
73
- async function startDeviceAuth(endpoint) {
75
+ async function startDeviceAuth(endpoint, clientId) {
74
76
  const base = trimTrailingSlash(endpoint);
75
77
  const res = await deviceFetch(`${base}/v1/cli/auth/device/start`, {
76
78
  method: "POST",
77
- headers: { "Content-Type": "application/json" }
79
+ headers: { "Content-Type": "application/json" },
80
+ body: JSON.stringify(clientId ? { client_id: clientId } : {})
78
81
  });
79
82
  const json = await res.json().catch(() => null);
80
83
  if (!res.ok || !json?.ok || !json.data) {
81
- throw new Error(json?.error?.message ?? `Could not start browser sign-in (HTTP ${res.status}).`);
84
+ throw new Error(
85
+ json?.error?.message ?? `Could not start browser sign-in (HTTP ${res.status}).`
86
+ );
82
87
  }
83
88
  return json.data;
84
89
  }
@@ -97,6 +102,12 @@ async function pollDeviceToken(endpoint, deviceCode) {
97
102
  switch (json.error) {
98
103
  case "authorization_pending":
99
104
  return { status: "pending" };
105
+ case "slow_down": {
106
+ const retryAfterHeader = res.headers?.get?.("Retry-After");
107
+ const retryAfterSeconds = retryAfterHeader ? Number(retryAfterHeader) : NaN;
108
+ const retryAfterMs = Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0 ? retryAfterSeconds * 1e3 : DEFAULT_SLOW_DOWN_MS;
109
+ return { status: "slow_down", retryAfterMs };
110
+ }
100
111
  case "access_denied":
101
112
  return { status: "denied" };
102
113
  case "expired_token":
@@ -105,20 +116,27 @@ async function pollDeviceToken(endpoint, deviceCode) {
105
116
  return {
106
117
  status: "error",
107
118
  message: json.error_description ?? json.error ?? `HTTP ${res.status}`,
108
- // 5xx is a server hiccup the next poll may clear; a 4xx is a definitive
109
- // rejection (already claimed / invalid_grant) that won't fix itself.
110
- retryable: res.status >= 500
119
+ // 5xx is a server hiccup the next poll may clear; 408 (gateway
120
+ // timeout) is equally transient. Any other 4xx is a definitive
121
+ // rejection (already claimed / invalid_grant) that won't fix
122
+ // itself. (429/slow_down is handled above as its own outcome.)
123
+ retryable: res.status >= 500 || res.status === 408
111
124
  };
112
125
  }
113
126
  } catch (err) {
114
- return { status: "error", message: err instanceof Error ? err.message : String(err), retryable: true };
127
+ return {
128
+ status: "error",
129
+ message: err instanceof Error ? err.message : String(err),
130
+ retryable: true
131
+ };
115
132
  }
116
133
  }
117
134
  var defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
135
+ var deviceAuthRetryDelayMs = 1e3;
118
136
  async function waitForCliToken(endpoint, session, opts = {}) {
119
137
  const sleep = opts.sleep ?? defaultSleep;
120
138
  const now = opts.now ?? Date.now;
121
- const intervalMs = (session.interval || 5) * 1e3;
139
+ let intervalMs = (session.interval || 5) * 1e3;
122
140
  const deadline = now() + (session.expires_in || 600) * 1e3;
123
141
  const maxConsecutiveErrors = opts.maxConsecutiveErrors ?? 5;
124
142
  let consecutiveErrors = 0;
@@ -136,6 +154,11 @@ async function waitForCliToken(endpoint, session, opts = {}) {
136
154
  consecutiveErrors = 0;
137
155
  opts.onPending?.();
138
156
  continue;
157
+ case "slow_down":
158
+ consecutiveErrors = 0;
159
+ intervalMs = Math.max(intervalMs, outcome.retryAfterMs);
160
+ opts.onSlowDown?.(outcome.retryAfterMs);
161
+ continue;
139
162
  case "denied":
140
163
  throw new Error("Login was denied in the browser.");
141
164
  case "expired":
@@ -160,19 +183,51 @@ function cliAuthHeaders(cliToken) {
160
183
  Authorization: `Bearer ${cliToken}`
161
184
  };
162
185
  }
186
+ var DeviceAuthRequestError = class extends Error {
187
+ step;
188
+ status;
189
+ retryable;
190
+ constructor(step, message, status) {
191
+ super(message);
192
+ this.name = "DeviceAuthRequestError";
193
+ this.step = step;
194
+ this.status = status;
195
+ this.retryable = status === void 0 || status >= 500 || status === 408;
196
+ }
197
+ };
198
+ async function withOneRetry(run) {
199
+ try {
200
+ return await run();
201
+ } catch (err) {
202
+ if (err instanceof DeviceAuthRequestError && err.retryable) {
203
+ if (deviceAuthRetryDelayMs > 0) {
204
+ await defaultSleep(deviceAuthRetryDelayMs);
205
+ }
206
+ return run();
207
+ }
208
+ throw err;
209
+ }
210
+ }
163
211
  async function listProjects(endpoint, cliToken) {
164
212
  const base = trimTrailingSlash(endpoint);
165
- try {
166
- const res = await deviceFetch(`${base}/v1/cli/projects`, { headers: cliAuthHeaders(cliToken) });
213
+ return withOneRetry(async () => {
214
+ let res;
215
+ try {
216
+ res = await deviceFetch(`${base}/v1/cli/projects`, { headers: cliAuthHeaders(cliToken) });
217
+ } catch (err) {
218
+ throw new DeviceAuthRequestError(
219
+ "list_projects",
220
+ `Could not reach ${base}/v1/cli/projects: ${err instanceof Error ? err.message : String(err)}`
221
+ );
222
+ }
167
223
  const json = await res.json().catch(() => null);
168
224
  if (res.ok && json?.ok) return json.data?.projects ?? [];
169
- } catch (err) {
170
- console.error(
171
- "[mushi] Failed to list projects:",
172
- err instanceof Error ? err.message : err
225
+ throw new DeviceAuthRequestError(
226
+ "list_projects",
227
+ json?.error?.message ?? `Could not list projects (HTTP ${res.status}).`,
228
+ res.status
173
229
  );
174
- }
175
- return [];
230
+ });
176
231
  }
177
232
  async function createProject(endpoint, cliToken, name) {
178
233
  const base = trimTrailingSlash(endpoint);
@@ -190,17 +245,28 @@ async function createProject(endpoint, cliToken, name) {
190
245
  }
191
246
  async function mintProjectKey(endpoint, cliToken, projectId) {
192
247
  const base = trimTrailingSlash(endpoint);
193
- try {
194
- const res = await deviceFetch(`${base}/v1/cli/projects/${projectId}/keys`, {
195
- method: "POST",
196
- headers: cliAuthHeaders(cliToken),
197
- body: JSON.stringify({})
198
- });
248
+ return withOneRetry(async () => {
249
+ let res;
250
+ try {
251
+ res = await deviceFetch(`${base}/v1/cli/projects/${projectId}/keys`, {
252
+ method: "POST",
253
+ headers: cliAuthHeaders(cliToken),
254
+ body: JSON.stringify({})
255
+ });
256
+ } catch (err) {
257
+ throw new DeviceAuthRequestError(
258
+ "mint_key",
259
+ `Could not reach ${base}/v1/cli/projects/\u2026/keys: ${err instanceof Error ? err.message : String(err)}`
260
+ );
261
+ }
199
262
  const json = await res.json().catch(() => null);
200
- if (res.ok && json?.ok) return json.data?.key ?? null;
201
- } catch {
202
- }
203
- return null;
263
+ if (res.ok && json?.ok && json.data?.key) return json.data.key;
264
+ throw new DeviceAuthRequestError(
265
+ "mint_key",
266
+ json?.error?.message ?? `Could not mint an SDK key (HTTP ${res.status}).`,
267
+ res.ok ? void 0 : res.status
268
+ );
269
+ });
204
270
  }
205
271
 
206
272
  // src/freshness.ts
@@ -431,7 +497,10 @@ async function runInit(options = {}) {
431
497
  const detected = detectFramework(cwd, pkg);
432
498
  const framework = await chooseFramework(detected, options);
433
499
  const consoleBase = await resolveConsoleUrl({ cwd });
434
- const endpoint = resolveCloudEndpoint(options.endpoint);
500
+ const endpoint = resolveCloudEndpoint(
501
+ options.endpoint ?? process.env.MUSHI_API_ENDPOINT?.trim() ?? loadConfig().endpoint
502
+ );
503
+ options = { ...options, endpoint };
435
504
  const credentials = await acquireCredentials(options, consoleBase, endpoint);
436
505
  await verifyCredentials(credentials, options, consoleBase);
437
506
  const pm = detectPackageManager(cwd);
@@ -441,8 +510,16 @@ async function runInit(options = {}) {
441
510
  } else {
442
511
  p.log.info(`Skipped install. Run \`${installCommand(pm, packagesToInstall)}\` yourself.`);
443
512
  }
444
- await writeEnvFile(cwd, credentials.apiKey, credentials.projectId, framework, endpoint, Boolean(options.yes));
513
+ await writeEnvFile(
514
+ cwd,
515
+ credentials.apiKey,
516
+ credentials.projectId,
517
+ framework,
518
+ endpoint,
519
+ Boolean(options.yes)
520
+ );
445
521
  persistCliConfig(credentials.apiKey, credentials.projectId, endpoint);
522
+ emitWizardFunnelEvent(credentials, endpoint, "wizard_env_written", { framework: framework.id });
446
523
  const enableRewards = await maybeEnableRewards(options);
447
524
  printNextSteps(framework, consoleBase, enableRewards);
448
525
  await maybeSendTestReport(credentials, { ...options, endpoint, consoleBase });
@@ -514,7 +591,9 @@ async function acquireCredentials(options, consoleBase, endpoint) {
514
591
  if (options.yes) {
515
592
  const creds = await runBrowserSignIn(options, endpoint, consoleBase);
516
593
  if (creds) return creds;
517
- p.log.warn("Browser sign-in didn't complete \u2014 switching to manual entry.");
594
+ p.log.warn(
595
+ "Browser sign-in didn't complete \u2014 switching to manual entry. Run `npx mushi-mushi doctor --auth` to diagnose the sign-in path."
596
+ );
518
597
  } else {
519
598
  const method = await p.select({
520
599
  message: "Connect this app to Mushi",
@@ -539,17 +618,19 @@ async function acquireCredentials(options, consoleBase, endpoint) {
539
618
  if (method === "browser") {
540
619
  const creds = await runBrowserSignIn(options, endpoint, consoleBase);
541
620
  if (creds) return creds;
542
- p.log.warn("Browser sign-in didn't complete \u2014 switching to manual entry.");
621
+ p.log.warn(
622
+ "Browser sign-in didn't complete \u2014 switching to manual entry. Run `npx mushi-mushi doctor --auth` to diagnose the sign-in path."
623
+ );
543
624
  }
544
625
  }
545
- return collectCredentialsManually(options, consoleBase);
626
+ return collectCredentialsManually(options, consoleBase, endpoint);
546
627
  }
547
628
  async function runBrowserSignIn(options, endpoint, consoleBase) {
548
629
  const startSpin = p.spinner();
549
630
  startSpin.start("Starting secure browser sign-in\u2026");
550
631
  let session;
551
632
  try {
552
- session = await startDeviceAuth(endpoint);
633
+ session = await startDeviceAuth(endpoint, ensureClientId());
553
634
  } catch (err) {
554
635
  startSpin.stop("Could not start browser sign-in.");
555
636
  p.log.warn(err instanceof Error ? err.message : String(err));
@@ -580,7 +661,9 @@ async function runBrowserSignIn(options, endpoint, consoleBase) {
580
661
  fetchSpin.start("Loading your projects\u2026");
581
662
  try {
582
663
  projects = await listProjects(endpoint, cliToken);
583
- fetchSpin.stop(projects.length > 0 ? `Found ${projects.length} project(s).` : "No projects yet.");
664
+ fetchSpin.stop(
665
+ projects.length > 0 ? `Found ${projects.length} project(s).` : "No projects yet."
666
+ );
584
667
  } catch (err) {
585
668
  fetchSpin.stop("Could not load projects \u2014 you can still create a new one.");
586
669
  p.log.warn(err instanceof Error ? err.message : String(err));
@@ -628,34 +711,50 @@ async function runBrowserSignIn(options, endpoint, consoleBase) {
628
711
  const keySpin = p.spinner();
629
712
  keySpin.start("Minting SDK key\u2026");
630
713
  try {
631
- const minted = await mintProjectKey(endpoint, cliToken, projectId);
632
- apiKey = minted ?? void 0;
633
- if (!apiKey) {
634
- keySpin.stop("Could not mint an API key.");
635
- p.log.warn(
636
- `Open the console Verify tab to generate a key manually: ${consoleUrl(consoleBase, "/onboarding?tab=verify")}`
637
- );
638
- return null;
639
- }
714
+ apiKey = await mintProjectKey(endpoint, cliToken, projectId);
640
715
  keySpin.stop("SDK key ready.");
641
716
  } catch (err) {
642
717
  keySpin.stop("Could not mint an API key.");
643
718
  p.log.warn(err instanceof Error ? err.message : String(err));
719
+ p.log.warn(
720
+ `You're signed in, but key minting failed. Generate one manually in the console Verify tab: ${consoleUrl(consoleBase, "/onboarding?tab=verify")}`
721
+ );
644
722
  return null;
645
723
  }
646
724
  }
647
725
  if (!projectId || !apiKey) return null;
648
726
  return { apiKey, projectId };
649
727
  }
650
- async function collectCredentialsManually(options, consoleBase) {
728
+ async function collectCredentialsManually(options, consoleBase, endpoint) {
651
729
  const existing = loadConfig();
652
- const rawProjectId = options.projectId ?? existing.projectId ?? await promptText({
730
+ let savedProjectId = existing.projectId;
731
+ let savedApiKey = existing.apiKey;
732
+ if (!options.projectId && !options.apiKey && savedProjectId && savedApiKey) {
733
+ p.log.info(
734
+ `Found saved credentials from a previous sign-in (project ${sanitizeSecret(savedProjectId).slice(0, 8)}\u2026).`
735
+ );
736
+ const checkSpin = p.spinner();
737
+ checkSpin.start("Checking saved credentials\u2026");
738
+ const check = await apiCall("/v1/sync/whoami", {
739
+ apiKey: sanitizeSecret(savedApiKey),
740
+ projectId: sanitizeSecret(savedProjectId),
741
+ endpoint
742
+ });
743
+ if (check.ok) {
744
+ checkSpin.stop(`Saved credentials still work (${check.data.project_name}).`);
745
+ } else {
746
+ checkSpin.stop("Saved credentials no longer authenticate \u2014 enter fresh ones below.");
747
+ savedProjectId = void 0;
748
+ savedApiKey = void 0;
749
+ }
750
+ }
751
+ const rawProjectId = options.projectId ?? savedProjectId ?? await promptText({
653
752
  message: "Project ID",
654
753
  placeholder: "e.g. bdafa28d-b153-482f-bd4f-42981f3fd3a4",
655
754
  hint: projectIdHint(consoleBase),
656
755
  validate: (v) => PROJECT_ID_PATTERN.test(v.trim()) ? void 0 : "Expected a UUID \u2014 copy it from the Projects page or the panel right after you create a project."
657
756
  });
658
- const rawApiKey = options.apiKey ?? existing.apiKey ?? await promptText({
757
+ const rawApiKey = options.apiKey ?? savedApiKey ?? await promptText({
659
758
  message: "API key",
660
759
  placeholder: "mushi_xxxxxxxxxxxx",
661
760
  hint: apiKeyHint(consoleBase),
@@ -679,17 +778,30 @@ async function verifyCredentials(credentials, options, consoleBase) {
679
778
  const endpoint = resolveCloudEndpoint(options.endpoint);
680
779
  const spinner3 = p.spinner();
681
780
  spinner3.start("Verifying credentials\u2026");
682
- const result = await apiCall(
683
- "/v1/sync/whoami",
684
- { apiKey: credentials.apiKey, projectId: credentials.projectId, endpoint }
685
- );
781
+ const result = await apiCall("/v1/sync/whoami", {
782
+ apiKey: credentials.apiKey,
783
+ projectId: credentials.projectId,
784
+ endpoint
785
+ });
686
786
  if (!result.ok) {
687
787
  spinner3.stop("Credentials could not be verified.");
688
788
  p.log.error(result.error?.message ?? "Authentication failed.");
689
- p.log.info(`Double-check values in the console:
690
- ${cliSetupDeepLink(consoleBase)}`);
789
+ p.log.warn("Setup did NOT complete: nothing was installed and no env vars were written.");
790
+ p.log.info(
791
+ [
792
+ "To recover:",
793
+ ' \u2022 Re-run `npx mushi-mushi` and choose "Sign in with your browser", or',
794
+ ` \u2022 Double-check the Project ID / API key in the console: ${cliSetupDeepLink(consoleBase)}`
795
+ ].join("\n")
796
+ );
691
797
  throw new Error("Credential verification failed \u2014 fix Project ID / API key and re-run.");
692
798
  }
799
+ if (result.data.project_id && result.data.project_id !== credentials.projectId) {
800
+ p.log.warn(
801
+ `Project ID ${redact(credentials.projectId)} doesn't match this API key's project \u2014 using the key's actual project (${result.data.project_name}) instead.`
802
+ );
803
+ credentials.projectId = result.data.project_id;
804
+ }
693
805
  spinner3.stop(`Connected to ${result.data.project_name}`);
694
806
  }
695
807
  function sanitizeSecret(raw) {
@@ -825,6 +937,20 @@ function persistCliConfig(apiKey, projectId, endpoint) {
825
937
  const existing = loadConfig();
826
938
  saveConfig({ ...existing, apiKey, projectId, endpoint });
827
939
  }
940
+ function emitWizardFunnelEvent(credentials, endpoint, event, metadata = {}) {
941
+ if (process.env.MUSHI_NO_TELEMETRY) return;
942
+ void fetch(`${endpoint.replace(/\/$/, "")}/v1/cli/funnel`, {
943
+ method: "POST",
944
+ headers: {
945
+ "Content-Type": "application/json",
946
+ "X-Mushi-Api-Key": credentials.apiKey,
947
+ "X-Mushi-Project": credentials.projectId
948
+ },
949
+ body: JSON.stringify({ event, source: "cli", metadata }),
950
+ signal: AbortSignal.timeout(4e3)
951
+ }).catch(() => {
952
+ });
953
+ }
828
954
  function printNextSteps(framework, consoleBase, enableRewards = false) {
829
955
  p.note(framework.snippet(), "Add this to your app:");
830
956
  if (enableRewards) {
@@ -858,7 +984,7 @@ async function maybeOfferConnect(credentials, options, _consoleBase) {
858
984
  if (p.isCancel(answer) || !answer) return;
859
985
  const endpoint = resolveCloudEndpoint(options.endpoint);
860
986
  try {
861
- const { runConnect: runConnect2 } = await import("./connect-XFVSH4JZ.js");
987
+ const { runConnect: runConnect2 } = await import("./connect-KKN62YK2.js");
862
988
  await runConnect2({
863
989
  apiKey: credentials.apiKey,
864
990
  projectId: credentials.projectId,
@@ -869,12 +995,8 @@ async function maybeOfferConnect(credentials, options, _consoleBase) {
869
995
  wait: true
870
996
  });
871
997
  } catch (err) {
872
- p.log.warn(
873
- err instanceof Error ? err.message : String(err)
874
- );
875
- p.log.info(
876
- "You can run manually: mushi connect --write-env --wire-ide --wait"
877
- );
998
+ p.log.warn(err instanceof Error ? err.message : String(err));
999
+ p.log.info("You can run manually: mushi connect --write-env --wire-ide --wait");
878
1000
  }
879
1001
  }
880
1002
  async function maybeEnableRewards(options) {
@@ -1201,7 +1323,7 @@ Examples:
1201
1323
  }
1202
1324
  let deviceData;
1203
1325
  try {
1204
- deviceData = await startDeviceAuth(endpoint);
1326
+ deviceData = await startDeviceAuth(endpoint, ensureClientId());
1205
1327
  } catch (err) {
1206
1328
  process.stderr.write(`
1207
1329
  error: Could not start login session: ${err instanceof Error ? err.message : String(err)}
@@ -2208,7 +2330,7 @@ Typical first-time flow:
2208
2330
  console.log("");
2209
2331
  let session;
2210
2332
  try {
2211
- session = await startDeviceAuth(endpoint);
2333
+ session = await startDeviceAuth(endpoint, ensureClientId());
2212
2334
  } catch (err) {
2213
2335
  process.stderr.write(`
2214
2336
  error: Could not start browser sign-in: ${err instanceof Error ? err.message : String(err)}
@@ -3141,6 +3263,88 @@ async function checkEndpointReachability(endpoint, doFetch = globalThis.fetch) {
3141
3263
  return { name: "Endpoint reachable", ok: false, detail: `Fetch failed: ${msg}` };
3142
3264
  }
3143
3265
  }
3266
+ async function checkCliAuthPath(config, doFetch = globalThis.fetch) {
3267
+ const checks = [];
3268
+ if (!config.endpoint) {
3269
+ return [
3270
+ {
3271
+ name: "Sign-in route reachable",
3272
+ ok: false,
3273
+ detail: "No endpoint configured \u2014 run `npx mushi-mushi` or pass MUSHI_API_ENDPOINT."
3274
+ }
3275
+ ];
3276
+ }
3277
+ const base = sanitizeEndpoint(config.endpoint);
3278
+ let dateHeader = null;
3279
+ try {
3280
+ const res = await doFetch(`${base}/v1/cli/auth/device/token`, {
3281
+ method: "POST",
3282
+ headers: { "Content-Type": "application/json" },
3283
+ body: JSON.stringify({ device_code: "doctor-probe" }),
3284
+ signal: AbortSignal.timeout(8e3)
3285
+ });
3286
+ dateHeader = res.headers?.get?.("date") ?? null;
3287
+ const signInRouteReachable = res.status === 400 || res.status === 429;
3288
+ checks.push({
3289
+ name: "Sign-in route reachable",
3290
+ ok: signInRouteReachable,
3291
+ detail: signInRouteReachable ? `POST ${base}/v1/cli/auth/device/token \u2192 ${res.status} (route deployed and answering)` : `POST ${base}/v1/cli/auth/device/token \u2192 ${res.status} \u2014 expected HTTP 400 (invalid device_code) or 429 (slow_down); the API may be down or mis-deployed.`
3292
+ });
3293
+ } catch (err) {
3294
+ checks.push({
3295
+ name: "Sign-in route reachable",
3296
+ ok: false,
3297
+ detail: `Fetch failed: ${err instanceof Error ? err.message : String(err)} \u2014 check network/proxy/firewall to ${base}.`
3298
+ });
3299
+ }
3300
+ if (dateHeader) {
3301
+ const serverMs = Date.parse(dateHeader);
3302
+ if (!Number.isNaN(serverMs)) {
3303
+ const skewSec = Math.round(Math.abs(Date.now() - serverMs) / 1e3);
3304
+ const ok = skewSec <= 120;
3305
+ checks.push({
3306
+ name: "System clock in sync",
3307
+ ok,
3308
+ warn: !ok && skewSec <= 300,
3309
+ detail: ok ? `Local clock within ${skewSec}s of the server.` : `Local clock is ${skewSec}s off the server \u2014 sign-in codes expire fast; fix your system time.`
3310
+ });
3311
+ }
3312
+ }
3313
+ if (config.apiKey && config.projectId) {
3314
+ try {
3315
+ const res = await doFetch(`${base}/v1/sync/whoami`, {
3316
+ method: "POST",
3317
+ headers: {
3318
+ "Content-Type": "application/json",
3319
+ ...apiKeyHeaders(config.apiKey, config.projectId)
3320
+ },
3321
+ body: JSON.stringify({}),
3322
+ signal: AbortSignal.timeout(8e3)
3323
+ });
3324
+ const json = await res.json().catch(() => null);
3325
+ const ok = res.ok && json?.ok === true;
3326
+ checks.push({
3327
+ name: "Saved credentials valid",
3328
+ ok,
3329
+ detail: ok ? `API key resolves to project "${json?.data?.project_name ?? config.projectId}".` : `${json?.error?.message ?? `whoami \u2192 HTTP ${res.status}`} \u2014 re-run \`npx mushi-mushi\` to sign in again.`
3330
+ });
3331
+ } catch (err) {
3332
+ checks.push({
3333
+ name: "Saved credentials valid",
3334
+ ok: false,
3335
+ detail: `whoami fetch failed: ${err instanceof Error ? err.message : String(err)}`
3336
+ });
3337
+ }
3338
+ } else {
3339
+ checks.push({
3340
+ name: "Saved credentials valid",
3341
+ ok: true,
3342
+ warn: true,
3343
+ detail: "No saved CLI credentials yet \u2014 normal before the first successful sign-in."
3344
+ });
3345
+ }
3346
+ return checks;
3347
+ }
3144
3348
  async function checkSdkInstall(cwd) {
3145
3349
  try {
3146
3350
  const { readFile: readFile3 } = await import("fs/promises");
@@ -3177,13 +3381,10 @@ async function checkServerPreflight(config, doFetch = globalThis.fetch) {
3177
3381
  }
3178
3382
  try {
3179
3383
  const { endpoint, apiKey, projectId } = sanitizeCliCredentials(config);
3180
- const res = await doFetch(
3181
- `${endpoint}/v1/admin/projects/${projectId}/preflight`,
3182
- {
3183
- headers: apiKeyHeaders(apiKey, projectId),
3184
- signal: AbortSignal.timeout(8e3)
3185
- }
3186
- );
3384
+ const res = await doFetch(`${endpoint}/v1/admin/projects/${projectId}/preflight`, {
3385
+ headers: apiKeyHeaders(apiKey, projectId),
3386
+ signal: AbortSignal.timeout(8e3)
3387
+ });
3187
3388
  if (res.ok) {
3188
3389
  const body = await res.json();
3189
3390
  const serverChecks = body.data?.checks ?? [];
@@ -3239,7 +3440,13 @@ async function checkIngestSetup(config, doFetch = globalThis.fetch) {
3239
3440
  doFetch
3240
3441
  );
3241
3442
  if (!data) {
3242
- return [{ name: "Ingest setup", ok: false, detail: "Request to /v1/sync/ingest-setup failed or returned invalid payload" }];
3443
+ return [
3444
+ {
3445
+ name: "Ingest setup",
3446
+ ok: false,
3447
+ detail: "Request to /v1/sync/ingest-setup failed or returned invalid payload"
3448
+ }
3449
+ ];
3243
3450
  }
3244
3451
  const steps = data.steps ?? [];
3245
3452
  const checks = steps.filter((s) => s.required).map((s) => ({
@@ -3275,22 +3482,27 @@ async function checkQaStoriesHealth(config, doFetch = globalThis.fetch) {
3275
3482
  try {
3276
3483
  const { endpoint, apiKey, projectId } = sanitizeCliCredentials(config);
3277
3484
  const headers = apiKeyHeaders(apiKey, projectId);
3278
- const storiesRes = await doFetch(
3279
- `${endpoint}/v1/admin/projects/${projectId}/qa-coverage`,
3280
- {
3281
- headers,
3282
- signal: AbortSignal.timeout(8e3)
3283
- }
3284
- );
3485
+ const storiesRes = await doFetch(`${endpoint}/v1/admin/projects/${projectId}/qa-coverage`, {
3486
+ headers,
3487
+ signal: AbortSignal.timeout(8e3)
3488
+ });
3285
3489
  if (!storiesRes.ok) {
3286
- checks.push({ name: "[qa] Fetch QA stories", ok: false, detail: `HTTP ${storiesRes.status}` });
3490
+ checks.push({
3491
+ name: "[qa] Fetch QA stories",
3492
+ ok: false,
3493
+ detail: `HTTP ${storiesRes.status}`
3494
+ });
3287
3495
  return checks;
3288
3496
  }
3289
3497
  const storiesBody = await storiesRes.json();
3290
3498
  const stories = storiesBody.data?.coverage ?? [];
3291
3499
  const enabled = stories.filter((s) => s.enabled);
3292
3500
  if (enabled.length === 0) {
3293
- checks.push({ name: "[qa] Enabled QA stories", ok: true, detail: "No enabled stories \u2014 create one at /qa-coverage" });
3501
+ checks.push({
3502
+ name: "[qa] Enabled QA stories",
3503
+ ok: true,
3504
+ detail: "No enabled stories \u2014 create one at /qa-coverage"
3505
+ });
3294
3506
  return checks;
3295
3507
  }
3296
3508
  checks.push({
@@ -3359,7 +3571,9 @@ async function checkHostAppWiring(cwd) {
3359
3571
  }
3360
3572
  }
3361
3573
  const hasProjectId = /VITE_MUSHI_PROJECT_ID=|NEXT_PUBLIC_MUSHI_PROJECT_ID=|MUSHI_PROJECT_ID=/.test(envContent);
3362
- const hasApiKey = /VITE_MUSHI_API_KEY=|NEXT_PUBLIC_MUSHI_API_KEY=|MUSHI_API_KEY=/.test(envContent);
3574
+ const hasApiKey = /VITE_MUSHI_API_KEY=|NEXT_PUBLIC_MUSHI_API_KEY=|MUSHI_API_KEY=/.test(
3575
+ envContent
3576
+ );
3363
3577
  checks.push({
3364
3578
  name: "[host] Mushi env vars in .env.local",
3365
3579
  ok: hasProjectId && hasApiKey,
@@ -3404,10 +3618,7 @@ async function checkMcpConfig(config, cwd, doFetch = globalThis.fetch) {
3404
3618
  const { join: join4, resolve: resolve3 } = await import("path");
3405
3619
  const { homedir } = await import("os");
3406
3620
  const root = resolve3(cwd);
3407
- const candidates = [
3408
- join4(root, ".cursor", "mcp.json"),
3409
- join4(homedir(), ".cursor", "mcp.json")
3410
- ];
3621
+ const candidates = [join4(root, ".cursor", "mcp.json"), join4(homedir(), ".cursor", "mcp.json")];
3411
3622
  let mcpPath = null;
3412
3623
  let mcpRaw = null;
3413
3624
  for (const candidate of candidates) {
@@ -3445,11 +3656,17 @@ async function checkMcpConfig(config, cwd, doFetch = globalThis.fetch) {
3445
3656
  try {
3446
3657
  mcpConfig = JSON.parse(mcpRaw);
3447
3658
  } catch {
3448
- checks.push({ name: "[mcp] mcp.json valid JSON", ok: false, detail: "mcp.json is not valid JSON \u2014 regenerate with `mushi setup`." });
3659
+ checks.push({
3660
+ name: "[mcp] mcp.json valid JSON",
3661
+ ok: false,
3662
+ detail: "mcp.json is not valid JSON \u2014 regenerate with `mushi setup`."
3663
+ });
3449
3664
  return checks;
3450
3665
  }
3451
3666
  const servers = mcpConfig.mcpServers ?? {};
3452
- const mushiEntries = Object.entries(servers).filter(([k]) => k === "mushi" || k.startsWith("mushi-"));
3667
+ const mushiEntries = Object.entries(servers).filter(
3668
+ ([k]) => k === "mushi" || k.startsWith("mushi-")
3669
+ );
3453
3670
  if (mushiEntries.length === 0) {
3454
3671
  checks.push({
3455
3672
  name: "[mcp] mushi server entry",
@@ -3569,7 +3786,9 @@ async function checkOnboardingStatus(config, _consoleBase, cwd, doFetch = global
3569
3786
  } catch {
3570
3787
  }
3571
3788
  }
3572
- const hasEnvVars = /(?:VITE_|NEXT_PUBLIC_|EXPO_PUBLIC_|NUXT_PUBLIC_)?MUSHI_PROJECT_ID=/.test(envContent);
3789
+ const hasEnvVars = /(?:VITE_|NEXT_PUBLIC_|EXPO_PUBLIC_|NUXT_PUBLIC_)?MUSHI_PROJECT_ID=/.test(
3790
+ envContent
3791
+ );
3573
3792
  if (!hasEnvVars) {
3574
3793
  return {
3575
3794
  nextAction: "Write env vars to your app: run `npx mushi-mushi` or `mushi connect --write-env`",
@@ -3658,6 +3877,9 @@ async function runDoctor(config, options = {}) {
3658
3877
  const mcpChecks = await checkMcpConfig(config, options.cwd ?? process.cwd(), doFetch);
3659
3878
  checks.push(...mcpChecks);
3660
3879
  }
3880
+ if (isFull || options.auth) {
3881
+ checks.push(...await checkCliAuthPath(config, doFetch));
3882
+ }
3661
3883
  const heartbeatPassed = checks.some((c) => c.name === "[ingest] Last SDK heartbeat" && c.ok);
3662
3884
  if (heartbeatPassed) {
3663
3885
  const idx = checks.findIndex((c) => c.name === "SDK installed in this repo" && !c.ok);
@@ -3690,8 +3912,10 @@ function formatDoctorResult(result) {
3690
3912
  const warned = result.checks.filter((c) => c.ok && c.warn);
3691
3913
  if (failed.length === 0) {
3692
3914
  if (warned.length > 0) {
3693
- lines.push(`
3694
- All checks passed with ${warned.length} advisory warning${warned.length === 1 ? "" : "s"}. The CLI is ready.`);
3915
+ lines.push(
3916
+ `
3917
+ All checks passed with ${warned.length} advisory warning${warned.length === 1 ? "" : "s"}. The CLI is ready.`
3918
+ );
3695
3919
  } else {
3696
3920
  lines.push("\nAll checks passed. The CLI is ready.");
3697
3921
  }
@@ -3716,6 +3940,9 @@ function registerDoctorCommand(program2) {
3716
3940
  ).option(
3717
3941
  "--mcp",
3718
3942
  "Check Cursor MCP config: verify mushi-* server entry, credentials, and probe account-overview connectivity."
3943
+ ).option(
3944
+ "--auth",
3945
+ "Diagnose the browser sign-in handshake: device-auth route reachability, system clock skew, and saved-credential validity. Run this when `npx mushi-mushi` browser sign-in does not complete."
3719
3946
  ).option(
3720
3947
  "--fix",
3721
3948
  "Apply safe local fixes when checks fail: write missing .env.local lines and wire Cursor MCP config."
@@ -3725,54 +3952,60 @@ function registerDoctorCommand(program2) {
3725
3952
  ).option(
3726
3953
  "--full",
3727
3954
  "Run ALL check categories (server, ingest, host-app, mcp, qa-stories) in one shot. Good for first-run diagnostics. Exit 0 only when all checks pass."
3728
- ).action(async (opts) => {
3729
- const config = loadConfig();
3730
- if (opts.onboarding) {
3731
- const cwd = opts.cwd ?? process.cwd();
3732
- const consoleBase = await resolveConsoleUrl({ cwd });
3733
- const status = await checkOnboardingStatus(config, consoleBase, cwd);
3734
- const base = consoleBase.replace(/\/$/, "");
3735
- if (status.done) {
3736
- console.log(`OK Setup complete. ${status.nextAction}`);
3737
- console.log(` ${base}${status.ctaPath}`);
3738
- } else {
3739
- console.log(`\u2192 Next: ${status.nextAction}`);
3740
- console.log(` Open: ${base}${status.ctaPath}`);
3955
+ ).action(
3956
+ async (opts) => {
3957
+ const config = loadConfig();
3958
+ if (opts.onboarding) {
3959
+ const cwd = opts.cwd ?? process.cwd();
3960
+ const consoleBase = await resolveConsoleUrl({ cwd });
3961
+ const status = await checkOnboardingStatus(config, consoleBase, cwd);
3962
+ const base = consoleBase.replace(/\/$/, "");
3963
+ if (status.done) {
3964
+ console.log(`OK Setup complete. ${status.nextAction}`);
3965
+ console.log(` ${base}${status.ctaPath}`);
3966
+ } else {
3967
+ console.log(`\u2192 Next: ${status.nextAction}`);
3968
+ console.log(` Open: ${base}${status.ctaPath}`);
3969
+ }
3970
+ if (!status.done) process.exit(1);
3971
+ return;
3741
3972
  }
3742
- if (!status.done) process.exit(1);
3743
- return;
3744
- }
3745
- const doctorOpts = {
3746
- cwd: opts.cwd,
3747
- server: opts.server,
3748
- ingest: opts.ingest,
3749
- qaStories: opts.qaStories,
3750
- hostApp: opts.hostApp,
3751
- mcp: opts.mcp,
3752
- full: opts.full
3753
- };
3754
- let result = await runDoctor(config, doctorOpts);
3755
- if (!result.ready && opts.fix && config.apiKey && config.projectId && config.endpoint) {
3756
- const connectResult = await runConnect({
3757
- apiKey: config.apiKey,
3758
- projectId: config.projectId,
3759
- endpoint: config.endpoint,
3760
- cwd: opts.cwd ?? process.cwd(),
3761
- writeEnv: true,
3762
- wireIde: true
3763
- }, config);
3764
- for (const msg of connectResult.messages) console.log(msg);
3765
- result = await runDoctor(config, { ...doctorOpts, mcp: opts.mcp });
3766
- }
3767
- const { checks } = result;
3768
- if (opts.json) {
3769
- console.log(JSON.stringify({ checks, ready: result.ready }, null, 2));
3973
+ const doctorOpts = {
3974
+ cwd: opts.cwd,
3975
+ server: opts.server,
3976
+ ingest: opts.ingest,
3977
+ qaStories: opts.qaStories,
3978
+ hostApp: opts.hostApp,
3979
+ mcp: opts.mcp,
3980
+ auth: opts.auth,
3981
+ full: opts.full
3982
+ };
3983
+ let result = await runDoctor(config, doctorOpts);
3984
+ if (!result.ready && opts.fix && config.apiKey && config.projectId && config.endpoint) {
3985
+ const connectResult = await runConnect(
3986
+ {
3987
+ apiKey: config.apiKey,
3988
+ projectId: config.projectId,
3989
+ endpoint: config.endpoint,
3990
+ cwd: opts.cwd ?? process.cwd(),
3991
+ writeEnv: true,
3992
+ wireIde: true
3993
+ },
3994
+ config
3995
+ );
3996
+ for (const msg of connectResult.messages) console.log(msg);
3997
+ result = await runDoctor(config, { ...doctorOpts, mcp: opts.mcp });
3998
+ }
3999
+ const { checks } = result;
4000
+ if (opts.json) {
4001
+ console.log(JSON.stringify({ checks, ready: result.ready }, null, 2));
4002
+ if (!result.ready) process.exit(1);
4003
+ return;
4004
+ }
4005
+ console.log(formatDoctorResult(result));
3770
4006
  if (!result.ready) process.exit(1);
3771
- return;
3772
4007
  }
3773
- console.log(formatDoctorResult(result));
3774
- if (!result.ready) process.exit(1);
3775
- });
4008
+ );
3776
4009
  }
3777
4010
 
3778
4011
  // src/commands/reset-cli.ts
@@ -4789,6 +5022,131 @@ Examples:
4789
5022
  });
4790
5023
  }
4791
5024
 
5025
+ // src/commands/completion-cli.ts
5026
+ import { Argument } from "commander";
5027
+
5028
+ // src/completion.ts
5029
+ var SUPPORTED_SHELLS = ["bash", "zsh", "fish"];
5030
+ function isSupportedShell(value) {
5031
+ return SUPPORTED_SHELLS.includes(value);
5032
+ }
5033
+ function buildCommandTree(program2) {
5034
+ return program2.commands.filter((cmd) => cmd.name() !== "help").map((cmd) => ({
5035
+ name: cmd.name(),
5036
+ subcommands: cmd.commands.filter((sub) => sub.name() !== "help").map((sub) => sub.name()).sort()
5037
+ })).sort((a, b) => a.name.localeCompare(b.name));
5038
+ }
5039
+ var COMPLETION_ARG_VALUES = [...SUPPORTED_SHELLS];
5040
+ function subcommandsFor(tree, name) {
5041
+ if (name === "completion") return COMPLETION_ARG_VALUES;
5042
+ return tree.find((n) => n.name === name)?.subcommands ?? [];
5043
+ }
5044
+ function generateBash(tree) {
5045
+ const topLevel = tree.map((n) => n.name).join(" ");
5046
+ const cases = tree.map((n) => {
5047
+ const subs = subcommandsFor(tree, n.name);
5048
+ if (subs.length === 0) return null;
5049
+ return ` ${n.name})
5050
+ COMPREPLY=( $(compgen -W "${subs.join(" ")}" -- "$cur") )
5051
+ ;;`;
5052
+ }).filter((line) => line !== null).join("\n");
5053
+ return `###-begin-mushi-completion-###
5054
+ # mushi(1) completion \u2014 https://github.com/kensaurus/mushi-mushi
5055
+ # Install: mushi completion bash >> ~/.bashrc (then restart your shell)
5056
+ _mushi_completion() {
5057
+ local cur
5058
+ cur="\${COMP_WORDS[COMP_CWORD]}"
5059
+ COMPREPLY=()
5060
+
5061
+ if [ "$COMP_CWORD" -eq 1 ]; then
5062
+ COMPREPLY=( $(compgen -W "${topLevel}" -- "$cur") )
5063
+ return 0
5064
+ fi
5065
+
5066
+ case "\${COMP_WORDS[1]}" in
5067
+ ${cases}
5068
+ *)
5069
+ ;;
5070
+ esac
5071
+ }
5072
+ complete -F _mushi_completion mushi
5073
+ ###-end-mushi-completion-###
5074
+ `;
5075
+ }
5076
+ function generateZsh(tree) {
5077
+ const topLevel = tree.map((n) => n.name).join(" ");
5078
+ const cases = tree.map((n) => {
5079
+ const subs = subcommandsFor(tree, n.name);
5080
+ if (subs.length === 0) return null;
5081
+ return ` ${n.name})
5082
+ local -a subcmds; subcmds=(${subs.join(" ")})
5083
+ _describe '${n.name} subcommand' subcmds
5084
+ ;;`;
5085
+ }).filter((line) => line !== null).join("\n");
5086
+ return `#compdef mushi
5087
+ # mushi(1) completion \u2014 https://github.com/kensaurus/mushi-mushi
5088
+ # Install: mushi completion zsh > "\${fpath[1]}/_mushi" (then restart your shell)
5089
+ _mushi() {
5090
+ local -a top_level
5091
+ top_level=(${topLevel})
5092
+
5093
+ if (( CURRENT == 2 )); then
5094
+ _describe 'command' top_level
5095
+ return
5096
+ fi
5097
+
5098
+ case "\${words[2]}" in
5099
+ ${cases}
5100
+ *)
5101
+ ;;
5102
+ esac
5103
+ }
5104
+
5105
+ _mushi
5106
+ `;
5107
+ }
5108
+ function generateFish(tree) {
5109
+ const topLevel = tree.map((n) => n.name).join(" ");
5110
+ const lines = [
5111
+ "# mushi(1) completion \u2014 https://github.com/kensaurus/mushi-mushi",
5112
+ "# Install: mushi completion fish > ~/.config/fish/completions/mushi.fish",
5113
+ `complete -c mushi -f -n "__fish_use_subcommand" -a "${topLevel}"`
5114
+ ];
5115
+ for (const n of tree) {
5116
+ const subs = subcommandsFor(tree, n.name);
5117
+ if (subs.length === 0) continue;
5118
+ lines.push(`complete -c mushi -f -n "__fish_seen_subcommand_from ${n.name}" -a "${subs.join(" ")}"`);
5119
+ }
5120
+ return lines.join("\n") + "\n";
5121
+ }
5122
+ function generateCompletionScript(shell, tree) {
5123
+ switch (shell) {
5124
+ case "bash":
5125
+ return generateBash(tree);
5126
+ case "zsh":
5127
+ return generateZsh(tree);
5128
+ case "fish":
5129
+ return generateFish(tree);
5130
+ }
5131
+ }
5132
+
5133
+ // src/commands/completion-cli.ts
5134
+ function registerCompletionCommand(program2) {
5135
+ program2.command("completion").description("Print a shell completion script for bash, zsh, or fish").addArgument(new Argument("<shell>", "Target shell").choices([...SUPPORTED_SHELLS])).addHelpText("after", `
5136
+ Examples:
5137
+ eval "$(mushi completion bash)" # try it for the current session
5138
+ mushi completion bash >> ~/.bashrc # bash: persist across sessions
5139
+ mushi completion zsh > "\${fpath[1]}/_mushi" # zsh: needs a dir already on $fpath
5140
+ mushi completion fish > ~/.config/fish/completions/mushi.fish`).action((shell) => {
5141
+ if (!isSupportedShell(shell)) {
5142
+ console.error(`Unsupported shell "${shell}". Supported: ${SUPPORTED_SHELLS.join(", ")}`);
5143
+ process.exit(2);
5144
+ }
5145
+ const tree = buildCommandTree(program2);
5146
+ console.log(generateCompletionScript(shell, tree));
5147
+ });
5148
+ }
5149
+
4792
5150
  // src/index.ts
4793
5151
  installSignalHandlers();
4794
5152
  var program = new Command().name("mushi").description("Mushi CLI \u2014 set up the SDK, triage a report, fix from your editor").version(MUSHI_CLI_VERSION).addHelpText("after", `
@@ -4827,6 +5185,7 @@ registerQaCommands(program);
4827
5185
  registerAuditCommands(program);
4828
5186
  registerSkillsCommands(program);
4829
5187
  registerBillingCommands(program);
5188
+ registerCompletionCommand(program);
4830
5189
  program.parseAsync().catch((err) => {
4831
5190
  printAndExit(err);
4832
5191
  });