@mushi-mushi/cli 0.26.0 → 0.27.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.
@@ -1,12 +1,55 @@
1
1
  import {
2
2
  MUSHI_CLI_VERSION
3
- } from "./chunk-MCVRTQAI.js";
3
+ } from "./chunk-LFZ5ZIBI.js";
4
+ import {
5
+ __require
6
+ } from "./chunk-3RG5ZIWI.js";
4
7
 
5
8
  // src/config.ts
6
9
  import { randomUUID } from "crypto";
10
+ import { spawnSync } from "child_process";
7
11
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "fs";
8
12
  import { homedir } from "os";
9
13
  import { dirname, join } from "path";
14
+
15
+ // src/keychain.ts
16
+ var KEYCHAIN_SERVICE = "mushi-cli";
17
+ function accountName(profile = "default") {
18
+ return `api-key:${profile}`;
19
+ }
20
+ var _EntryClass = void 0;
21
+ function getEntryClass() {
22
+ if (_EntryClass === void 0) {
23
+ try {
24
+ const mod = __require("@napi-rs/keyring");
25
+ _EntryClass = mod.Entry ?? null;
26
+ } catch {
27
+ _EntryClass = null;
28
+ }
29
+ }
30
+ return _EntryClass ?? null;
31
+ }
32
+ function tryLoadKeyFromKeychain(profile = "default") {
33
+ const EntryClass = getEntryClass();
34
+ if (!EntryClass) return null;
35
+ try {
36
+ const entry = new EntryClass(KEYCHAIN_SERVICE, accountName(profile));
37
+ const key = entry.getPassword();
38
+ return key && key.length > 0 ? key : null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ // src/config.ts
45
+ var DEFAULT_PROFILE = "default";
46
+ function isMultiProfileFile(value) {
47
+ return typeof value === "object" && value !== null && "profiles" in value && typeof value.profiles === "object" && value.profiles !== null;
48
+ }
49
+ function resolveProfileName(explicit, fileActive) {
50
+ const fromEnv = process.env["MUSHI_PROFILE"]?.trim();
51
+ return explicit?.trim() || fromEnv || fileActive || DEFAULT_PROFILE;
52
+ }
10
53
  var SECURE_FILE_MODE = 384;
11
54
  var SECURE_DIR_MODE = 448;
12
55
  function resolveXdgConfigPath() {
@@ -24,17 +67,28 @@ function resolveXdgConfigPath() {
24
67
  }
25
68
  var LEGACY_CONFIG_PATH = join(homedir(), ".mushirc");
26
69
  var CONFIG_PATH = resolveXdgConfigPath();
27
- function loadConfig(path = CONFIG_PATH) {
70
+ function loadConfig(path = CONFIG_PATH, opts = {}) {
28
71
  let file = {};
29
72
  if (existsSync(path)) {
30
73
  tightenPermissions(path);
31
74
  try {
32
- file = JSON.parse(readFileSync(path, "utf-8"));
75
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
76
+ if (isMultiProfileFile(parsed)) {
77
+ const name = resolveProfileName(opts.profile, parsed.activeProfile);
78
+ file = parsed.profiles[name] ?? {};
79
+ } else {
80
+ file = parsed ?? {};
81
+ }
33
82
  } catch {
34
83
  }
35
84
  } else if (path === CONFIG_PATH && existsSync(LEGACY_CONFIG_PATH)) {
36
85
  file = migrateLegacyConfig() ?? {};
37
86
  }
87
+ const profile = resolveProfileName(opts.profile, void 0);
88
+ const keychainKey = tryLoadKeyFromKeychain(profile);
89
+ if (keychainKey) {
90
+ file = { ...file, apiKey: keychainKey };
91
+ }
38
92
  const endpointFromEnv = process.env["MUSHI_API_ENDPOINT"] ?? process.env["MUSHI_ENDPOINT"] ?? void 0;
39
93
  const fromEnv = {
40
94
  ...process.env["MUSHI_API_KEY"] ? { apiKey: process.env["MUSHI_API_KEY"] } : {},
@@ -56,14 +110,47 @@ function ensureClientId(path = CONFIG_PATH) {
56
110
  }
57
111
  return clientId;
58
112
  }
59
- function saveConfig(config, path = CONFIG_PATH) {
113
+ function maybeShowTelemetryNotice(config, path = CONFIG_PATH) {
114
+ if (config.telemetryNoticeShown) return;
115
+ if (process.env.MUSHI_NO_TELEMETRY) {
116
+ saveConfig({ ...config, telemetryNoticeShown: true }, path);
117
+ return;
118
+ }
119
+ console.log("");
120
+ console.log(" \u{1F4CA} Mushi collects minimal anonymous usage data to improve the CLI");
121
+ console.log(" (funnel step + error category \u2014 never your code or bug content).");
122
+ console.log(" To opt out: export MUSHI_NO_TELEMETRY=1");
123
+ console.log("");
124
+ saveConfig({ ...config, telemetryNoticeShown: true }, path);
125
+ }
126
+ function saveConfig(config, path = CONFIG_PATH, opts = {}) {
60
127
  const dir = dirname(path);
61
128
  if (!existsSync(dir)) {
62
129
  mkdirSync(dir, { recursive: true, mode: SECURE_DIR_MODE });
63
130
  } else {
64
131
  tightenDirPermissions(dir);
65
132
  }
66
- writeFileSync(path, JSON.stringify(config, null, 2), { mode: SECURE_FILE_MODE });
133
+ let existing = null;
134
+ try {
135
+ existing = JSON.parse(readFileSync(path, "utf-8"));
136
+ } catch {
137
+ existing = null;
138
+ }
139
+ const wantsProfile = opts.profile !== void 0 || process.env["MUSHI_PROFILE"] !== void 0 || isMultiProfileFile(existing);
140
+ if (!wantsProfile) {
141
+ writeFileSync(path, JSON.stringify(config, null, 2), { mode: SECURE_FILE_MODE });
142
+ tightenPermissions(path);
143
+ return;
144
+ }
145
+ const file = isMultiProfileFile(existing) ? { version: 2, activeProfile: existing.activeProfile || DEFAULT_PROFILE, profiles: { ...existing.profiles } } : {
146
+ version: 2,
147
+ activeProfile: DEFAULT_PROFILE,
148
+ // A pre-existing flat file becomes the default profile.
149
+ profiles: { [DEFAULT_PROFILE]: existing ?? {} }
150
+ };
151
+ const target = resolveProfileName(opts.profile, file.activeProfile);
152
+ file.profiles[target] = config;
153
+ writeFileSync(path, JSON.stringify(file, null, 2), { mode: SECURE_FILE_MODE });
67
154
  tightenPermissions(path);
68
155
  }
69
156
  function migrateLegacyConfig(legacyPath = LEGACY_CONFIG_PATH, destPath = CONFIG_PATH) {
@@ -92,7 +179,10 @@ function migrateLegacyConfig(legacyPath = LEGACY_CONFIG_PATH, destPath = CONFIG_
92
179
  return parsed;
93
180
  }
94
181
  function tightenPermissions(path) {
95
- if (process.platform === "win32") return;
182
+ if (process.platform === "win32") {
183
+ tightenWindowsAcl(path, "file");
184
+ return;
185
+ }
96
186
  try {
97
187
  const current = statSync(path).mode & 511;
98
188
  if (current !== SECURE_FILE_MODE) chmodSync(path, SECURE_FILE_MODE);
@@ -100,13 +190,40 @@ function tightenPermissions(path) {
100
190
  }
101
191
  }
102
192
  function tightenDirPermissions(path) {
103
- if (process.platform === "win32") return;
193
+ if (process.platform === "win32") {
194
+ tightenWindowsAcl(path, "dir");
195
+ return;
196
+ }
104
197
  try {
105
198
  const current = statSync(path).mode & 511;
106
199
  if (current !== SECURE_DIR_MODE) chmodSync(path, SECURE_DIR_MODE);
107
200
  } catch {
108
201
  }
109
202
  }
203
+ var _cachedWindowsUser = void 0;
204
+ var _aclTightened = /* @__PURE__ */ new Set();
205
+ function getWindowsUser() {
206
+ if (_cachedWindowsUser !== void 0) return _cachedWindowsUser;
207
+ try {
208
+ const result = spawnSync("whoami", [], { encoding: "utf8", timeout: 500 });
209
+ _cachedWindowsUser = result.stdout?.trim() || null;
210
+ } catch {
211
+ _cachedWindowsUser = null;
212
+ }
213
+ return _cachedWindowsUser;
214
+ }
215
+ function tightenWindowsAcl(path, type) {
216
+ if (_aclTightened.has(path)) return;
217
+ try {
218
+ const user = getWindowsUser();
219
+ if (!user) return;
220
+ const args = [path, "/inheritance:r", `/grant:r`, `${user}:F`, `SYSTEM:F`];
221
+ if (type === "dir") args.push("/T");
222
+ spawnSync("icacls", args, { encoding: "utf8", timeout: 1e3 });
223
+ _aclTightened.add(path);
224
+ } catch {
225
+ }
226
+ }
110
227
 
111
228
  // src/console-url.ts
112
229
  import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
@@ -415,10 +532,122 @@ async function apiCall(path, config, options = {}) {
415
532
  }
416
533
  }
417
534
 
535
+ // src/sanitize-config.ts
536
+ var PROJECT_ID_RE = /^(?:proj_[A-Za-z0-9_-]{10,}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
537
+ var API_KEY_RE = /^(mushi_|mush_pk_)[A-Za-z0-9_-]{10,}$/;
538
+ function sanitizeApiKey(raw) {
539
+ const key = raw.replace(/[\r\n\0]/g, "");
540
+ if (!API_KEY_RE.test(key)) {
541
+ throw new Error(
542
+ "Invalid API key in config \u2014 run `mushi login --api-key <key>` to refresh credentials."
543
+ );
544
+ }
545
+ return key;
546
+ }
547
+ function sanitizeProjectId(raw) {
548
+ const id = raw.trim();
549
+ if (!PROJECT_ID_RE.test(id)) {
550
+ throw new Error(
551
+ "Invalid project ID in config \u2014 expected a UUID or proj_* slug from the admin console."
552
+ );
553
+ }
554
+ return id;
555
+ }
556
+ function sanitizeEndpoint(raw) {
557
+ return assertEndpoint(normalizeEndpoint(raw));
558
+ }
559
+ function sanitizeCliCredentials(config) {
560
+ if (!config.endpoint || !config.apiKey || !config.projectId) {
561
+ throw new Error("Missing endpoint, apiKey, or projectId");
562
+ }
563
+ return {
564
+ endpoint: sanitizeEndpoint(config.endpoint),
565
+ apiKey: sanitizeApiKey(config.apiKey),
566
+ projectId: sanitizeProjectId(config.projectId)
567
+ };
568
+ }
569
+ function apiKeyHeaders(apiKey, projectId) {
570
+ const headers = {
571
+ Authorization: `Bearer ${apiKey}`,
572
+ "X-Mushi-Api-Key": apiKey
573
+ };
574
+ if (projectId) headers["X-Mushi-Project"] = projectId;
575
+ return headers;
576
+ }
577
+
578
+ // src/heartbeat-wait.ts
579
+ var IngestSetupHttpError = class extends Error {
580
+ status;
581
+ constructor(status) {
582
+ super(`ingest-setup HTTP ${status}`);
583
+ this.name = "IngestSetupHttpError";
584
+ this.status = status;
585
+ }
586
+ };
587
+ var NON_RETRYABLE_STATUSES = /* @__PURE__ */ new Set([401, 403, 404]);
588
+ async function fetchIngestSetup(config, doFetch = globalThis.fetch) {
589
+ const endpoint = sanitizeEndpoint(config.endpoint);
590
+ const apiKey = sanitizeApiKey(config.apiKey);
591
+ const projectId = config.projectId ? sanitizeProjectId(config.projectId) : void 0;
592
+ const res = await doFetch(`${endpoint}/v1/sync/ingest-setup`, {
593
+ headers: apiKeyHeaders(apiKey, projectId),
594
+ signal: AbortSignal.timeout(8e3)
595
+ });
596
+ if (!res.ok) {
597
+ if (NON_RETRYABLE_STATUSES.has(res.status)) throw new IngestSetupHttpError(res.status);
598
+ return null;
599
+ }
600
+ const body = await res.json();
601
+ return body.ok && body.data ? body.data : null;
602
+ }
603
+ async function waitForIngestReady(options) {
604
+ const doFetch = options.fetch ?? globalThis.fetch;
605
+ const maxAttempts = options.maxAttempts ?? 40;
606
+ const intervalMs = options.intervalMs ?? 3e3;
607
+ let lastPayload = null;
608
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
609
+ if (options.signal?.aborted) {
610
+ return { ok: false, payload: lastPayload, attempts: attempt - 1, reason: "aborted" };
611
+ }
612
+ try {
613
+ lastPayload = await fetchIngestSetup(
614
+ { endpoint: options.endpoint, apiKey: options.apiKey, projectId: options.projectId },
615
+ doFetch
616
+ );
617
+ if (lastPayload) {
618
+ options.onPoll?.(lastPayload, attempt);
619
+ if (lastPayload.ready) {
620
+ return { ok: true, payload: lastPayload, attempts: attempt, reason: "ready" };
621
+ }
622
+ const sdkStep = lastPayload.steps.find((s) => s.id === "sdk_installed");
623
+ if (sdkStep?.complete) {
624
+ return { ok: true, payload: lastPayload, attempts: attempt, reason: "heartbeat" };
625
+ }
626
+ }
627
+ } catch (err) {
628
+ const msg = err instanceof Error ? err.message : String(err);
629
+ if (err instanceof IngestSetupHttpError) {
630
+ return { ok: false, payload: lastPayload, attempts: attempt, reason: "unauthorized", error: msg };
631
+ }
632
+ if (options.signal?.aborted) {
633
+ return { ok: false, payload: lastPayload, attempts: attempt, reason: "aborted", error: msg };
634
+ }
635
+ if (attempt === maxAttempts) {
636
+ return { ok: false, payload: lastPayload, attempts: attempt, reason: "fetch-error", error: msg };
637
+ }
638
+ }
639
+ if (attempt < maxAttempts) {
640
+ await new Promise((r) => setTimeout(r, intervalMs));
641
+ }
642
+ }
643
+ return { ok: false, payload: lastPayload, attempts: maxAttempts, reason: "timeout" };
644
+ }
645
+
418
646
  export {
419
647
  CONFIG_PATH,
420
648
  loadConfig,
421
649
  ensureClientId,
650
+ maybeShowTelemetryNotice,
422
651
  saveConfig,
423
652
  consoleUrl,
424
653
  resolveConsoleUrlSync,
@@ -433,5 +662,10 @@ export {
433
662
  assertEndpoint,
434
663
  normalizeEndpoint,
435
664
  requireUuid,
436
- apiCall
665
+ apiCall,
666
+ sanitizeEndpoint,
667
+ sanitizeCliCredentials,
668
+ apiKeyHeaders,
669
+ fetchIngestSetup,
670
+ waitForIngestReady
437
671
  };
@@ -1,122 +1,24 @@
1
1
  import {
2
2
  CONFIG_PATH,
3
3
  assertEndpoint,
4
- normalizeEndpoint,
5
4
  requireUuid,
6
- saveConfig
7
- } from "./chunk-L3LUPBFG.js";
8
- import "./chunk-MCVRTQAI.js";
5
+ saveConfig,
6
+ waitForIngestReady
7
+ } from "./chunk-TZL3F3GT.js";
8
+ import {
9
+ MUSHI_MCP_PIN_SPEC
10
+ } from "./chunk-LFZ5ZIBI.js";
9
11
  import {
10
12
  detectFramework,
11
13
  envVarsToWrite,
12
14
  readPackageJson
13
15
  } from "./chunk-AIZRI665.js";
16
+ import "./chunk-3RG5ZIWI.js";
14
17
 
15
18
  // src/connect.ts
16
19
  import { appendFile, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
17
20
  import { join, resolve } from "path";
18
21
 
19
- // src/sanitize-config.ts
20
- var PROJECT_ID_RE = /^(?:proj_[A-Za-z0-9_-]{10,}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
21
- var API_KEY_RE = /^(mushi_|mush_pk_)[A-Za-z0-9_-]{10,}$/;
22
- function sanitizeApiKey(raw) {
23
- const key = raw.replace(/[\r\n\0]/g, "");
24
- if (!API_KEY_RE.test(key)) {
25
- throw new Error(
26
- "Invalid API key in config \u2014 run `mushi login --api-key <key>` to refresh credentials."
27
- );
28
- }
29
- return key;
30
- }
31
- function sanitizeProjectId(raw) {
32
- const id = raw.trim();
33
- if (!PROJECT_ID_RE.test(id)) {
34
- throw new Error(
35
- "Invalid project ID in config \u2014 expected a UUID or proj_* slug from the admin console."
36
- );
37
- }
38
- return id;
39
- }
40
- function sanitizeEndpoint(raw) {
41
- return assertEndpoint(normalizeEndpoint(raw));
42
- }
43
- function apiKeyHeaders(apiKey, projectId) {
44
- const headers = {
45
- Authorization: `Bearer ${apiKey}`,
46
- "X-Mushi-Api-Key": apiKey
47
- };
48
- if (projectId) headers["X-Mushi-Project"] = projectId;
49
- return headers;
50
- }
51
-
52
- // src/heartbeat-wait.ts
53
- var IngestSetupHttpError = class extends Error {
54
- status;
55
- constructor(status) {
56
- super(`ingest-setup HTTP ${status}`);
57
- this.name = "IngestSetupHttpError";
58
- this.status = status;
59
- }
60
- };
61
- var NON_RETRYABLE_STATUSES = /* @__PURE__ */ new Set([401, 403, 404]);
62
- async function fetchIngestSetup(config, doFetch = globalThis.fetch) {
63
- const endpoint = sanitizeEndpoint(config.endpoint);
64
- const apiKey = sanitizeApiKey(config.apiKey);
65
- const projectId = config.projectId ? sanitizeProjectId(config.projectId) : void 0;
66
- const res = await doFetch(`${endpoint}/v1/sync/ingest-setup`, {
67
- headers: apiKeyHeaders(apiKey, projectId),
68
- signal: AbortSignal.timeout(8e3)
69
- });
70
- if (!res.ok) {
71
- if (NON_RETRYABLE_STATUSES.has(res.status)) throw new IngestSetupHttpError(res.status);
72
- return null;
73
- }
74
- const body = await res.json();
75
- return body.ok && body.data ? body.data : null;
76
- }
77
- async function waitForIngestReady(options) {
78
- const doFetch = options.fetch ?? globalThis.fetch;
79
- const maxAttempts = options.maxAttempts ?? 40;
80
- const intervalMs = options.intervalMs ?? 3e3;
81
- let lastPayload = null;
82
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
83
- if (options.signal?.aborted) {
84
- return { ok: false, payload: lastPayload, attempts: attempt - 1, reason: "aborted" };
85
- }
86
- try {
87
- lastPayload = await fetchIngestSetup(
88
- { endpoint: options.endpoint, apiKey: options.apiKey, projectId: options.projectId },
89
- doFetch
90
- );
91
- if (lastPayload) {
92
- options.onPoll?.(lastPayload, attempt);
93
- if (lastPayload.ready) {
94
- return { ok: true, payload: lastPayload, attempts: attempt, reason: "ready" };
95
- }
96
- const sdkStep = lastPayload.steps.find((s) => s.id === "sdk_installed");
97
- if (sdkStep?.complete) {
98
- return { ok: true, payload: lastPayload, attempts: attempt, reason: "heartbeat" };
99
- }
100
- }
101
- } catch (err) {
102
- const msg = err instanceof Error ? err.message : String(err);
103
- if (err instanceof IngestSetupHttpError) {
104
- return { ok: false, payload: lastPayload, attempts: attempt, reason: "unauthorized", error: msg };
105
- }
106
- if (options.signal?.aborted) {
107
- return { ok: false, payload: lastPayload, attempts: attempt, reason: "aborted", error: msg };
108
- }
109
- if (attempt === maxAttempts) {
110
- return { ok: false, payload: lastPayload, attempts: attempt, reason: "fetch-error", error: msg };
111
- }
112
- }
113
- if (attempt < maxAttempts) {
114
- await new Promise((r) => setTimeout(r, intervalMs));
115
- }
116
- }
117
- return { ok: false, payload: lastPayload, attempts: maxAttempts, reason: "timeout" };
118
- }
119
-
120
22
  // src/mcp-config.ts
121
23
  import { mkdir, readFile, writeFile } from "fs/promises";
122
24
  import { dirname } from "path";
@@ -149,11 +51,11 @@ async function writeMcpServerEntry(opts) {
149
51
  function buildMcpServerBlock(opts) {
150
52
  return {
151
53
  command: "npx",
152
- args: ["-y", "@mushi-mushi/mcp@latest"],
54
+ args: ["-y", MUSHI_MCP_PIN_SPEC],
153
55
  env: {
154
56
  MUSHI_API_ENDPOINT: opts.endpoint,
155
57
  MUSHI_PROJECT_ID: opts.projectId,
156
- MUSHI_API_KEY: opts.apiKey,
58
+ MUSHI_API_KEY: opts.inlineKey ? opts.apiKey : "${MUSHI_API_KEY}",
157
59
  MUSHI_FEATURES: DEFAULT_MUSHI_FEATURES
158
60
  }
159
61
  };
@@ -254,11 +156,14 @@ async function runConnect(opts, baseConfig = {}) {
254
156
  const serverBlock = buildMcpServerBlock({
255
157
  endpoint,
256
158
  projectId,
257
- apiKey: opts.apiKey
159
+ apiKey: opts.apiKey,
160
+ inlineKey: false
161
+ // use ${MUSHI_API_KEY} placeholder; caller must export the key
258
162
  });
259
163
  await writeMcpServerEntry({ configPath: mcpPath, serverName, serverBlock });
260
164
  await ensureMcpJsonGitignored(cwd, messages);
261
165
  messages.push(`\u2713 Wired ${mcpPath} \u2014 restart Cursor and run "list mushi tools"`);
166
+ messages.push(` Set MUSHI_API_KEY="${opts.apiKey}" in your shell / .env.local before restarting the IDE.`);
262
167
  }
263
168
  let heartbeat = null;
264
169
  if (opts.wait) {
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runConnect
4
- } from "./chunk-VIHWH3IG.js";
5
- import "./chunk-TYVJU4AH.js";
4
+ } from "./chunk-7JZUKNN5.js";
5
+ import "./chunk-MXD5UNMP.js";
6
6
  import "./chunk-LDPFTQLE.js";
7
+ import "./chunk-PDX44BCA.js";
7
8
  export {
8
9
  runConnect
9
10
  };
package/dist/detect.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  isFrameworkId,
9
9
  readPackageJson
10
10
  } from "./chunk-AIZRI665.js";
11
+ import "./chunk-3RG5ZIWI.js";
11
12
  export {
12
13
  FRAMEWORKS,
13
14
  FRAMEWORK_IDS,
@@ -6,6 +6,7 @@ import {
6
6
  normalizeEndpoint,
7
7
  resolveCloudEndpoint
8
8
  } from "./chunk-LDPFTQLE.js";
9
+ import "./chunk-PDX44BCA.js";
9
10
  export {
10
11
  CLOUD_API_ENDPOINT,
11
12
  TEST_REPORT_FETCH_TIMEOUT_MS,