@mushi-mushi/cli 0.18.2 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ // src/version.ts
2
+ var MUSHI_CLI_VERSION = true ? "0.19.0" : "0.0.0-dev";
3
+
4
+ export {
5
+ MUSHI_CLI_VERSION
6
+ };
@@ -0,0 +1,137 @@
1
+ // src/config.ts
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { dirname, join } from "path";
5
+ var SECURE_FILE_MODE = 384;
6
+ var SECURE_DIR_MODE = 448;
7
+ function resolveXdgConfigPath() {
8
+ const xdg = process.env["XDG_CONFIG_HOME"];
9
+ if (xdg && xdg.length > 0) {
10
+ return join(xdg, "mushi", "config.json");
11
+ }
12
+ if (process.platform === "win32") {
13
+ const appData = process.env["APPDATA"];
14
+ if (appData && appData.length > 0) {
15
+ return join(appData, "mushi", "config.json");
16
+ }
17
+ }
18
+ return join(homedir(), ".config", "mushi", "config.json");
19
+ }
20
+ var LEGACY_CONFIG_PATH = join(homedir(), ".mushirc");
21
+ var CONFIG_PATH = resolveXdgConfigPath();
22
+ function loadConfig(path = CONFIG_PATH) {
23
+ let file = {};
24
+ if (existsSync(path)) {
25
+ tightenPermissions(path);
26
+ try {
27
+ file = JSON.parse(readFileSync(path, "utf-8"));
28
+ } catch {
29
+ }
30
+ } else if (path === CONFIG_PATH && existsSync(LEGACY_CONFIG_PATH)) {
31
+ file = migrateLegacyConfig() ?? {};
32
+ }
33
+ const endpointFromEnv = process.env["MUSHI_API_ENDPOINT"] ?? process.env["MUSHI_ENDPOINT"] ?? void 0;
34
+ const fromEnv = {
35
+ ...process.env["MUSHI_API_KEY"] ? { apiKey: process.env["MUSHI_API_KEY"] } : {},
36
+ ...process.env["MUSHI_PROJECT_ID"] ? { projectId: process.env["MUSHI_PROJECT_ID"] } : {},
37
+ ...endpointFromEnv ? { endpoint: endpointFromEnv } : {},
38
+ ...process.env["MUSHI_CONSOLE_URL"] ? { consoleUrl: process.env["MUSHI_CONSOLE_URL"].trim() } : {}
39
+ };
40
+ return { ...file, ...fromEnv };
41
+ }
42
+ function saveConfig(config, path = CONFIG_PATH) {
43
+ const dir = dirname(path);
44
+ if (!existsSync(dir)) {
45
+ mkdirSync(dir, { recursive: true, mode: SECURE_DIR_MODE });
46
+ } else {
47
+ tightenDirPermissions(dir);
48
+ }
49
+ writeFileSync(path, JSON.stringify(config, null, 2), { mode: SECURE_FILE_MODE });
50
+ tightenPermissions(path);
51
+ }
52
+ function migrateLegacyConfig(legacyPath = LEGACY_CONFIG_PATH, destPath = CONFIG_PATH) {
53
+ if (!existsSync(legacyPath)) return null;
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(readFileSync(legacyPath, "utf-8"));
57
+ } catch {
58
+ return null;
59
+ }
60
+ const dir = dirname(destPath);
61
+ if (!existsSync(dir)) {
62
+ mkdirSync(dir, { recursive: true, mode: SECURE_DIR_MODE });
63
+ }
64
+ try {
65
+ renameSync(legacyPath, destPath);
66
+ } catch {
67
+ writeFileSync(destPath, JSON.stringify(parsed, null, 2), { mode: SECURE_FILE_MODE });
68
+ try {
69
+ unlinkSync(legacyPath);
70
+ } catch {
71
+ }
72
+ }
73
+ tightenPermissions(destPath);
74
+ tightenDirPermissions(dir);
75
+ return parsed;
76
+ }
77
+ function tightenPermissions(path) {
78
+ if (process.platform === "win32") return;
79
+ try {
80
+ const current = statSync(path).mode & 511;
81
+ if (current !== SECURE_FILE_MODE) chmodSync(path, SECURE_FILE_MODE);
82
+ } catch {
83
+ }
84
+ }
85
+ function tightenDirPermissions(path) {
86
+ if (process.platform === "win32") return;
87
+ try {
88
+ const current = statSync(path).mode & 511;
89
+ if (current !== SECURE_DIR_MODE) chmodSync(path, SECURE_DIR_MODE);
90
+ } catch {
91
+ }
92
+ }
93
+
94
+ // src/endpoint.ts
95
+ var TEST_REPORT_TIMEOUT_MS = 1e4;
96
+ var TEST_REPORT_FETCH_TIMEOUT_MS = TEST_REPORT_TIMEOUT_MS;
97
+ var CLOUD_API_ENDPOINT = "https://dxptnwrhwsqckaftyymj.supabase.co/functions/v1/api";
98
+ function resolveCloudEndpoint(explicit) {
99
+ if (explicit?.trim()) return explicit.trim();
100
+ const fromEnv = process.env.MUSHI_API_ENDPOINT?.trim();
101
+ if (fromEnv) return fromEnv;
102
+ return CLOUD_API_ENDPOINT;
103
+ }
104
+ function assertEndpoint(url) {
105
+ let parsed;
106
+ try {
107
+ parsed = new URL(url);
108
+ } catch {
109
+ throw new Error(`Invalid endpoint URL: ${url}`);
110
+ }
111
+ const host = parsed.hostname;
112
+ const isLocal = host === "localhost" || host === "127.0.0.1" || host === "::1" || host.endsWith(".local");
113
+ if (parsed.protocol !== "https:" && !isLocal) {
114
+ throw new Error(`Endpoint must use https:// (got ${parsed.protocol}//${host}).`);
115
+ }
116
+ return normalizeEndpoint(parsed.origin + parsed.pathname);
117
+ }
118
+ function normalizeEndpoint(url) {
119
+ if (!url) {
120
+ throw new Error(
121
+ "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"
122
+ );
123
+ }
124
+ let end = url.length;
125
+ while (end > 0 && url.charCodeAt(end - 1) === 47) end--;
126
+ return url.slice(0, end);
127
+ }
128
+
129
+ export {
130
+ CONFIG_PATH,
131
+ loadConfig,
132
+ saveConfig,
133
+ TEST_REPORT_FETCH_TIMEOUT_MS,
134
+ resolveCloudEndpoint,
135
+ assertEndpoint,
136
+ normalizeEndpoint
137
+ };
@@ -0,0 +1,297 @@
1
+ import {
2
+ CONFIG_PATH,
3
+ assertEndpoint,
4
+ normalizeEndpoint,
5
+ saveConfig
6
+ } from "./chunk-XEXYGDQV.js";
7
+ import {
8
+ detectFramework,
9
+ envVarsToWrite,
10
+ readPackageJson
11
+ } from "./chunk-7Q44KWGN.js";
12
+
13
+ // src/connect.ts
14
+ import { appendFile, readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
15
+ import { join, resolve } from "path";
16
+
17
+ // src/sanitize-config.ts
18
+ 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;
19
+ var API_KEY_RE = /^(mushi_|mush_pk_)[A-Za-z0-9_-]{10,}$/;
20
+ function sanitizeApiKey(raw) {
21
+ const key = raw.replace(/[\r\n\0]/g, "");
22
+ if (!API_KEY_RE.test(key)) {
23
+ throw new Error(
24
+ "Invalid API key in config \u2014 run `mushi login --api-key <key>` to refresh credentials."
25
+ );
26
+ }
27
+ return key;
28
+ }
29
+ function sanitizeProjectId(raw) {
30
+ const id = raw.trim();
31
+ if (!PROJECT_ID_RE.test(id)) {
32
+ throw new Error(
33
+ "Invalid project ID in config \u2014 expected a UUID or proj_* slug from the admin console."
34
+ );
35
+ }
36
+ return id;
37
+ }
38
+ function sanitizeEndpoint(raw) {
39
+ return assertEndpoint(normalizeEndpoint(raw));
40
+ }
41
+ function apiKeyHeaders(apiKey, projectId) {
42
+ const headers = {
43
+ Authorization: `Bearer ${apiKey}`,
44
+ "X-Mushi-Api-Key": apiKey
45
+ };
46
+ if (projectId) headers["X-Mushi-Project"] = projectId;
47
+ return headers;
48
+ }
49
+
50
+ // src/heartbeat-wait.ts
51
+ var IngestSetupHttpError = class extends Error {
52
+ status;
53
+ constructor(status) {
54
+ super(`ingest-setup HTTP ${status}`);
55
+ this.name = "IngestSetupHttpError";
56
+ this.status = status;
57
+ }
58
+ };
59
+ var NON_RETRYABLE_STATUSES = /* @__PURE__ */ new Set([401, 403, 404]);
60
+ async function fetchIngestSetup(config, doFetch = globalThis.fetch) {
61
+ const endpoint = sanitizeEndpoint(config.endpoint);
62
+ const apiKey = sanitizeApiKey(config.apiKey);
63
+ const projectId = config.projectId ? sanitizeProjectId(config.projectId) : void 0;
64
+ const res = await doFetch(`${endpoint}/v1/sync/ingest-setup`, {
65
+ headers: apiKeyHeaders(apiKey, projectId),
66
+ signal: AbortSignal.timeout(8e3)
67
+ });
68
+ if (!res.ok) {
69
+ if (NON_RETRYABLE_STATUSES.has(res.status)) throw new IngestSetupHttpError(res.status);
70
+ return null;
71
+ }
72
+ const body = await res.json();
73
+ return body.ok && body.data ? body.data : null;
74
+ }
75
+ async function waitForIngestReady(options) {
76
+ const doFetch = options.fetch ?? globalThis.fetch;
77
+ const maxAttempts = options.maxAttempts ?? 40;
78
+ const intervalMs = options.intervalMs ?? 3e3;
79
+ let lastPayload = null;
80
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
81
+ if (options.signal?.aborted) {
82
+ return { ok: false, payload: lastPayload, attempts: attempt - 1, reason: "aborted" };
83
+ }
84
+ try {
85
+ lastPayload = await fetchIngestSetup(
86
+ { endpoint: options.endpoint, apiKey: options.apiKey, projectId: options.projectId },
87
+ doFetch
88
+ );
89
+ if (lastPayload) {
90
+ options.onPoll?.(lastPayload, attempt);
91
+ if (lastPayload.ready) {
92
+ return { ok: true, payload: lastPayload, attempts: attempt, reason: "ready" };
93
+ }
94
+ const sdkStep = lastPayload.steps.find((s) => s.id === "sdk_installed");
95
+ if (sdkStep?.complete) {
96
+ return { ok: true, payload: lastPayload, attempts: attempt, reason: "heartbeat" };
97
+ }
98
+ }
99
+ } catch (err) {
100
+ const msg = err instanceof Error ? err.message : String(err);
101
+ if (err instanceof IngestSetupHttpError) {
102
+ return { ok: false, payload: lastPayload, attempts: attempt, reason: "unauthorized", error: msg };
103
+ }
104
+ if (options.signal?.aborted) {
105
+ return { ok: false, payload: lastPayload, attempts: attempt, reason: "aborted", error: msg };
106
+ }
107
+ if (attempt === maxAttempts) {
108
+ return { ok: false, payload: lastPayload, attempts: attempt, reason: "fetch-error", error: msg };
109
+ }
110
+ }
111
+ if (attempt < maxAttempts) {
112
+ await new Promise((r) => setTimeout(r, intervalMs));
113
+ }
114
+ }
115
+ return { ok: false, payload: lastPayload, attempts: maxAttempts, reason: "timeout" };
116
+ }
117
+
118
+ // src/mcp-config.ts
119
+ import { mkdir, readFile, writeFile } from "fs/promises";
120
+ import { dirname } from "path";
121
+ var DEFAULT_MUSHI_FEATURES = "triage,fixes,inventory,setup,docs";
122
+ async function writeMcpServerEntry(opts) {
123
+ const { configPath, serverName, serverBlock, dryRun = false } = opts;
124
+ let merged = { mcpServers: {} };
125
+ let created = false;
126
+ try {
127
+ const existing = await readFile(configPath, "utf8");
128
+ try {
129
+ merged = JSON.parse(existing);
130
+ } catch {
131
+ merged = { mcpServers: {} };
132
+ }
133
+ } catch {
134
+ created = true;
135
+ merged = { mcpServers: {} };
136
+ }
137
+ const servers = merged.mcpServers ?? {};
138
+ servers[serverName] = serverBlock;
139
+ merged.mcpServers = servers;
140
+ const output = JSON.stringify(merged, null, 2) + "\n";
141
+ if (!dryRun) {
142
+ await mkdir(dirname(configPath), { recursive: true });
143
+ await writeFile(configPath, output, "utf8");
144
+ }
145
+ return { created, path: configPath };
146
+ }
147
+ function buildMcpServerBlock(opts) {
148
+ return {
149
+ command: "npx",
150
+ args: ["-y", "@mushi-mushi/mcp@latest"],
151
+ env: {
152
+ MUSHI_API_ENDPOINT: opts.endpoint,
153
+ MUSHI_PROJECT_ID: opts.projectId,
154
+ MUSHI_API_KEY: opts.apiKey,
155
+ MUSHI_FEATURES: DEFAULT_MUSHI_FEATURES
156
+ }
157
+ };
158
+ }
159
+ function buildMcpServerName(opts) {
160
+ if (opts.legacy) return "mushi";
161
+ if (opts.projectName) {
162
+ const slug = opts.projectName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 24);
163
+ return `mushi-${slug}`;
164
+ }
165
+ return `mushi-${(opts.projectId ?? "unknown").slice(0, 8)}`;
166
+ }
167
+
168
+ // src/connect.ts
169
+ function envKeyPresent(content, key) {
170
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
171
+ return new RegExp(`^${escaped}=`, "m").test(content);
172
+ }
173
+ async function mergeEnvFile(path, lines) {
174
+ const block = `
175
+ # Mushi \u2014 added by mushi connect
176
+ ${lines.join("\n")}
177
+ `;
178
+ let existing = null;
179
+ try {
180
+ existing = await readFile2(path, "utf8");
181
+ } catch {
182
+ }
183
+ if (existing !== null) {
184
+ const needs = lines.filter((line) => {
185
+ const key = line.split("=")[0];
186
+ return !envKeyPresent(existing, key);
187
+ });
188
+ if (needs.length === 0) return false;
189
+ await appendFile(path, `
190
+ # Mushi \u2014 added by mushi connect
191
+ ${needs.join("\n")}
192
+ `, "utf8");
193
+ return true;
194
+ }
195
+ await writeFile2(path, block, "utf8");
196
+ return true;
197
+ }
198
+ async function ensureMcpJsonGitignored(cwd, messages) {
199
+ const gitignorePath = join(cwd, ".gitignore");
200
+ const patterns = [".cursor/mcp.json", ".cursor/"];
201
+ let content = null;
202
+ try {
203
+ content = await readFile2(gitignorePath, "utf8");
204
+ } catch {
205
+ }
206
+ if (content === null) {
207
+ messages.push(
208
+ "\u26A0 No .gitignore found \u2014 .cursor/mcp.json contains your API key. Add `.cursor/mcp.json` before committing."
209
+ );
210
+ return;
211
+ }
212
+ const covered = patterns.some((p) => content.split("\n").some((line) => line.trim() === p || line.trim() === `${p}/`));
213
+ if (covered) return;
214
+ await appendFile(gitignorePath, "\n# Mushi \u2014 keep MCP credentials out of git\n.cursor/mcp.json\n", "utf8");
215
+ messages.push("\u2713 Added .cursor/mcp.json to .gitignore (contains API key)");
216
+ }
217
+ async function runConnect(opts, baseConfig = {}) {
218
+ const cwd = resolve(opts.cwd ?? process.cwd());
219
+ const endpoint = assertEndpoint(opts.endpoint);
220
+ const messages = [];
221
+ const config = {
222
+ ...baseConfig,
223
+ apiKey: opts.apiKey,
224
+ projectId: opts.projectId,
225
+ endpoint
226
+ };
227
+ saveConfig(config);
228
+ messages.push(`\u2713 Credentials saved to ${CONFIG_PATH}`);
229
+ let envPath = null;
230
+ if (opts.writeEnv !== false) {
231
+ const pkg = readPackageJson(cwd);
232
+ const framework = detectFramework(cwd, pkg);
233
+ const lines = envVarsToWrite(opts.apiKey, opts.projectId, framework).split("\n");
234
+ envPath = join(cwd, ".env.local");
235
+ const wrote = await mergeEnvFile(envPath, lines);
236
+ messages.push(
237
+ wrote ? `\u2713 Env vars merged into ${envPath}` : `\u2713 Env vars already present in ${envPath} (existing values left untouched)`
238
+ );
239
+ const deps = { ...pkg?.dependencies ?? {}, ...pkg?.devDependencies ?? {} };
240
+ if (deps["@capacitor/core"] && deps["react"]) {
241
+ messages.push(
242
+ "\u2139 Capacitor + React detected \u2014 install @mushi-mushi/web (or @mushi-mushi/react) and call initMushi() in main.tsx. Optional @mushi-mushi/capacitor for native shell parity."
243
+ );
244
+ }
245
+ }
246
+ let mcpPath = null;
247
+ if (opts.wireIde !== false) {
248
+ const mcpDir = join(cwd, ".cursor");
249
+ mcpPath = join(mcpDir, "mcp.json");
250
+ const serverName = buildMcpServerName({ projectId: opts.projectId });
251
+ const serverBlock = buildMcpServerBlock({
252
+ endpoint,
253
+ projectId: opts.projectId,
254
+ apiKey: opts.apiKey
255
+ });
256
+ await writeMcpServerEntry({ configPath: mcpPath, serverName, serverBlock });
257
+ await ensureMcpJsonGitignored(cwd, messages);
258
+ messages.push(`\u2713 Wired ${mcpPath} \u2014 restart Cursor and run "list mushi tools"`);
259
+ }
260
+ let heartbeat = null;
261
+ if (opts.wait) {
262
+ const timeoutSec = opts.waitTimeoutSec ?? 120;
263
+ const maxAttempts = Math.max(1, Math.ceil(timeoutSec * 1e3 / 3e3));
264
+ messages.push(`\u2026 Waiting for SDK heartbeat (up to ${timeoutSec}s) \u2014 start your dev server with the snippet installed`);
265
+ heartbeat = await waitForIngestReady({
266
+ endpoint,
267
+ apiKey: opts.apiKey,
268
+ projectId: opts.projectId,
269
+ maxAttempts,
270
+ onPoll: (payload, attempt) => {
271
+ if (!opts.json && attempt % 3 === 0) {
272
+ const sdk = payload.steps.find((s) => s.id === "sdk_installed");
273
+ const seen = payload.diagnostic?.last_sdk_seen_at ?? "never";
274
+ process.stdout.write(` poll ${attempt}: sdk_installed=${sdk?.complete ? "yes" : "no"} last_seen=${seen}
275
+ `);
276
+ }
277
+ }
278
+ });
279
+ if (heartbeat.ok) {
280
+ const label = heartbeat.reason === "heartbeat" ? "SDK heartbeat detected" : "Ingest setup complete";
281
+ messages.push(`\u2713 ${label} \u2014 ingest pipeline is live`);
282
+ } else if (heartbeat.reason === "unauthorized") {
283
+ messages.push(
284
+ `\u2717 The backend rejected these credentials (${heartbeat.error ?? "auth error"}). Double-check --api-key, --project-id, and --endpoint, then re-run \`mushi connect --wait\`.`
285
+ );
286
+ } else {
287
+ messages.push(
288
+ `\u2717 No heartbeat before timeout (${heartbeat.reason}). Confirm env vars are in your build, restart the dev server, then re-run \`mushi connect --wait\`.`
289
+ );
290
+ }
291
+ }
292
+ const ok = !opts.wait || Boolean(heartbeat?.ok);
293
+ return { ok, envPath, mcpPath, heartbeat, messages };
294
+ }
295
+ export {
296
+ runConnect
297
+ };
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ runConnect
4
+ } from "./chunk-D5IDNBJO.js";
5
+ import "./chunk-LDPFTQLE.js";
6
+ export {
7
+ runConnect
8
+ };