@mushi-mushi/cli 0.2.0 → 0.5.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
@@ -4,12 +4,14 @@
4
4
  import { Command } from "commander";
5
5
 
6
6
  // src/config.ts
7
- import { readFileSync, writeFileSync, existsSync } from "fs";
7
+ import { chmodSync, readFileSync, statSync, writeFileSync, existsSync } from "fs";
8
8
  import { join } from "path";
9
9
  import { homedir } from "os";
10
10
  var CONFIG_PATH = join(homedir(), ".mushirc");
11
+ var SECURE_FILE_MODE = 384;
11
12
  function loadConfig(path = CONFIG_PATH) {
12
13
  if (!existsSync(path)) return {};
14
+ tightenPermissions(path);
13
15
  try {
14
16
  return JSON.parse(readFileSync(path, "utf-8"));
15
17
  } catch {
@@ -17,12 +19,753 @@ function loadConfig(path = CONFIG_PATH) {
17
19
  }
18
20
  }
19
21
  function saveConfig(config, path = CONFIG_PATH) {
20
- writeFileSync(path, JSON.stringify(config, null, 2));
22
+ writeFileSync(path, JSON.stringify(config, null, 2), { mode: SECURE_FILE_MODE });
23
+ tightenPermissions(path);
24
+ }
25
+ function tightenPermissions(path) {
26
+ if (process.platform === "win32") return;
27
+ try {
28
+ const current = statSync(path).mode & 511;
29
+ if (current !== SECURE_FILE_MODE) chmodSync(path, SECURE_FILE_MODE);
30
+ } catch {
31
+ }
32
+ }
33
+
34
+ // src/init.ts
35
+ import * as p from "@clack/prompts";
36
+ import { spawn } from "child_process";
37
+ import { randomUUID } from "crypto";
38
+ import { appendFileSync, existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
39
+ import { join as join4 } from "path";
40
+
41
+ // src/detect.ts
42
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
43
+ import { join as join2 } from "path";
44
+ var FRAMEWORKS = {
45
+ next: {
46
+ id: "next",
47
+ label: "Next.js",
48
+ packageName: "@mushi-mushi/react",
49
+ needsWebPackage: false,
50
+ snippet: (apiKey, projectId) => `// app/providers.tsx (or pages/_app.tsx for /pages router)
51
+ 'use client'
52
+ import { MushiProvider } from '@mushi-mushi/react'
53
+
54
+ export function Providers({ children }: { children: React.ReactNode }) {
55
+ return (
56
+ <MushiProvider config={{
57
+ projectId: '${projectId}',
58
+ apiKey: '${apiKey}',
59
+ }}>
60
+ {children}
61
+ </MushiProvider>
62
+ )
63
+ }`
64
+ },
65
+ react: {
66
+ id: "react",
67
+ label: "React",
68
+ packageName: "@mushi-mushi/react",
69
+ needsWebPackage: false,
70
+ snippet: (apiKey, projectId) => `// src/main.tsx
71
+ import { MushiProvider } from '@mushi-mushi/react'
72
+
73
+ createRoot(document.getElementById('root')!).render(
74
+ <MushiProvider config={{
75
+ projectId: '${projectId}',
76
+ apiKey: '${apiKey}',
77
+ }}>
78
+ <App />
79
+ </MushiProvider>
80
+ )`
81
+ },
82
+ vue: {
83
+ id: "vue",
84
+ label: "Vue 3",
85
+ packageName: "@mushi-mushi/vue",
86
+ needsWebPackage: true,
87
+ snippet: (apiKey, projectId) => `// src/main.ts
88
+ import { MushiPlugin } from '@mushi-mushi/vue'
89
+ import { Mushi } from '@mushi-mushi/web'
90
+
91
+ app.use(MushiPlugin, { projectId: '${projectId}', apiKey: '${apiKey}' })
92
+ Mushi.init({ projectId: '${projectId}', apiKey: '${apiKey}' })`
93
+ },
94
+ nuxt: {
95
+ id: "nuxt",
96
+ label: "Nuxt",
97
+ packageName: "@mushi-mushi/vue",
98
+ needsWebPackage: true,
99
+ snippet: (apiKey, projectId) => `// plugins/mushi.client.ts
100
+ import { MushiPlugin } from '@mushi-mushi/vue'
101
+ import { Mushi } from '@mushi-mushi/web'
102
+
103
+ export default defineNuxtPlugin((nuxtApp) => {
104
+ nuxtApp.vueApp.use(MushiPlugin, {
105
+ projectId: '${projectId}',
106
+ apiKey: '${apiKey}',
107
+ })
108
+ Mushi.init({ projectId: '${projectId}', apiKey: '${apiKey}' })
109
+ })`
110
+ },
111
+ svelte: {
112
+ id: "svelte",
113
+ label: "Svelte",
114
+ packageName: "@mushi-mushi/svelte",
115
+ needsWebPackage: true,
116
+ snippet: (apiKey, projectId) => `// src/main.ts (or +layout.svelte for SvelteKit)
117
+ import { initMushi } from '@mushi-mushi/svelte'
118
+ import { Mushi } from '@mushi-mushi/web'
119
+
120
+ initMushi({ projectId: '${projectId}', apiKey: '${apiKey}' })
121
+ Mushi.init({ projectId: '${projectId}', apiKey: '${apiKey}' })`
122
+ },
123
+ sveltekit: {
124
+ id: "sveltekit",
125
+ label: "SvelteKit",
126
+ packageName: "@mushi-mushi/svelte",
127
+ needsWebPackage: true,
128
+ snippet: (apiKey, projectId) => `// src/routes/+layout.svelte
129
+ <script>
130
+ import { onMount } from 'svelte'
131
+ import { initMushi } from '@mushi-mushi/svelte'
132
+
133
+ onMount(async () => {
134
+ const { Mushi } = await import('@mushi-mushi/web')
135
+ initMushi({ projectId: '${projectId}', apiKey: '${apiKey}' })
136
+ Mushi.init({ projectId: '${projectId}', apiKey: '${apiKey}' })
137
+ })
138
+ </script>`
139
+ },
140
+ angular: {
141
+ id: "angular",
142
+ label: "Angular",
143
+ packageName: "@mushi-mushi/angular",
144
+ needsWebPackage: true,
145
+ snippet: (apiKey, projectId) => `// src/main.ts
146
+ import { provideMushi } from '@mushi-mushi/angular'
147
+ import { Mushi } from '@mushi-mushi/web'
148
+
149
+ bootstrapApplication(AppComponent, {
150
+ providers: [
151
+ provideMushi({ projectId: '${projectId}', apiKey: '${apiKey}' }),
152
+ ],
153
+ })
154
+ Mushi.init({ projectId: '${projectId}', apiKey: '${apiKey}' })`
155
+ },
156
+ expo: {
157
+ id: "expo",
158
+ label: "Expo",
159
+ packageName: "@mushi-mushi/react-native",
160
+ needsWebPackage: false,
161
+ snippet: (apiKey, projectId) => `// App.tsx
162
+ import { MushiProvider } from '@mushi-mushi/react-native'
163
+
164
+ export default function App() {
165
+ return (
166
+ <MushiProvider projectId="${projectId}" apiKey="${apiKey}">
167
+ <YourApp />
168
+ </MushiProvider>
169
+ )
170
+ }`
171
+ },
172
+ "react-native": {
173
+ id: "react-native",
174
+ label: "React Native",
175
+ packageName: "@mushi-mushi/react-native",
176
+ needsWebPackage: false,
177
+ snippet: (apiKey, projectId) => `// App.tsx
178
+ import { MushiProvider } from '@mushi-mushi/react-native'
179
+
180
+ export default function App() {
181
+ return (
182
+ <MushiProvider projectId="${projectId}" apiKey="${apiKey}">
183
+ <YourApp />
184
+ </MushiProvider>
185
+ )
186
+ }`
187
+ },
188
+ capacitor: {
189
+ id: "capacitor",
190
+ label: "Capacitor (Ionic)",
191
+ packageName: "@mushi-mushi/capacitor",
192
+ needsWebPackage: false,
193
+ snippet: (apiKey, projectId) => `// src/main.ts
194
+ import { Mushi } from '@mushi-mushi/capacitor'
195
+
196
+ Mushi.init({ projectId: '${projectId}', apiKey: '${apiKey}' })`
197
+ },
198
+ vanilla: {
199
+ id: "vanilla",
200
+ label: "Vanilla JS / unknown",
201
+ packageName: "@mushi-mushi/web",
202
+ needsWebPackage: false,
203
+ snippet: (apiKey, projectId) => `// Anywhere in your client bundle
204
+ import { Mushi } from '@mushi-mushi/web'
205
+
206
+ Mushi.init({ projectId: '${projectId}', apiKey: '${apiKey}' })`
207
+ }
208
+ };
209
+ function readPackageJson(cwd) {
210
+ const path = join2(cwd, "package.json");
211
+ if (!existsSync2(path)) return null;
212
+ try {
213
+ return JSON.parse(readFileSync2(path, "utf-8"));
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+ function detectFramework(cwd, pkg) {
219
+ const deps = collectDeps(pkg);
220
+ if (deps.has("next")) return FRAMEWORKS.next;
221
+ if (deps.has("nuxt")) return FRAMEWORKS.nuxt;
222
+ if (deps.has("@sveltejs/kit")) return FRAMEWORKS.sveltekit;
223
+ if (deps.has("@angular/core")) return FRAMEWORKS.angular;
224
+ if (deps.has("expo")) return FRAMEWORKS.expo;
225
+ if (deps.has("react-native")) return FRAMEWORKS["react-native"];
226
+ if (deps.has("@capacitor/core")) return FRAMEWORKS.capacitor;
227
+ if (deps.has("svelte")) return FRAMEWORKS.svelte;
228
+ if (deps.has("vue")) return FRAMEWORKS.vue;
229
+ if (deps.has("react")) return FRAMEWORKS.react;
230
+ if (existsSync2(join2(cwd, "next.config.js")) || existsSync2(join2(cwd, "next.config.ts"))) {
231
+ return FRAMEWORKS.next;
232
+ }
233
+ if (existsSync2(join2(cwd, "nuxt.config.ts")) || existsSync2(join2(cwd, "nuxt.config.js"))) {
234
+ return FRAMEWORKS.nuxt;
235
+ }
236
+ if (existsSync2(join2(cwd, "svelte.config.js"))) return FRAMEWORKS.svelte;
237
+ if (existsSync2(join2(cwd, "angular.json"))) return FRAMEWORKS.angular;
238
+ return FRAMEWORKS.vanilla;
239
+ }
240
+ function detectPackageManager(cwd) {
241
+ if (existsSync2(join2(cwd, "bun.lockb")) || existsSync2(join2(cwd, "bun.lock"))) return "bun";
242
+ if (existsSync2(join2(cwd, "pnpm-lock.yaml"))) return "pnpm";
243
+ if (existsSync2(join2(cwd, "yarn.lock"))) return "yarn";
244
+ if (existsSync2(join2(cwd, "package-lock.json"))) return "npm";
245
+ const userAgent = process.env.npm_config_user_agent ?? "";
246
+ if (userAgent.startsWith("bun")) return "bun";
247
+ if (userAgent.startsWith("pnpm")) return "pnpm";
248
+ if (userAgent.startsWith("yarn")) return "yarn";
249
+ return "npm";
250
+ }
251
+ function installCommand(pm, packages) {
252
+ const verb = pm === "npm" ? "install" : "add";
253
+ return `${pm} ${verb} ${packages.join(" ")}`;
254
+ }
255
+ function envVarsToWrite(apiKey, projectId, framework) {
256
+ const prefix = framework.id === "next" ? "NEXT_PUBLIC_" : framework.id === "nuxt" ? "NUXT_PUBLIC_" : "VITE_";
257
+ return [
258
+ `${prefix}MUSHI_PROJECT_ID=${projectId}`,
259
+ `${prefix}MUSHI_API_KEY=${apiKey}`
260
+ ].join("\n");
261
+ }
262
+ function collectDeps(pkg) {
263
+ if (!pkg) return /* @__PURE__ */ new Set();
264
+ return /* @__PURE__ */ new Set([
265
+ ...Object.keys(pkg.dependencies ?? {}),
266
+ ...Object.keys(pkg.devDependencies ?? {}),
267
+ ...Object.keys(pkg.peerDependencies ?? {})
268
+ ]);
269
+ }
270
+
271
+ // src/endpoint.ts
272
+ var DEFAULT_ENDPOINT = "https://api.mushimushi.dev";
273
+ var TEST_REPORT_TIMEOUT_MS = 1e4;
274
+ var TEST_REPORT_FETCH_TIMEOUT_MS = TEST_REPORT_TIMEOUT_MS;
275
+ function assertEndpoint(url) {
276
+ let parsed;
277
+ try {
278
+ parsed = new URL(url);
279
+ } catch {
280
+ throw new Error(`Invalid endpoint URL: ${url}`);
281
+ }
282
+ const host = parsed.hostname;
283
+ const isLocal = host === "localhost" || host === "127.0.0.1" || host === "::1" || host.endsWith(".local");
284
+ if (parsed.protocol !== "https:" && !isLocal) {
285
+ throw new Error(`Endpoint must use https:// (got ${parsed.protocol}//${host}).`);
286
+ }
287
+ return parsed.origin + (parsed.pathname === "/" ? "" : parsed.pathname);
288
+ }
289
+ function normalizeEndpoint(url) {
290
+ const input = url ?? DEFAULT_ENDPOINT;
291
+ let end = input.length;
292
+ while (end > 0 && input.charCodeAt(end - 1) === 47) end--;
293
+ return input.slice(0, end);
294
+ }
295
+
296
+ // src/freshness.ts
297
+ var REGISTRY = "https://registry.npmjs.org";
298
+ var DEFAULT_TIMEOUT_MS = 2e3;
299
+ async function checkFreshness(packageName, currentVersion, opts = {}) {
300
+ if (process.env.MUSHI_NO_UPDATE_CHECK === "1") return null;
301
+ const registry = opts.registry ?? REGISTRY;
302
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
303
+ const controller = new AbortController();
304
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
305
+ try {
306
+ const res = await fetch(
307
+ `${registry}/${encodeURIComponent(packageName)}/latest`,
308
+ {
309
+ signal: controller.signal,
310
+ headers: { Accept: "application/json" }
311
+ }
312
+ );
313
+ if (!res.ok) return null;
314
+ const body = await res.json();
315
+ const latest = typeof body.version === "string" ? body.version : null;
316
+ if (!latest) return null;
317
+ return {
318
+ current: currentVersion,
319
+ latest,
320
+ isOutdated: isNewerStableVersion(latest, currentVersion)
321
+ };
322
+ } catch {
323
+ return null;
324
+ } finally {
325
+ clearTimeout(timer);
326
+ }
327
+ }
328
+ function isNewerStableVersion(latest, current) {
329
+ const latestCore = stripPreRelease(latest);
330
+ if (hasPreReleaseTag(latest)) return false;
331
+ const [la, lb, lc] = parse(latestCore);
332
+ const [ca, cb, cc] = parse(stripPreRelease(current));
333
+ if (la !== ca) return la > ca;
334
+ if (lb !== cb) return lb > cb;
335
+ return lc > cc;
336
+ }
337
+ function stripPreRelease(version) {
338
+ const idx = version.indexOf("-");
339
+ return idx === -1 ? version : version.slice(0, idx);
340
+ }
341
+ function hasPreReleaseTag(version) {
342
+ return version.includes("-");
343
+ }
344
+ function parse(version) {
345
+ const parts = version.split(".").map((part) => Number(part));
346
+ return [
347
+ Number.isFinite(parts[0]) ? parts[0] : 0,
348
+ Number.isFinite(parts[1]) ? parts[1] : 0,
349
+ Number.isFinite(parts[2]) ? parts[2] : 0
350
+ ];
351
+ }
352
+
353
+ // src/monorepo.ts
354
+ import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync, statSync as statSync2 } from "fs";
355
+ import { dirname, join as join3, resolve } from "path";
356
+ var WORKSPACE_GLOB_CANDIDATES = ["apps/*", "packages/*", "examples/*"];
357
+ var FRAMEWORK_DEPS = {
358
+ next: "Next.js",
359
+ nuxt: "Nuxt",
360
+ "@sveltejs/kit": "SvelteKit",
361
+ "@angular/core": "Angular",
362
+ expo: "Expo",
363
+ "react-native": "React Native",
364
+ "@capacitor/core": "Capacitor",
365
+ svelte: "Svelte",
366
+ vue: "Vue",
367
+ react: "React"
368
+ };
369
+ function detectWorkspaceHint(cwd) {
370
+ const root = findWorkspaceRoot(cwd);
371
+ if (!root) return null;
372
+ const rootPkg = readPackageJsonSafely(join3(root, "package.json"));
373
+ if (rootPkg && getFrameworkFromPkg(rootPkg)) return null;
374
+ const source = existsSync3(join3(root, "pnpm-workspace.yaml")) ? "pnpm-workspace" : root === cwd ? "package-json" : "parent";
375
+ const apps = collectAppsFromGlobs(root);
376
+ if (apps.length === 0) return null;
377
+ return { root, apps, source };
378
+ }
379
+ function findWorkspaceRoot(start) {
380
+ let dir = resolve(start);
381
+ for (let i = 0; i < 8; i++) {
382
+ if (isWorkspaceRoot(dir)) return dir;
383
+ const parent = dirname(dir);
384
+ if (parent === dir) break;
385
+ dir = parent;
386
+ }
387
+ return null;
388
+ }
389
+ function isWorkspaceRoot(dir) {
390
+ if (existsSync3(join3(dir, "pnpm-workspace.yaml"))) return true;
391
+ const pkg = readPackageJsonSafely(join3(dir, "package.json"));
392
+ if (!pkg) return false;
393
+ return Boolean(pkg.workspaces);
394
+ }
395
+ function collectAppsFromGlobs(root) {
396
+ const results = [];
397
+ for (const glob of WORKSPACE_GLOB_CANDIDATES) {
398
+ const prefix = glob.replace("/*", "");
399
+ const parentDir = join3(root, prefix);
400
+ if (!existsSync3(parentDir)) continue;
401
+ let entries;
402
+ try {
403
+ entries = readdirSync(parentDir);
404
+ } catch {
405
+ continue;
406
+ }
407
+ for (const entry of entries) {
408
+ const pkgPath = join3(parentDir, entry, "package.json");
409
+ if (!isFileSafe(pkgPath)) continue;
410
+ const pkg = readPackageJsonSafely(pkgPath);
411
+ if (!pkg) continue;
412
+ const framework = getFrameworkFromPkg(pkg);
413
+ if (!framework) continue;
414
+ results.push({
415
+ name: pkg.name ?? `${prefix}/${entry}`,
416
+ relativePath: `${prefix}/${entry}`,
417
+ framework
418
+ });
419
+ }
420
+ }
421
+ return results;
422
+ }
423
+ function readPackageJsonSafely(path) {
424
+ if (!isFileSafe(path)) return null;
425
+ try {
426
+ return JSON.parse(readFileSync3(path, "utf-8"));
427
+ } catch {
428
+ return null;
429
+ }
430
+ }
431
+ function isFileSafe(path) {
432
+ try {
433
+ return existsSync3(path) && statSync2(path).isFile();
434
+ } catch {
435
+ return false;
436
+ }
437
+ }
438
+ function getFrameworkFromPkg(pkg) {
439
+ const deps = {
440
+ ...pkg.dependencies ?? {},
441
+ ...pkg.devDependencies ?? {},
442
+ ...pkg.peerDependencies ?? {}
443
+ };
444
+ for (const dep of Object.keys(FRAMEWORK_DEPS)) {
445
+ if (dep in deps) return FRAMEWORK_DEPS[dep];
446
+ }
447
+ return void 0;
448
+ }
449
+
450
+ // src/version.ts
451
+ var MUSHI_CLI_VERSION = true ? "0.5.0" : "0.0.0-dev";
452
+
453
+ // src/init.ts
454
+ var ENV_FILES = [".env.local", ".env"];
455
+ var PROJECT_ID_PATTERN = /^proj_[A-Za-z0-9_-]{10,}$/;
456
+ var API_KEY_PATTERN = /^mushi_[A-Za-z0-9_-]{10,}$/;
457
+ async function runInit(options = {}) {
458
+ const cwd = options.cwd ?? process.cwd();
459
+ ensureInteractiveOrBailOut(options);
460
+ p.intro("\u{1F41B} Mushi Mushi setup wizard");
461
+ await printFreshnessHint();
462
+ warnIfWorkspaceRoot(cwd);
463
+ const pkg = readPackageJson(cwd);
464
+ if (!pkg) {
465
+ p.log.warn("No package.json found in this directory.");
466
+ const cont = await p.confirm({
467
+ message: "Continue anyway? (Mushi will install into the current folder)",
468
+ initialValue: false
469
+ });
470
+ if (p.isCancel(cont) || !cont) {
471
+ p.cancel("Aborted. Run from your project root and try again.");
472
+ process.exit(0);
473
+ }
474
+ }
475
+ const detected = detectFramework(cwd, pkg);
476
+ const framework = await chooseFramework(detected, options);
477
+ const credentials = await collectCredentials(options);
478
+ const pm = detectPackageManager(cwd);
479
+ const packagesToInstall = framework.needsWebPackage ? [framework.packageName, "@mushi-mushi/web"] : [framework.packageName];
480
+ if (!options.skipInstall) {
481
+ await installPackages(pm, packagesToInstall, cwd);
482
+ } else {
483
+ p.log.info(`Skipped install. Run \`${installCommand(pm, packagesToInstall)}\` yourself.`);
484
+ }
485
+ writeEnvFile(cwd, credentials.apiKey, credentials.projectId, framework);
486
+ persistCliConfig(credentials.apiKey, credentials.projectId);
487
+ printNextSteps(framework, credentials.apiKey, credentials.projectId);
488
+ await maybeSendTestReport(credentials, options);
489
+ p.outro("Setup complete. Happy bug squashing \u{1F41B}");
490
+ }
491
+ function ensureInteractiveOrBailOut(options) {
492
+ const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY);
493
+ if (isTTY) return;
494
+ const hasAllFlags = Boolean(
495
+ (options.framework || options.yes) && options.projectId && options.apiKey
496
+ );
497
+ if (hasAllFlags) return;
498
+ process.stderr.write(
499
+ "mushi-mushi: non-interactive terminal detected.\nPass all of --yes (or --framework), --project-id, and --api-key to run unattended.\nExample: npx mushi-mushi --yes --project-id proj_xxx --api-key mushi_xxx\n"
500
+ );
501
+ process.exit(1);
502
+ }
503
+ async function chooseFramework(detected, options) {
504
+ if (options.framework) {
505
+ const explicit = FRAMEWORKS[options.framework];
506
+ if (!explicit) throw new Error(`Unknown framework: ${options.framework}`);
507
+ p.log.step(`Using framework: ${explicit.label} (from --framework)`);
508
+ return explicit;
509
+ }
510
+ if (options.yes) {
511
+ p.log.step(`Detected ${detected.label} \u2192 installing ${detected.packageName}`);
512
+ return detected;
513
+ }
514
+ const confirmed = await p.select({
515
+ message: `Detected ${detected.label}. Use this?`,
516
+ initialValue: detected.id,
517
+ options: Object.values(FRAMEWORKS).map((fw) => ({
518
+ value: fw.id,
519
+ label: `${fw.id === detected.id ? "\u2713 " : " "}${fw.label}`,
520
+ hint: fw.packageName
521
+ }))
522
+ });
523
+ if (p.isCancel(confirmed)) {
524
+ p.cancel("Aborted.");
525
+ process.exit(0);
526
+ }
527
+ return FRAMEWORKS[confirmed];
528
+ }
529
+ async function collectCredentials(options) {
530
+ const existing = loadConfig();
531
+ const rawProjectId = options.projectId ?? existing.projectId ?? await promptText({
532
+ message: "Project ID",
533
+ placeholder: "proj_xxxxxxxxxxxx",
534
+ hint: "Find this at https://kensaur.us/mushi-mushi/projects",
535
+ validate: (v) => PROJECT_ID_PATTERN.test(v) ? void 0 : "Expected format: proj_ followed by 10+ alphanumeric characters"
536
+ });
537
+ const rawApiKey = options.apiKey ?? existing.apiKey ?? await promptText({
538
+ message: "API key",
539
+ placeholder: "mushi_xxxxxxxxxxxx",
540
+ hint: "Treat this like a password \u2014 it goes in your env file, not in source.",
541
+ validate: (v) => API_KEY_PATTERN.test(v) ? void 0 : "Expected format: mushi_ followed by 10+ alphanumeric characters"
542
+ });
543
+ const projectId = sanitizeSecret(rawProjectId);
544
+ const apiKey = sanitizeSecret(rawApiKey);
545
+ if (!PROJECT_ID_PATTERN.test(projectId)) {
546
+ throw new Error(
547
+ `Invalid project ID. Expected format: proj_[A-Za-z0-9_-]{10,}. Got: ${redact(projectId)}`
548
+ );
549
+ }
550
+ if (!API_KEY_PATTERN.test(apiKey)) {
551
+ throw new Error(
552
+ `Invalid API key. Expected format: mushi_[A-Za-z0-9_-]{10,}. Got: ${redact(apiKey)}`
553
+ );
554
+ }
555
+ return { projectId, apiKey };
556
+ }
557
+ function sanitizeSecret(raw) {
558
+ return raw.trim().replace(/^['"]|['"]$/g, "").replace(/[\r\n\0]/g, "");
559
+ }
560
+ function redact(value) {
561
+ if (value.length <= 8) return "***";
562
+ return `${value.slice(0, 4)}\u2026${value.slice(-2)}`;
563
+ }
564
+ async function promptText(opts) {
565
+ const value = await p.text({
566
+ message: opts.message,
567
+ placeholder: opts.placeholder,
568
+ validate: (v) => {
569
+ const clean = sanitizeSecret(v);
570
+ if (clean.length === 0) return "Required";
571
+ return opts.validate ? opts.validate(clean) : void 0;
572
+ }
573
+ });
574
+ if (p.isCancel(value)) {
575
+ p.cancel("Aborted.");
576
+ process.exit(0);
577
+ }
578
+ if (opts.hint) p.log.info(opts.hint);
579
+ return value;
580
+ }
581
+ async function installPackages(pm, packages, cwd) {
582
+ const command = installCommand(pm, packages);
583
+ const spinner2 = p.spinner();
584
+ spinner2.start(`Installing ${packages.join(", ")} via ${pm}\u2026`);
585
+ try {
586
+ await runCommand(pm, packages, cwd);
587
+ spinner2.stop(`Installed ${packages.join(", ")}`);
588
+ } catch (err) {
589
+ spinner2.stop(`Install failed \u2014 run \`${command}\` manually.`);
590
+ p.log.error(err instanceof Error ? err.name + ": " + err.message : String(err));
591
+ }
592
+ }
593
+ function runCommand(pm, packages, cwd) {
594
+ const verb = pm === "npm" ? "install" : "add";
595
+ const command = process.platform === "win32" ? `${pm}.cmd` : pm;
596
+ return new Promise((resolve2, reject) => {
597
+ const child = spawn(command, [verb, ...packages], {
598
+ stdio: "inherit",
599
+ shell: false,
600
+ cwd,
601
+ env: process.env
602
+ });
603
+ child.on("error", reject);
604
+ child.on("exit", (code) => {
605
+ if (code === 0) resolve2();
606
+ else reject(new Error(`${pm} exited with code ${code ?? "null"}`));
607
+ });
608
+ });
609
+ }
610
+ function writeEnvFile(cwd, apiKey, projectId, framework) {
611
+ const target = ENV_FILES.find((f) => existsSync4(join4(cwd, f))) ?? ENV_FILES[0];
612
+ const targetPath = join4(cwd, target);
613
+ const newVars = envVarsToWrite(apiKey, projectId, framework);
614
+ const existing = existsSync4(targetPath) ? readFileSync4(targetPath, "utf-8") : "";
615
+ if (existing.includes("MUSHI_PROJECT_ID")) {
616
+ p.log.warn(`Existing MUSHI_* vars found in ${target} \u2014 leaving them untouched.`);
617
+ return;
618
+ }
619
+ const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
620
+ appendFileSync(targetPath, `${prefix}
621
+ # Mushi Mushi
622
+ ${newVars}
623
+ `);
624
+ p.log.success(`Wrote env vars to ${target}`);
625
+ warnIfMissingFromGitignore(cwd, target);
626
+ }
627
+ function isEnvFileCoveredByGitignore(gitignoreContent, envFile) {
628
+ const lines = gitignoreContent.split("\n").map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#"));
629
+ let covered = false;
630
+ for (const line of lines) {
631
+ if (line.startsWith("!")) {
632
+ if (matchesGitignorePattern(line.slice(1), envFile)) covered = false;
633
+ continue;
634
+ }
635
+ if (matchesGitignorePattern(line, envFile)) covered = true;
636
+ }
637
+ return covered;
638
+ }
639
+ function matchesGitignorePattern(pattern, filename) {
640
+ if (pattern.endsWith("/")) return false;
641
+ const normalized = pattern.startsWith("/") ? pattern.slice(1) : pattern;
642
+ const regexSource = normalized.split("").map((ch) => ch === "*" ? "[^/]*" : escapeRegexChar(ch)).join("");
643
+ return new RegExp(`^${regexSource}$`).test(filename);
644
+ }
645
+ function escapeRegexChar(ch) {
646
+ return /[-/\\^$+?.()|[\]{}]/.test(ch) ? `\\${ch}` : ch;
647
+ }
648
+ function warnIfMissingFromGitignore(cwd, envFile) {
649
+ const gitignorePath = join4(cwd, ".gitignore");
650
+ if (!existsSync4(gitignorePath)) {
651
+ p.log.warn(`No .gitignore found \u2014 make sure ${envFile} is not committed.`);
652
+ return;
653
+ }
654
+ const content = readFileSync4(gitignorePath, "utf-8");
655
+ if (!isEnvFileCoveredByGitignore(content, envFile)) {
656
+ p.log.warn(`${envFile} is not in .gitignore \u2014 add it before committing.`);
657
+ }
658
+ }
659
+ function persistCliConfig(apiKey, projectId) {
660
+ const existing = loadConfig();
661
+ saveConfig({ ...existing, apiKey, projectId });
662
+ }
663
+ function printNextSteps(framework, apiKey, projectId) {
664
+ p.note(framework.snippet(apiKey, projectId), "Add this to your app:");
665
+ p.log.message("Verify the install:");
666
+ p.log.message(" \u2022 Start your dev server");
667
+ p.log.message(" \u2022 Look for the \u{1F41B} button in the bottom-right corner (or shake on mobile)");
668
+ p.log.message(" \u2022 Submit a test report \u2014 it should appear at https://kensaur.us/mushi-mushi/reports");
669
+ }
670
+ async function maybeSendTestReport(credentials, options) {
671
+ if (options.sendTestReport === false) return;
672
+ let shouldSend;
673
+ if (options.sendTestReport === true || options.yes) {
674
+ shouldSend = true;
675
+ } else {
676
+ const answer = await p.confirm({
677
+ message: "Send a test report now to verify the pipeline?",
678
+ initialValue: true
679
+ });
680
+ if (p.isCancel(answer)) return;
681
+ shouldSend = answer;
682
+ }
683
+ if (!shouldSend) return;
684
+ const spinner2 = p.spinner();
685
+ spinner2.start("Sending test report\u2026");
686
+ const endpoint = normalizeEndpoint(options.endpoint);
687
+ const controller = new AbortController();
688
+ const timer = setTimeout(() => controller.abort(), TEST_REPORT_FETCH_TIMEOUT_MS);
689
+ try {
690
+ const res = await fetch(`${endpoint}/v1/reports`, {
691
+ method: "POST",
692
+ signal: controller.signal,
693
+ headers: {
694
+ "Content-Type": "application/json",
695
+ "X-Mushi-Api-Key": credentials.apiKey,
696
+ "X-Mushi-Project": credentials.projectId
697
+ },
698
+ body: JSON.stringify({
699
+ projectId: credentials.projectId,
700
+ description: "Test report from the mushi-mushi setup wizard",
701
+ category: "other",
702
+ reporterToken: `wizard-${randomUUID()}`,
703
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
704
+ environment: {
705
+ url: "cli://wizard",
706
+ userAgent: `mushi-wizard/${process.platform}-${process.arch}`,
707
+ platform: process.platform,
708
+ language: "en",
709
+ viewport: { width: 0, height: 0 },
710
+ referrer: "",
711
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
712
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone
713
+ }
714
+ })
715
+ });
716
+ if (!res.ok) {
717
+ spinner2.stop(`Test report rejected (HTTP ${res.status}).`);
718
+ p.log.warn(
719
+ res.status === 401 || res.status === 403 ? "Credentials did not authenticate \u2014 double-check the project ID and API key." : "Skipping test report. You can retry with `mushi test`."
720
+ );
721
+ return;
722
+ }
723
+ spinner2.stop("Test report sent.");
724
+ p.log.success("View it at https://kensaur.us/mushi-mushi/reports");
725
+ } catch (err) {
726
+ const aborted = err instanceof Error && err.name === "AbortError";
727
+ spinner2.stop(aborted ? "Timed out reaching the Mushi API." : "Could not reach the Mushi API.");
728
+ p.log.warn(err instanceof Error ? err.message : String(err));
729
+ } finally {
730
+ clearTimeout(timer);
731
+ }
732
+ }
733
+ async function printFreshnessHint() {
734
+ const result = await checkFreshness("mushi-mushi", MUSHI_CLI_VERSION);
735
+ if (!result || !result.isOutdated) return;
736
+ p.log.info(
737
+ `A newer version of mushi-mushi is available: ${result.current} \u2192 ${result.latest}. Run \`npx mushi-mushi@latest\` to get the freshest wizard.`
738
+ );
739
+ }
740
+ function warnIfWorkspaceRoot(cwd) {
741
+ let hint;
742
+ try {
743
+ hint = detectWorkspaceHint(cwd);
744
+ } catch {
745
+ return;
746
+ }
747
+ if (!hint || hint.apps.length === 0) return;
748
+ const hasFrameworkAtCwd = hint.apps.some(
749
+ (app) => isSameDirectory(cwd, resolveWorkspaceAppPath(hint.root, app.relativePath))
750
+ );
751
+ if (hasFrameworkAtCwd) return;
752
+ const apps = hint.apps.slice(0, 5).map((app) => ` \u2022 ${app.relativePath} (${app.framework})`).join("\n");
753
+ p.log.warn(
754
+ `You appear to be at a workspace root (source: ${hint.source}). Mushi will install into the current directory, which has no framework dep. You probably meant one of these sub-packages:
755
+ ${apps}
756
+ Run \`mushi init --cwd <path>\` \u2014 or re-run the wizard from inside that package.`
757
+ );
758
+ }
759
+ function resolveWorkspaceAppPath(root, relativePath) {
760
+ return `${root}/${relativePath}`.replace(/\\/g, "/");
761
+ }
762
+ function isSameDirectory(a, b) {
763
+ return a.replace(/\\/g, "/").replace(/\/+$/, "") === b.replace(/\\/g, "/").replace(/\/+$/, "");
21
764
  }
22
765
 
23
766
  // src/index.ts
24
767
  async function apiCall(path, config, options = {}) {
25
- const endpoint = config.endpoint ?? "https://api.mushimushi.dev";
768
+ const endpoint = config.endpoint ?? DEFAULT_ENDPOINT;
26
769
  const res = await fetch(`${endpoint}${path}`, {
27
770
  ...options,
28
771
  headers: {
@@ -35,14 +778,26 @@ async function apiCall(path, config, options = {}) {
35
778
  });
36
779
  return res.json();
37
780
  }
38
- var program = new Command().name("mushi").description("Mushi Mushi CLI \u2014 manage bug reports and pipeline").version("0.0.1");
781
+ var program = new Command().name("mushi").description("Mushi Mushi CLI \u2014 set up the SDK, manage bug reports, monitor pipeline").version(MUSHI_CLI_VERSION);
782
+ program.command("init").description("Set up the Mushi Mushi SDK in this project (auto-detects framework)").option("--project-id <id>", "Skip the prompt by passing the project ID").option("--api-key <key>", "Skip the prompt by passing the API key").option("--framework <id>", "Force a framework (next, react, vue, nuxt, svelte, sveltekit, angular, expo, react-native, capacitor, vanilla)").option("--skip-install", "Don't auto-install the SDK package \u2014 print the command instead").option("-y, --yes", "Accept detected framework without prompting").option("--cwd <path>", "Run the wizard in a different directory").option("--endpoint <url>", "Override the Mushi API endpoint (self-hosted)").option("--skip-test-report", 'Skip the end-of-wizard "send a test report" prompt').action(async (opts) => {
783
+ await runInit({
784
+ projectId: opts.projectId,
785
+ apiKey: opts.apiKey,
786
+ framework: opts.framework,
787
+ skipInstall: opts.skipInstall,
788
+ yes: opts.yes,
789
+ cwd: opts.cwd,
790
+ endpoint: opts.endpoint,
791
+ sendTestReport: opts.skipTestReport ? false : void 0
792
+ });
793
+ });
39
794
  program.command("login").description("Store API key for authentication").requiredOption("--api-key <key>", "API key").option("--endpoint <url>", "API endpoint URL").option("--project-id <id>", "Default project ID").action((opts) => {
40
795
  const config = loadConfig();
41
796
  config.apiKey = opts.apiKey;
42
- if (opts.endpoint) config.endpoint = opts.endpoint;
797
+ if (opts.endpoint) config.endpoint = assertEndpoint(opts.endpoint);
43
798
  if (opts.projectId) config.projectId = opts.projectId;
44
799
  saveConfig(config);
45
- console.log("Saved credentials to ~/.mushirc");
800
+ console.log("Saved credentials to ~/.mushirc (mode 0o600)");
46
801
  });
47
802
  program.command("status").description("Show project stats").action(async () => {
48
803
  const config = loadConfig();
@@ -97,10 +852,10 @@ reports.command("triage <id>").description("Update report status/severity").opti
97
852
  program.command("config").description("View or update CLI config").argument("[key]", "Config key to set").argument("[value]", "Value").action((key, value) => {
98
853
  const config = loadConfig();
99
854
  if (key && value) {
100
- ;
101
- config[key] = value;
855
+ const safeValue = key === "endpoint" ? assertEndpoint(value) : value;
856
+ config[key] = safeValue;
102
857
  saveConfig(config);
103
- console.log(`Set ${key} = ${value}`);
858
+ console.log(`Set ${key} = ${safeValue}`);
104
859
  } else {
105
860
  console.log(JSON.stringify(config, null, 2));
106
861
  }