@mushi-mushi/cli 0.18.2 → 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.
package/dist/init.js CHANGED
@@ -1,3 +1,13 @@
1
+ import {
2
+ MUSHI_CLI_VERSION
3
+ } from "./chunk-6LM2KW4S.js";
4
+ import {
5
+ TEST_REPORT_FETCH_TIMEOUT_MS,
6
+ loadConfig,
7
+ normalizeEndpoint,
8
+ resolveCloudEndpoint,
9
+ saveConfig
10
+ } from "./chunk-XEXYGDQV.js";
1
11
  import {
2
12
  FRAMEWORKS,
3
13
  detectFramework,
@@ -6,9 +16,6 @@ import {
6
16
  installCommand,
7
17
  readPackageJson
8
18
  } from "./chunk-7Q44KWGN.js";
9
- import {
10
- MUSHI_CLI_VERSION
11
- } from "./chunk-E4IZPV6D.js";
12
19
 
13
20
  // src/init.ts
14
21
  import * as p from "@clack/prompts";
@@ -17,117 +24,359 @@ import { randomUUID } from "crypto";
17
24
  import { appendFileSync, existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
18
25
  import { join as join3 } from "path";
19
26
 
20
- // src/config.ts
21
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "fs";
22
- import { homedir } from "os";
27
+ // src/console-url.ts
28
+ import { existsSync, readFileSync } from "fs";
23
29
  import { dirname, join } from "path";
24
- var SECURE_FILE_MODE = 384;
25
- var SECURE_DIR_MODE = 448;
26
- function resolveXdgConfigPath() {
27
- const xdg = process.env["XDG_CONFIG_HOME"];
28
- if (xdg && xdg.length > 0) {
29
- return join(xdg, "mushi", "config.json");
30
- }
31
- if (process.platform === "win32") {
32
- const appData = process.env["APPDATA"];
33
- if (appData && appData.length > 0) {
34
- return join(appData, "mushi", "config.json");
30
+ var HOSTED_CONSOLE_BASE = "https://kensaur.us/mushi-mushi/admin";
31
+ var LOCAL_ADMIN_URL = "http://localhost:6464";
32
+ var LOCAL_PROBE_MS = 800;
33
+ function normalizeConsoleBase(url) {
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);
38
+ }
39
+ function consoleUrl(base, route) {
40
+ const normalizedRoute = route.startsWith("/") ? route : `/${route}`;
41
+ return `${normalizeConsoleBase(base)}${normalizedRoute}`;
42
+ }
43
+ function resolveConsoleUrlFromEnv() {
44
+ const raw = process.env["MUSHI_CONSOLE_URL"]?.trim();
45
+ return raw ? normalizeConsoleBase(raw) : void 0;
46
+ }
47
+ function isInsideMushiMonorepo(cwd) {
48
+ let dir = cwd;
49
+ for (let depth = 0; depth < 14; depth += 1) {
50
+ const pkgPath = join(dir, "package.json");
51
+ if (existsSync(pkgPath)) {
52
+ try {
53
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
54
+ if (pkg.name === "mushi-mushi") return true;
55
+ } catch {
56
+ }
35
57
  }
58
+ const parent = dirname(dir);
59
+ if (parent === dir) break;
60
+ dir = parent;
36
61
  }
37
- return join(homedir(), ".config", "mushi", "config.json");
62
+ return false;
38
63
  }
39
- var LEGACY_CONFIG_PATH = join(homedir(), ".mushirc");
40
- var CONFIG_PATH = resolveXdgConfigPath();
41
- function loadConfig(path = CONFIG_PATH) {
42
- let file = {};
43
- if (existsSync(path)) {
44
- tightenPermissions(path);
45
- try {
46
- file = JSON.parse(readFileSync(path, "utf-8"));
47
- } catch {
64
+ async function probeLocalAdminConsole() {
65
+ try {
66
+ const controller = new AbortController();
67
+ const timer = setTimeout(() => controller.abort(), LOCAL_PROBE_MS);
68
+ const res = await fetch(LOCAL_ADMIN_URL, {
69
+ signal: controller.signal,
70
+ method: "HEAD"
71
+ });
72
+ clearTimeout(timer);
73
+ if (res.ok || res.status === 404 || res.status === 401) {
74
+ return LOCAL_ADMIN_URL;
48
75
  }
49
- } else if (path === CONFIG_PATH && existsSync(LEGACY_CONFIG_PATH)) {
50
- file = migrateLegacyConfig() ?? {};
51
- }
52
- const endpointFromEnv = process.env["MUSHI_API_ENDPOINT"] ?? process.env["MUSHI_ENDPOINT"] ?? void 0;
53
- const fromEnv = {
54
- ...process.env["MUSHI_API_KEY"] ? { apiKey: process.env["MUSHI_API_KEY"] } : {},
55
- ...process.env["MUSHI_PROJECT_ID"] ? { projectId: process.env["MUSHI_PROJECT_ID"] } : {},
56
- ...endpointFromEnv ? { endpoint: endpointFromEnv } : {}
57
- };
58
- return { ...file, ...fromEnv };
59
- }
60
- function saveConfig(config, path = CONFIG_PATH) {
61
- const dir = dirname(path);
62
- if (!existsSync(dir)) {
63
- mkdirSync(dir, { recursive: true, mode: SECURE_DIR_MODE });
64
- } else {
65
- tightenDirPermissions(dir);
76
+ } catch {
66
77
  }
67
- writeFileSync(path, JSON.stringify(config, null, 2), { mode: SECURE_FILE_MODE });
68
- tightenPermissions(path);
78
+ return null;
79
+ }
80
+ function resolveConsoleUrlSync(cwd = process.cwd()) {
81
+ const fromEnv = resolveConsoleUrlFromEnv();
82
+ if (fromEnv) return fromEnv;
83
+ const config = loadConfig();
84
+ if (config.consoleUrl) return normalizeConsoleBase(config.consoleUrl);
85
+ if (isInsideMushiMonorepo(cwd)) return LOCAL_ADMIN_URL;
86
+ return HOSTED_CONSOLE_BASE;
87
+ }
88
+ async function resolveConsoleUrl(options) {
89
+ const fromEnv = resolveConsoleUrlFromEnv();
90
+ if (fromEnv) return fromEnv;
91
+ const config = loadConfig();
92
+ if (config.consoleUrl) return normalizeConsoleBase(config.consoleUrl);
93
+ const local = await probeLocalAdminConsole();
94
+ if (local) return local;
95
+ const cwd = options?.cwd ?? process.cwd();
96
+ if (isInsideMushiMonorepo(cwd)) return LOCAL_ADMIN_URL;
97
+ return HOSTED_CONSOLE_BASE;
69
98
  }
70
- function migrateLegacyConfig(legacyPath = LEGACY_CONFIG_PATH, destPath = CONFIG_PATH) {
71
- if (!existsSync(legacyPath)) return null;
99
+ function projectIdHint(base) {
100
+ return `Where to find it: ${consoleUrl(base, "/projects")} \u2192 select your project \u2192 click the UUID chip to copy (or copy it from the panel right after you create the project).`;
101
+ }
102
+ function apiKeyHint(base) {
103
+ return `Where to find it: ${consoleUrl(base, "/onboarding?tab=verify")} \u2192 Generate API key (SDK ingest needs report:write \u2014 not Settings \u2192 BYOK keys). Treat it like a password \u2014 env file only, never commit.`;
104
+ }
105
+ function cliSetupDeepLink(base) {
106
+ return consoleUrl(base, "/onboarding?tab=steps&setup=cli");
107
+ }
108
+ function reportsUrl(base, reportId) {
109
+ return reportId ? consoleUrl(base, `/reports/${reportId}`) : consoleUrl(base, "/reports");
110
+ }
111
+ async function openInBrowser(url) {
72
112
  let parsed;
73
113
  try {
74
- parsed = JSON.parse(readFileSync(legacyPath, "utf-8"));
114
+ parsed = new URL(url);
75
115
  } catch {
76
- return null;
116
+ return;
77
117
  }
78
- const dir = dirname(destPath);
79
- if (!existsSync(dir)) {
80
- mkdirSync(dir, { recursive: true, mode: SECURE_DIR_MODE });
118
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
119
+ return;
81
120
  }
82
- try {
83
- renameSync(legacyPath, destPath);
84
- } catch {
85
- writeFileSync(destPath, JSON.stringify(parsed, null, 2), { mode: SECURE_FILE_MODE });
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]];
131
+ await new Promise((resolve2) => {
86
132
  try {
87
- unlinkSync(legacyPath);
133
+ const child = spawn2(command, [...args], { stdio: "ignore", shell: false });
134
+ child.on("error", () => resolve2());
135
+ child.on("exit", () => resolve2());
136
+ child.unref();
88
137
  } catch {
138
+ resolve2();
139
+ }
140
+ });
141
+ }
142
+
143
+ // src/signals.ts
144
+ var activeController = null;
145
+ function getAbortSignal(external) {
146
+ if (external) return external.signal;
147
+ if (!activeController || activeController.signal.aborted) {
148
+ activeController = new AbortController();
149
+ }
150
+ return activeController.signal;
151
+ }
152
+
153
+ // src/cli-shared.ts
154
+ var API_TIMEOUT_MS = 15e3;
155
+ async function apiCall(path, config, options = {}) {
156
+ const endpoint = config.endpoint;
157
+ if (!endpoint) {
158
+ return {
159
+ ok: false,
160
+ error: {
161
+ code: "NO_ENDPOINT",
162
+ message: "No API endpoint configured.\n Run: mushi login --api-key <key> --endpoint <url> --project-id <id>\n Or: export MUSHI_API_ENDPOINT=https://<project-ref>.supabase.co/functions/v1/api"
163
+ }
164
+ };
165
+ }
166
+ const controller = new AbortController();
167
+ const timer = setTimeout(() => controller.abort(), API_TIMEOUT_MS);
168
+ const signals = [controller.signal, getAbortSignal()];
169
+ const compositeSignal = AbortSignal.any ? AbortSignal.any(signals) : controller.signal;
170
+ try {
171
+ const res = await fetch(`${endpoint}${path}`, {
172
+ ...options,
173
+ signal: compositeSignal,
174
+ headers: {
175
+ "Content-Type": "application/json",
176
+ // `apiKeyAuth` reads X-Mushi-Api-Key; the Authorization header is a
177
+ // fallback accepted by some older middleware. Both are sent.
178
+ "Authorization": `Bearer ${config.apiKey ?? ""}`,
179
+ "X-Mushi-Api-Key": config.apiKey ?? "",
180
+ "X-Mushi-Project": config.projectId ?? "",
181
+ "X-Mushi-Cli-Version": MUSHI_CLI_VERSION,
182
+ ...options.headers
183
+ }
184
+ });
185
+ clearTimeout(timer);
186
+ let body;
187
+ const contentType = res.headers.get("content-type") ?? "";
188
+ if (contentType.includes("application/json")) {
189
+ try {
190
+ body = await res.json();
191
+ } catch {
192
+ body = null;
193
+ }
194
+ } else {
195
+ const text2 = await res.text();
196
+ try {
197
+ body = JSON.parse(text2);
198
+ } catch {
199
+ body = {
200
+ ok: false,
201
+ error: {
202
+ code: `HTTP_${res.status}`,
203
+ message: text2.trim().slice(0, 300) || `HTTP ${res.status}`
204
+ }
205
+ };
206
+ }
207
+ }
208
+ if (!res.ok && typeof body === "object" && body !== null && !("ok" in body)) {
209
+ const b = body;
210
+ return {
211
+ ok: false,
212
+ httpStatus: res.status,
213
+ error: {
214
+ code: b["code"] ?? `HTTP_${res.status}`,
215
+ message: b["message"] ?? `Request failed (${res.status})`
216
+ }
217
+ };
89
218
  }
219
+ return body;
220
+ } catch (err) {
221
+ clearTimeout(timer);
222
+ if (err instanceof Error && err.name === "AbortError") {
223
+ return {
224
+ ok: false,
225
+ error: {
226
+ code: "TIMEOUT",
227
+ message: `Request timed out after ${API_TIMEOUT_MS / 1e3}s. Check your network or endpoint.`
228
+ }
229
+ };
230
+ }
231
+ return {
232
+ ok: false,
233
+ error: {
234
+ code: "NETWORK_ERROR",
235
+ message: err instanceof Error ? err.message : String(err)
236
+ }
237
+ };
90
238
  }
91
- tightenPermissions(destPath);
92
- tightenDirPermissions(dir);
93
- return parsed;
94
239
  }
95
- function tightenPermissions(path) {
96
- if (process.platform === "win32") return;
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);
97
251
  try {
98
- const current = statSync(path).mode & 511;
99
- if (current !== SECURE_FILE_MODE) chmodSync(path, SECURE_FILE_MODE);
100
- } catch {
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
+ }
101
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
+ };
102
342
  }
103
- function tightenDirPermissions(path) {
104
- if (process.platform === "win32") return;
343
+ async function listProjects(endpoint, cliToken) {
344
+ const base = trimTrailingSlash(endpoint);
105
345
  try {
106
- const current = statSync(path).mode & 511;
107
- if (current !== SECURE_DIR_MODE) chmodSync(path, SECURE_DIR_MODE);
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 ?? [];
108
349
  } catch {
109
350
  }
351
+ return [];
110
352
  }
111
-
112
- // src/endpoint.ts
113
- var TEST_REPORT_TIMEOUT_MS = 1e4;
114
- var TEST_REPORT_FETCH_TIMEOUT_MS = TEST_REPORT_TIMEOUT_MS;
115
- var CLOUD_API_ENDPOINT = "https://dxptnwrhwsqckaftyymj.supabase.co/functions/v1/api";
116
- function resolveCloudEndpoint(explicit) {
117
- if (explicit?.trim()) return explicit.trim();
118
- const fromEnv = process.env.MUSHI_API_ENDPOINT?.trim();
119
- if (fromEnv) return fromEnv;
120
- return CLOUD_API_ENDPOINT;
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 };
121
366
  }
122
- function normalizeEndpoint(url) {
123
- if (!url) {
124
- throw new Error(
125
- "No API endpoint configured. Run `mushi init` or set MUSHI_API_ENDPOINT. Set endpoint to your Supabase edge function URL, e.g. https://xyz.supabase.co/functions/v1/api"
126
- );
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 {
127
378
  }
128
- let end = url.length;
129
- while (end > 0 && url.charCodeAt(end - 1) === 47) end--;
130
- return url.slice(0, end);
379
+ return null;
131
380
  }
132
381
 
133
382
  // src/freshness.ts
@@ -190,7 +439,7 @@ function parse(version) {
190
439
  }
191
440
 
192
441
  // src/monorepo.ts
193
- import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync, statSync as statSync2 } from "fs";
442
+ import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync, statSync } from "fs";
194
443
  import { dirname as dirname2, join as join2, resolve } from "path";
195
444
  var WORKSPACE_GLOB_CANDIDATES = ["apps/*", "packages/*", "examples/*"];
196
445
  var FRAMEWORK_DEPS = {
@@ -269,7 +518,7 @@ function readPackageJsonSafely(path) {
269
518
  }
270
519
  function isFileSafe(path) {
271
520
  try {
272
- return existsSync2(path) && statSync2(path).isFile();
521
+ return existsSync2(path) && statSync(path).isFile();
273
522
  } catch {
274
523
  return false;
275
524
  }
@@ -310,7 +559,10 @@ async function runInit(options = {}) {
310
559
  }
311
560
  const detected = detectFramework(cwd, pkg);
312
561
  const framework = await chooseFramework(detected, options);
313
- const credentials = await collectCredentials(options);
562
+ const consoleBase = await resolveConsoleUrl({ cwd });
563
+ const endpoint = resolveCloudEndpoint(options.endpoint);
564
+ const credentials = await acquireCredentials(options, consoleBase, endpoint);
565
+ await verifyCredentials(credentials, options, consoleBase);
314
566
  const pm = detectPackageManager(cwd);
315
567
  const packagesToInstall = framework.needsWebPackage ? [framework.packageName, "@mushi-mushi/web"] : [framework.packageName];
316
568
  if (!options.skipInstall) {
@@ -318,12 +570,12 @@ async function runInit(options = {}) {
318
570
  } else {
319
571
  p.log.info(`Skipped install. Run \`${installCommand(pm, packagesToInstall)}\` yourself.`);
320
572
  }
321
- const endpoint = resolveCloudEndpoint(options.endpoint);
322
573
  writeEnvFile(cwd, credentials.apiKey, credentials.projectId, framework);
323
574
  persistCliConfig(credentials.apiKey, credentials.projectId, endpoint);
324
575
  const enableRewards = await maybeEnableRewards(options);
325
- printNextSteps(framework, credentials.apiKey, credentials.projectId, enableRewards);
326
- await maybeSendTestReport(credentials, { ...options, endpoint });
576
+ printNextSteps(framework, consoleBase, enableRewards);
577
+ await maybeSendTestReport(credentials, { ...options, endpoint, consoleBase });
578
+ await maybeOfferConnect(credentials, options, consoleBase);
327
579
  p.outro("Setup complete. Happy bug squashing \u{1F41B}");
328
580
  }
329
581
  function ensureInteractiveOrBailOut(options) {
@@ -364,25 +616,165 @@ async function chooseFramework(detected, options) {
364
616
  }
365
617
  return FRAMEWORKS[confirmed];
366
618
  }
367
- async function collectCredentials(options) {
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
+ };
625
+ }
626
+ const existing = loadConfig();
627
+ if (!options.projectId && !options.apiKey && existing.projectId && existing.apiKey) {
628
+ const reuse = options.yes ? true : await p.confirm({
629
+ message: "Use the Mushi credentials saved from your last sign-in?",
630
+ initialValue: true
631
+ });
632
+ if (p.isCancel(reuse)) {
633
+ p.cancel("Aborted.");
634
+ process.exit(0);
635
+ }
636
+ if (reuse) {
637
+ return {
638
+ projectId: sanitizeSecret(existing.projectId),
639
+ apiKey: sanitizeSecret(existing.apiKey)
640
+ };
641
+ }
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) {
368
760
  const existing = loadConfig();
369
761
  const rawProjectId = options.projectId ?? existing.projectId ?? await promptText({
370
762
  message: "Project ID",
371
763
  placeholder: "e.g. bdafa28d-b153-482f-bd4f-42981f3fd3a4",
372
- hint: "Where to find it: https://kensaur.us/mushi-mushi/projects \u2192 click your project \u2192 copy the UUID below the project name.",
373
- validate: (v) => PROJECT_ID_PATTERN.test(v.trim()) ? void 0 : "Expected a UUID (e.g. bdafa28d-b153-482f-bd4f-42981f3fd3a4) \u2014 copy it from the Mushi admin console Projects page."
764
+ hint: projectIdHint(consoleBase),
765
+ 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."
374
766
  });
375
767
  const rawApiKey = options.apiKey ?? existing.apiKey ?? await promptText({
376
768
  message: "API key",
377
769
  placeholder: "mushi_xxxxxxxxxxxx",
378
- hint: "Where to find it: https://kensaur.us/mushi-mushi/settings \u2192 API Keys tab. Treat it like a password \u2014 env file only, never commit it.",
770
+ hint: apiKeyHint(consoleBase),
379
771
  validate: (v) => API_KEY_PATTERN.test(v) ? void 0 : "Expected format: mushi_ followed by 10+ alphanumeric characters"
380
772
  });
381
773
  const projectId = sanitizeSecret(rawProjectId);
382
774
  const apiKey = sanitizeSecret(rawApiKey);
383
775
  if (!PROJECT_ID_PATTERN.test(projectId)) {
384
776
  throw new Error(
385
- `Invalid project ID. Expected a UUID (e.g. bdafa28d-b153-482f-bd4f-42981f3fd3a4) or the proj_* prefixed form. Got: ${redact(projectId)} \u2014 copy it from the Projects page in the Mushi console at https://kensaur.us/mushi-mushi/projects`
777
+ `Invalid project ID. Expected a UUID (e.g. bdafa28d-b153-482f-bd4f-42981f3fd3a4) or the proj_* prefixed form. Got: ${redact(projectId)} \u2014 copy it from ${projectIdHint(consoleBase)}`
386
778
  );
387
779
  }
388
780
  if (!API_KEY_PATTERN.test(apiKey)) {
@@ -392,6 +784,23 @@ async function collectCredentials(options) {
392
784
  }
393
785
  return { projectId, apiKey };
394
786
  }
787
+ async function verifyCredentials(credentials, options, consoleBase) {
788
+ const endpoint = resolveCloudEndpoint(options.endpoint);
789
+ const spinner2 = p.spinner();
790
+ spinner2.start("Verifying credentials\u2026");
791
+ const result = await apiCall(
792
+ "/v1/sync/whoami",
793
+ { apiKey: credentials.apiKey, projectId: credentials.projectId, endpoint }
794
+ );
795
+ if (!result.ok) {
796
+ spinner2.stop("Credentials could not be verified.");
797
+ p.log.error(result.error?.message ?? "Authentication failed.");
798
+ p.log.info(`Double-check values in the console:
799
+ ${cliSetupDeepLink(consoleBase)}`);
800
+ throw new Error("Credential verification failed \u2014 fix Project ID / API key and re-run.");
801
+ }
802
+ spinner2.stop(`Connected to ${result.data.project_name}`);
803
+ }
395
804
  function sanitizeSecret(raw) {
396
805
  return raw.trim().replace(/^['"]|['"]$/g, "").replace(/[\r\n\0]/g, "");
397
806
  }
@@ -501,7 +910,7 @@ function persistCliConfig(apiKey, projectId, endpoint) {
501
910
  const existing = loadConfig();
502
911
  saveConfig({ ...existing, apiKey, projectId, endpoint });
503
912
  }
504
- function printNextSteps(framework, _apiKey, _projectId, enableRewards = false) {
913
+ function printNextSteps(framework, consoleBase, enableRewards = false) {
505
914
  p.note(framework.snippet(), "Add this to your app:");
506
915
  if (enableRewards) {
507
916
  const badgeSnippet = framework.id === "react" ? `// Add to your user menu or profile UI:
@@ -512,13 +921,44 @@ import { MushiRewardsBadge } from '@mushi-mushi/react';
512
921
  // import { MushiRewardsBadge } from '@mushi-mushi/react';
513
922
  // <MushiRewardsBadge showPoints />`;
514
923
  p.note(badgeSnippet, "Rewards badge snippet:");
515
- p.log.info("Enable rewards in your project settings at https://kensaur.us/mushi-mushi/rewards");
924
+ p.log.info(`Enable rewards in your project settings at ${consoleUrl(consoleBase, "/rewards")}`);
516
925
  p.log.info("Users will earn points for bug reports, screen navigation, and app activity.");
517
926
  }
518
927
  p.log.message("Verify the install:");
519
928
  p.log.message(" \u2022 Start your dev server");
520
929
  p.log.message(" \u2022 Look for the \u{1F41B} button in the bottom-right corner (or shake on mobile)");
521
- p.log.message(" \u2022 Submit a test report \u2014 it should appear at https://kensaur.us/mushi-mushi/reports");
930
+ p.log.message(` \u2022 Submit a test report \u2014 it should appear at ${reportsUrl(consoleBase)}`);
931
+ p.log.info(
932
+ "Tip: `mushi connect --write-env --wire-ide --wait` also wires Cursor MCP and waits for SDK heartbeat."
933
+ );
934
+ }
935
+ async function maybeOfferConnect(credentials, options, _consoleBase) {
936
+ if (options.yes) return;
937
+ const answer = await p.confirm({
938
+ message: "Run `mushi connect --write-env --wire-ide --wait` now? (SDK env + Cursor MCP + heartbeat check)",
939
+ initialValue: false
940
+ });
941
+ if (p.isCancel(answer) || !answer) return;
942
+ const endpoint = resolveCloudEndpoint(options.endpoint);
943
+ try {
944
+ const { runConnect } = await import("./connect-IC7I76Z2.js");
945
+ await runConnect({
946
+ apiKey: credentials.apiKey,
947
+ projectId: credentials.projectId,
948
+ endpoint,
949
+ cwd: options.cwd,
950
+ writeEnv: true,
951
+ wireIde: true,
952
+ wait: true
953
+ });
954
+ } catch (err) {
955
+ p.log.warn(
956
+ err instanceof Error ? err.message : String(err)
957
+ );
958
+ p.log.info(
959
+ "You can run manually: mushi connect --write-env --wire-ide --wait"
960
+ );
961
+ }
522
962
  }
523
963
  async function maybeEnableRewards(options) {
524
964
  if (options.yes) return false;
@@ -589,8 +1029,8 @@ async function maybeSendTestReport(credentials, options) {
589
1029
  reportId = body.data?.reportId;
590
1030
  } catch {
591
1031
  }
592
- const reportPath = reportId ? `/reports/${reportId}` : "/reports";
593
- p.log.success(`View it at https://kensaur.us/mushi-mushi/admin${reportPath}`);
1032
+ const consoleBase = options.consoleBase ?? resolveConsoleUrlSync(options.cwd);
1033
+ p.log.success(`View it at ${reportsUrl(consoleBase, reportId)}`);
594
1034
  } catch (err) {
595
1035
  const aborted = err instanceof Error && err.name === "AbortError";
596
1036
  spinner2.stop(aborted ? "Timed out reaching the Mushi API." : "Could not reach the Mushi API.");