@exposify/supabase 0.3.7 → 0.3.9

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/cli.js CHANGED
@@ -169,6 +169,36 @@ function parseSelectionAnswer(answer, candidates) {
169
169
  );
170
170
  return candidates.filter((c) => wanted.has(c));
171
171
  }
172
+ function writesByTable(writes) {
173
+ const map = /* @__PURE__ */ new Map();
174
+ for (const w of writes) {
175
+ const dot = w.lastIndexOf(".");
176
+ const table = dot === -1 ? w : w.slice(0, dot);
177
+ map.set(table, [...map.get(table) ?? [], w]);
178
+ }
179
+ return map;
180
+ }
181
+ function writesLabel(entries) {
182
+ if (entries.length === 0) return "none";
183
+ if (entries.some((e) => e.endsWith(".*"))) return "i,u,d";
184
+ return entries.map((e) => e.slice(e.lastIndexOf(".") + 1)[0]).join(",");
185
+ }
186
+ function resolveWritesAnswer(answer, table, current) {
187
+ const a = answer.trim();
188
+ if (a === "") return { value: current };
189
+ if (a === "-") return { value: [] };
190
+ const ops = parseWriteOpsAnswer(a);
191
+ if (ops.length === 0) return { value: current, note: `didn't understand "${a}" -- kept ${writesLabel(current)}` };
192
+ return { value: flattenWriteGrants([{ table, ops }]) };
193
+ }
194
+ function resolveSelectionAnswer(answer, candidates, current) {
195
+ const a = answer.trim();
196
+ if (a === "") return { value: current };
197
+ if (a === "-") return { value: [] };
198
+ const picked = parseSelectionAnswer(a, candidates);
199
+ if (picked.length === 0) return { value: current, note: `none of "${a}" matched -- kept ${current.join(", ") || "none"}` };
200
+ return { value: picked };
201
+ }
172
202
  function tablesMissingDocs(tables) {
173
203
  return tables.map((t) => ({
174
204
  name: t.name,
@@ -198,46 +228,6 @@ function buildCommentStatements(entries) {
198
228
  return lines.length > 0 ? `${lines.join("\n")}
199
229
  ` : "";
200
230
  }
201
- var LICENSE_KEY_SECRET = "EXPOSIFY_LICENSE_KEY";
202
- var ANONYMOUS_SIGN_IN_WARNING = `// NOTE: this project has anonymous sign-in enabled. An anonymous Supabase
203
- // Auth session still gets role: authenticated (with an is_anonymous claim),
204
- // NOT role: anon -- so it passes any RLS policy scoped to "authenticated"
205
- // unless that policy explicitly excludes auth.jwt()->>'is_anonymous' = 'true'.
206
- // Double-check your RLS policies (especially on tables granted writes below)
207
- // account for this before trusting "authenticated" to mean "a real user".
208
- `;
209
- function buildFunctionTemplate(opts = {}) {
210
- const lines = [];
211
- if (opts.anonymousSignIn) lines.push(ANONYMOUS_SIGN_IN_WARNING);
212
- lines.push(`import { aiReady } from "npm:@exposify/supabase";`);
213
- if (opts.broker) lines.push(`import { createHostedAuthResolver } from "npm:@exposify/token-broker";`);
214
- lines.push("");
215
- lines.push("Deno.serve(aiReady({");
216
- if (opts.broker) {
217
- lines.push(
218
- " auth: createHostedAuthResolver({",
219
- ` brokerUrl: ${JSON.stringify(opts.broker.brokerUrl)},`,
220
- ` projectId: ${JSON.stringify(opts.broker.projectId)},`,
221
- ` licenseKey: Deno.env.get(${JSON.stringify(LICENSE_KEY_SECRET)}) ?? "",`,
222
- " }),",
223
- " authRequired: true,"
224
- );
225
- }
226
- lines.push(
227
- opts.writes && opts.writes.length > 0 ? ` writes: ${JSON.stringify(opts.writes)},` : ` // writes: ["orders.insert", "orders.update"],`
228
- );
229
- lines.push(
230
- opts.rpc && opts.rpc.length > 0 ? ` rpc: ${JSON.stringify(opts.rpc)},` : ` // rpc: ["place_order"],`
231
- );
232
- lines.push(
233
- " // functions: {",
234
- ' // refund: { description: "Refund an order", input: { type: "object", properties: {} } },',
235
- " // },",
236
- "}));",
237
- ""
238
- );
239
- return lines.join("\n");
240
- }
241
231
  function brokerUrlProblem(brokerUrl) {
242
232
  let url;
243
233
  try {
@@ -302,11 +292,782 @@ function partialConnectionWarning(url, key) {
302
292
  return null;
303
293
  }
304
294
 
305
- // bin/cli.ts
306
- var PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
307
- var FUNCTION_DIR = "supabase/functions/mcp";
295
+ // bin/credentials.ts
296
+ var PROJECT_REF_FILE = "supabase/.temp/project-ref";
297
+ function explicitConnection(input) {
298
+ const url = input.flagUrl ?? input.envUrl;
299
+ const serviceRoleKey = input.flagKey ?? input.envKey;
300
+ if (!url || !serviceRoleKey) return null;
301
+ const fromFlags = input.flagUrl !== void 0 || input.flagKey !== void 0;
302
+ return {
303
+ url,
304
+ serviceRoleKey,
305
+ ...input.envAnonKey ? { anonKey: input.envAnonKey } : {},
306
+ source: fromFlags ? "flags" : "env",
307
+ label: fromFlags ? "--url/--key" : "SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY"
308
+ };
309
+ }
310
+ function parseStatusEnv(stdout) {
311
+ const vars = /* @__PURE__ */ new Map();
312
+ for (const line of stdout.split("\n")) {
313
+ const m = /^\s*([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
314
+ if (!m) continue;
315
+ vars.set(m[1], m[2].trim().replace(/^"(.*)"$/, "$1").replace(/^'(.*)'$/, "$1"));
316
+ }
317
+ const url = vars.get("API_URL");
318
+ const serviceRoleKey = vars.get("SERVICE_ROLE_KEY") || vars.get("SECRET_KEY");
319
+ if (!url || !serviceRoleKey) return null;
320
+ const anonKey = vars.get("ANON_KEY");
321
+ return { url, serviceRoleKey, ...anonKey ? { anonKey } : {} };
322
+ }
323
+ function isMaskedKey(key) {
324
+ return /[·•*]/.test(key);
325
+ }
326
+ function parseApiKeysJson(stdout) {
327
+ let parsed;
328
+ try {
329
+ parsed = JSON.parse(stdout);
330
+ } catch {
331
+ return null;
332
+ }
333
+ if (!Array.isArray(parsed)) return null;
334
+ const keys = parsed.filter(
335
+ (k) => !!k && typeof k === "object" && typeof k.api_key === "string" && !isMaskedKey(k.api_key)
336
+ );
337
+ const serviceRole = keys.find((k) => k.name === "service_role") ?? keys.find((k) => k.type === "secret");
338
+ if (!serviceRole) return null;
339
+ const anon = keys.find((k) => k.name === "anon");
340
+ return { serviceRoleKey: serviceRole.api_key, ...anon ? { anonKey: anon.api_key } : {} };
341
+ }
342
+ function linkedProjectUrl(ref) {
343
+ return `https://${ref}.supabase.co`;
344
+ }
345
+ function projectRefFromUrl(url) {
346
+ try {
347
+ const m = /^([a-z0-9]{20})\.supabase\.co$/.exec(new URL(url).hostname);
348
+ return m ? m[1] : null;
349
+ } catch {
350
+ return null;
351
+ }
352
+ }
353
+ async function discoverConnection(deps) {
354
+ const notes = [];
355
+ if (!deps.run) {
356
+ notes.push("Supabase CLI not on PATH, so it can't supply credentials.");
357
+ return { connection: null, notes };
358
+ }
359
+ const run = deps.run;
360
+ let local = null;
361
+ if (deps.prefer !== "linked") {
362
+ try {
363
+ local = parseStatusEnv(run(["status", "-o", "env"]));
364
+ if (!local) notes.push("`supabase status` didn't report an API URL and service role key.");
365
+ } catch {
366
+ notes.push("No local stack running (`supabase status` failed).");
367
+ }
368
+ }
369
+ const ref = deps.prefer === "local" ? null : deps.projectRef;
370
+ if (deps.prefer !== "local" && !ref) notes.push(`No linked project (${PROJECT_REF_FILE} not found; run \`supabase link\`).`);
371
+ const fromLocal = () => ({
372
+ ...local,
373
+ source: "local",
374
+ label: `local stack at ${local.url} (supabase status)`
375
+ });
376
+ let pick = null;
377
+ if (local && ref) pick = deps.choose ? await deps.choose({ localUrl: local.url, projectRef: ref }) : "linked";
378
+ else if (ref) pick = "linked";
379
+ else if (local) pick = "local";
380
+ if (pick === null) return { connection: null, notes };
381
+ if (pick === "local") return { connection: fromLocal(), notes };
382
+ try {
383
+ const keys = parseApiKeysJson(run(["projects", "api-keys", "--project-ref", ref, "--reveal", "-o", "json"]));
384
+ if (keys) {
385
+ return {
386
+ connection: {
387
+ url: linkedProjectUrl(ref),
388
+ ...keys,
389
+ source: "linked",
390
+ label: `linked project ${ref} (supabase projects api-keys)`
391
+ },
392
+ notes
393
+ };
394
+ }
395
+ notes.push(`\`supabase projects api-keys\` returned no usable service role or secret key for ${ref}.`);
396
+ } catch {
397
+ notes.push(`Couldn't fetch API keys for linked project ${ref} (run \`supabase login\`?).`);
398
+ }
399
+ return { connection: local ? fromLocal() : null, notes };
400
+ }
401
+
402
+ // bin/function-file.ts
403
+ var KEY_SECRET = "EXPOSIFY_KEY";
404
+ var EMPTY_CONFIG = { writes: [], rpc: [], anonymousSignIn: false, broker: null, oauth: false };
405
+ function bypassesGatewayJwt(config) {
406
+ return config.broker !== null || config.oauth;
407
+ }
408
+ function gatewayJwtReason(config) {
409
+ return config.oauth ? "MCP clients' first, tokenless request must reach the function to get its login challenge" : "broker tokens aren't Supabase JWTs";
410
+ }
411
+ var BEGIN = "// exposify:begin";
412
+ var END = "// exposify:end";
413
+ var ANONYMOUS_SIGN_IN_WARNING = [
414
+ "// NOTE: this project has anonymous sign-in enabled. An anonymous Supabase",
415
+ "// Auth session still gets role: authenticated (with an is_anonymous claim),",
416
+ '// NOT role: anon -- so it passes any RLS policy scoped to "authenticated"',
417
+ "// unless that policy explicitly excludes auth.jwt()->>'is_anonymous' = 'true'.",
418
+ "// Double-check your RLS policies (especially on tables granted writes below)",
419
+ '// account for this before trusting "authenticated" to mean "a real user".'
420
+ ];
421
+ var ANONYMOUS_MARKER = "anonymous sign-in enabled";
422
+ function buildManagedBlock(config) {
423
+ if (config.broker && config.oauth) throw new Error("the hosted broker and Supabase Auth OAuth can't be combined");
424
+ const lines = [
425
+ `${BEGIN} -- generated by \`npx exposify-supabase init\`, which rewrites`,
426
+ `// everything down to exposify:end when you re-run it. Add your own code below.`
427
+ ];
428
+ if (config.anonymousSignIn) lines.push(...ANONYMOUS_SIGN_IN_WARNING);
429
+ const imports = ["aiReady", ...config.oauth ? ["supabaseAuth"] : [], ...config.broker ? ["hostedBroker"] : []];
430
+ lines.push(`import { ${imports.join(", ")} } from "npm:@exposify/supabase";`);
431
+ lines.push("", "const generated = {");
432
+ if (config.broker) {
433
+ lines.push(
434
+ config.broker.brokerUrl ? ` auth: hostedBroker({ brokerUrl: ${JSON.stringify(config.broker.brokerUrl)} }),` : " auth: hostedBroker(),",
435
+ " authRequired: true,"
436
+ );
437
+ }
438
+ if (config.oauth) {
439
+ lines.push(" oauth: supabaseAuth(),");
440
+ }
441
+ lines.push(
442
+ ` // "<table>.insert" | "<table>.update" | "<table>.delete" | "<table>.*"`,
443
+ ` writes: ${JSON.stringify(config.writes)},`,
444
+ " // Postgres functions exposed as rpc_<fn> tools",
445
+ ` rpc: ${JSON.stringify(config.rpc)},`,
446
+ "};",
447
+ END
448
+ );
449
+ return lines.join("\n");
450
+ }
451
+ function buildFunctionFile(config) {
452
+ return [
453
+ buildManagedBlock(config),
454
+ "",
455
+ "Deno.serve(aiReady({",
456
+ " ...generated,",
457
+ " // functions: {",
458
+ ' // refund: { description: "Refund an order", input: { type: "object", properties: {} } },',
459
+ " // },",
460
+ "}));",
461
+ ""
462
+ ].join("\n");
463
+ }
464
+ function readStringArray(text, key) {
465
+ const m = new RegExp(`^\\s*${key}:\\s*(\\[.*\\])\\s*,?\\s*$`, "m").exec(text);
466
+ if (!m) return null;
467
+ try {
468
+ const value = JSON.parse(m[1]);
469
+ return Array.isArray(value) && value.every((v) => typeof v === "string") ? value : null;
470
+ } catch {
471
+ return null;
472
+ }
473
+ }
474
+ function readStringField(text, key) {
475
+ const m = new RegExp(`^\\s*${key}:\\s*("(?:[^"\\\\]|\\\\.)*")\\s*,?\\s*$`, "m").exec(text);
476
+ if (!m) return null;
477
+ try {
478
+ return JSON.parse(m[1]);
479
+ } catch {
480
+ return null;
481
+ }
482
+ }
483
+ function readConfig(text) {
484
+ const inline = /\bhostedBroker\(\{\s*brokerUrl:\s*("(?:[^"\\]|\\.)*")\s*\}\)/.exec(text);
485
+ let brokerUrl = null;
486
+ try {
487
+ brokerUrl = inline ? JSON.parse(inline[1]) : readStringField(text, "brokerUrl");
488
+ } catch {
489
+ brokerUrl = null;
490
+ }
491
+ return {
492
+ writes: readStringArray(text, "writes") ?? [],
493
+ rpc: readStringArray(text, "rpc") ?? [],
494
+ anonymousSignIn: text.includes(ANONYMOUS_MARKER),
495
+ // hostedBroker() today; createHostedAuthResolver(...) in files from before the one-key setup.
496
+ broker: /\b(hostedBroker|createHostedAuthResolver)\s*\(/.test(text) ? brokerUrl ? { brokerUrl } : {} : null,
497
+ oauth: /^\s*oauth:\s*supabaseAuth\(\s*\)\s*,?\s*$/m.test(text)
498
+ };
499
+ }
500
+ function findBlock(lines) {
501
+ const start = lines.findIndex((l) => l.startsWith(BEGIN));
502
+ if (start === -1) return null;
503
+ const end = lines.findIndex((l, i) => i > start && l.trim() === END);
504
+ return end === -1 ? null : { start, end };
505
+ }
506
+ function sameIgnoringWhitespace(a, b) {
507
+ return a.replace(/\s+/g, "") === b.replace(/\s+/g, "");
508
+ }
509
+ function parseFunctionFile(text) {
510
+ const lines = text.split("\n");
511
+ const found = findBlock(lines);
512
+ if (!found) return { kind: "unmanaged", config: readConfig(text) };
513
+ const block = lines.slice(found.start, found.end + 1).join("\n");
514
+ const config = readConfig(block);
515
+ return sameIgnoringWhitespace(buildManagedBlock(config), block) ? { kind: "managed", config, block } : { kind: "modified", config };
516
+ }
517
+ function replaceManagedBlock(text, config) {
518
+ const lines = text.split("\n");
519
+ const found = findBlock(lines);
520
+ if (!found) throw new Error("replaceManagedBlock: no managed region");
521
+ return [...lines.slice(0, found.start), buildManagedBlock(config), ...lines.slice(found.end + 1)].join("\n");
522
+ }
523
+ function configsEqual(a, b) {
524
+ const sameSet = (x, y) => x.length === y.length && [...x].sort().join("\0") === [...y].sort().join("\0");
525
+ return sameSet(a.writes, b.writes) && sameSet(a.rpc, b.rpc) && a.anonymousSignIn === b.anonymousSignIn && a.broker === null === (b.broker === null) && a.broker?.brokerUrl === b.broker?.brokerUrl && a.oauth === b.oauth;
526
+ }
527
+
528
+ // bin/doctor.ts
529
+ var FUNCTION_NAME = "mcp";
530
+ var FUNCTION_PATH = `supabase/functions/${FUNCTION_NAME}/index.ts`;
308
531
  var MIGRATIONS_DIR = "supabase/migrations";
309
532
  var CONFIG_TOML = "supabase/config.toml";
533
+ var DIAGNOSTICS_URI = "exposify://diagnostics";
534
+ function detectAuthMode(functionSource) {
535
+ if (/\b(hostedBroker|createHostedAuthResolver)\s*\(/.test(functionSource)) return "broker";
536
+ return /\boauth:\s*supabaseAuth\s*\(/.test(functionSource) ? "oauth" : "jwt";
537
+ }
538
+ function bypassesGatewayJwt2(mode) {
539
+ return mode === "broker" || mode === "oauth";
540
+ }
541
+ var AUTH_LABEL = { jwt: "user JWT", broker: "hosted broker", oauth: "Supabase Auth OAuth" };
542
+ function readFunctionVerifyJwt(toml, name = FUNCTION_NAME) {
543
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
544
+ const header = new RegExp(`^\\s*\\[\\s*functions\\s*\\.\\s*("?)${escaped}\\1\\s*\\]\\s*(#.*)?$`);
545
+ const lines = toml.split("\n");
546
+ const start = lines.findIndex((l) => header.test(l));
547
+ if (start === -1) return null;
548
+ for (let i = start + 1; i < lines.length; i++) {
549
+ if (/^\s*\[/.test(lines[i])) break;
550
+ const m = /^\s*verify_jwt\s*=\s*(true|false)\b/.exec(lines[i]);
551
+ if (m) return m[1] === "true";
552
+ }
553
+ return null;
554
+ }
555
+ function parseSecretNames(stdout) {
556
+ try {
557
+ const parsed = JSON.parse(stdout);
558
+ if (!Array.isArray(parsed)) return null;
559
+ return parsed.flatMap((s) => s && typeof s === "object" && typeof s.name === "string" ? [s.name] : []);
560
+ } catch {
561
+ return null;
562
+ }
563
+ }
564
+ function invisibleTables(metaRows, exposed) {
565
+ const exposedNames = new Set(exposed.map((t) => t.name));
566
+ const rlsByTable = /* @__PURE__ */ new Map();
567
+ for (const row of metaRows) rlsByTable.set(row.table_name, row.rls_enabled);
568
+ const rlsOff = [];
569
+ const notGranted = [];
570
+ for (const [name, rls] of [...rlsByTable].sort(([a], [b]) => a.localeCompare(b))) {
571
+ if (!rls) rlsOff.push(name);
572
+ else if (!exposedNames.has(name)) notGranted.push(name);
573
+ }
574
+ return { rlsOff, notGranted };
575
+ }
576
+ function parseJsonRpcResponse(contentType, body, id) {
577
+ const candidates = contentType.includes("text/event-stream") ? body.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()) : [body];
578
+ for (const raw of candidates) {
579
+ try {
580
+ const msg = JSON.parse(raw);
581
+ if (msg && msg.id === id) return msg;
582
+ } catch {
583
+ }
584
+ }
585
+ return null;
586
+ }
587
+ function isGatewayJwtRejection(status, body) {
588
+ return status === 401 && /UNAUTHORIZED_|Missing authorization header|Invalid JWT/i.test(body);
589
+ }
590
+ async function probeFunction(functionUrl, fetchImpl, auth) {
591
+ const attempt = async (bearer, authUsed) => {
592
+ let nextId = 1;
593
+ const call = async (method, params) => {
594
+ const id = nextId++;
595
+ const res = await fetchImpl(functionUrl, {
596
+ method: "POST",
597
+ headers: {
598
+ "content-type": "application/json",
599
+ accept: "application/json, text/event-stream",
600
+ ...bearer ? { authorization: `Bearer ${bearer}` } : {},
601
+ ...authUsed === "anon-key" && bearer ? { apikey: bearer } : {}
602
+ },
603
+ body: JSON.stringify({ jsonrpc: "2.0", id, method, params })
604
+ });
605
+ const body = await res.text();
606
+ return { res, body, msg: res.ok ? parseJsonRpcResponse(res.headers.get("content-type") ?? "", body, id) : null };
607
+ };
608
+ const init = await call("initialize", {
609
+ protocolVersion: "2025-06-18",
610
+ capabilities: {},
611
+ clientInfo: { name: "exposify-supabase-doctor", version: "0" }
612
+ });
613
+ if (init.res.status === 404) return { kind: "not-found" };
614
+ if (isGatewayJwtRejection(init.res.status, init.body)) return { kind: "gateway-jwt", body: init.body.trim(), authUsed };
615
+ if (init.res.status === 401) return { kind: "auth-required", authUsed };
616
+ if (!init.res.ok) return { kind: "error", detail: `initialize: HTTP ${init.res.status} ${init.body.trim()}`.trim() };
617
+ if (!init.msg || init.msg.error) {
618
+ return { kind: "error", detail: `initialize: unexpected response ${JSON.stringify(init.msg?.error ?? init.body.slice(0, 200))}` };
619
+ }
620
+ const list = await call("tools/list", {});
621
+ const noTools = list.msg?.error?.code === -32601;
622
+ const tools = noTools ? [] : list.msg?.result?.tools;
623
+ if (!Array.isArray(tools)) {
624
+ return { kind: "error", detail: `tools/list: HTTP ${list.res.status} ${JSON.stringify(list.msg?.error ?? list.body.slice(0, 200))}` };
625
+ }
626
+ let diagnostics = null;
627
+ const resources = await call("resources/list", {});
628
+ const listed = resources.msg?.result?.resources ?? [];
629
+ if (listed.some((r) => r.uri === DIAGNOSTICS_URI)) {
630
+ const read = await call("resources/read", { uri: DIAGNOSTICS_URI });
631
+ const contents = read.msg?.result?.contents;
632
+ diagnostics = typeof contents?.[0]?.text === "string" ? contents[0].text : "(diagnostics resource present but unreadable)";
633
+ }
634
+ return { kind: "ok", tools: tools.map((t) => String(t.name)), diagnostics, authUsed };
635
+ };
636
+ try {
637
+ if (auth.token) return await attempt(auth.token, "token");
638
+ const first = await attempt(void 0, "none");
639
+ if (first.kind === "gateway-jwt" && auth.anonKey) return await attempt(auth.anonKey, "anon-key");
640
+ return first;
641
+ } catch (err) {
642
+ return { kind: "error", detail: err instanceof Error ? err.message : String(err) };
643
+ }
644
+ }
645
+ function functionUrlFor(projectUrl) {
646
+ return `${projectUrl.replace(/\/+$/, "")}/functions/v1/${FUNCTION_NAME}`;
647
+ }
648
+ async function runDoctor(deps) {
649
+ const sections = [];
650
+ const project = [];
651
+ const toml = await deps.readText(CONFIG_TOML);
652
+ project.push(
653
+ toml !== null ? { status: "pass", message: `${CONFIG_TOML} found` } : { status: "warn", message: `no ${CONFIG_TOML} here`, fix: "run from your Supabase project root (or `supabase init` first)" }
654
+ );
655
+ if (deps.runSupabase) {
656
+ let version = "";
657
+ try {
658
+ version = deps.runSupabase(["--version"]).trim().split("\n")[0] ?? "";
659
+ } catch {
660
+ }
661
+ project.push({ status: "pass", message: `Supabase CLI ${version}`.trim() });
662
+ } else {
663
+ project.push({ status: "warn", message: "Supabase CLI not on PATH", fix: "install it: https://supabase.com/docs/guides/cli" });
664
+ }
665
+ project.push(
666
+ deps.projectRef ? { status: "pass", message: `project ${deps.projectRef}` } : { status: "warn", message: "no linked hosted project", fix: "supabase link --project-ref <ref>" }
667
+ );
668
+ sections.push({ title: "Project", checks: project });
669
+ const files = [];
670
+ const migration = findExistingMigration(await deps.listDir(MIGRATIONS_DIR), "_exposify_meta.sql");
671
+ files.push(
672
+ migration ? { status: "pass", message: `migration ${MIGRATIONS_DIR}/${migration}` } : { status: "fail", message: "no *_exposify_meta.sql migration", fix: "npx exposify-supabase init" }
673
+ );
674
+ const functionSource = await deps.readText(FUNCTION_PATH);
675
+ const authMode = functionSource === null ? null : detectAuthMode(functionSource);
676
+ files.push(
677
+ functionSource !== null ? { status: "pass", message: `function ${FUNCTION_PATH} (auth: ${AUTH_LABEL[authMode]})` } : { status: "fail", message: `no ${FUNCTION_PATH}`, fix: "npx exposify-supabase init" }
678
+ );
679
+ if (bypassesGatewayJwt2(authMode)) {
680
+ const verifyJwt = toml === null ? null : readFunctionVerifyJwt(toml);
681
+ const oauth = authMode === "oauth";
682
+ files.push(
683
+ verifyJwt === false ? {
684
+ status: "pass",
685
+ message: `verify_jwt = false for ${FUNCTION_NAME} (${oauth ? "the OAuth login challenge needs tokenless requests through" : "broker tokens aren't JWTs"})`
686
+ } : {
687
+ status: "fail",
688
+ message: `verify_jwt is ${verifyJwt === null ? "unset (defaults to on)" : "on"} for ${FUNCTION_NAME}; ` + (oauth ? "MCP clients will never get the OAuth login challenge" : "the gateway will reject every extb_... token"),
689
+ fix: "re-run `npx exposify-supabase init` (it sets it), or add `[functions.mcp] verify_jwt = false` to config.toml and redeploy"
690
+ }
691
+ );
692
+ }
693
+ sections.push({ title: "Local files", checks: files });
694
+ const db = [];
695
+ const conn = deps.connection;
696
+ if (!conn) {
697
+ db.push({
698
+ status: "fail",
699
+ message: "no credentials to reach the project",
700
+ fix: "start the local stack (`supabase start`), link a project (`supabase link`), or pass --url/--key" + (deps.connectionNotes.length ? ` [${deps.connectionNotes.join(" ")}]` : "")
701
+ });
702
+ } else {
703
+ db.push({ status: "pass", message: `credentials from ${conn.label}` });
704
+ const postgrestUrl = `${conn.url.replace(/\/+$/, "")}/rest/v1`;
705
+ const headers = new Headers({ authorization: `Bearer ${conn.serviceRoleKey}`, apikey: conn.serviceRoleKey });
706
+ const [openapi, meta] = await Promise.allSettled([
707
+ fetchOpenApi(postgrestUrl, headers, deps.fetch),
708
+ fetchExposifyMeta(postgrestUrl, headers, deps.fetch)
709
+ ]);
710
+ if (meta.status === "rejected") {
711
+ const err = meta.reason;
712
+ db.push(
713
+ err instanceof MissingExposifyMetaError ? { status: "fail", message: "exposify_meta() isn't in the database yet", fix: "supabase db push" } : { status: "fail", message: `exposify_meta() call failed: ${err instanceof Error ? err.message : String(err)}` }
714
+ );
715
+ } else {
716
+ const tableCount = new Set(meta.value.map((r) => r.table_name)).size;
717
+ db.push({ status: "pass", message: `exposify_meta() applied (${tableCount} table(s) in the catalog)` });
718
+ }
719
+ if (openapi.status === "rejected") {
720
+ const reason = openapi.reason instanceof Error ? openapi.reason.message : String(openapi.reason);
721
+ db.push({ status: "fail", message: reason.startsWith("PostgREST") ? reason : `PostgREST schema fetch failed: ${reason}` });
722
+ } else {
723
+ db.push({ status: "pass", message: "PostgREST schema readable" });
724
+ }
725
+ if (openapi.status === "fulfilled" && meta.status === "fulfilled") {
726
+ const tables = introspectTables(openapi.value, meta.value);
727
+ db.push(
728
+ tables.length > 0 ? { status: "pass", message: `${tables.length} table(s) exposed as tools: ${tables.map((t) => t.name).join(", ")}` } : { status: "warn", message: "no tables exposed (none have RLS on and API grants)", fix: "enable RLS on the tables agents should read" }
729
+ );
730
+ const { rlsOff, notGranted } = invisibleTables(meta.value, tables);
731
+ if (rlsOff.length > 0) {
732
+ db.push({
733
+ status: "warn",
734
+ message: `RLS off, so invisible to agents: ${rlsOff.join(", ")}`,
735
+ fix: "alter table <name> enable row level security; (only for tables agents should see)"
736
+ });
737
+ }
738
+ if (notGranted.length > 0) {
739
+ db.push({
740
+ status: "warn",
741
+ message: `RLS on but not exposed by PostgREST (no API role grants): ${notGranted.join(", ")}`,
742
+ fix: "grant select on <name> to authenticated;"
743
+ });
744
+ }
745
+ const undocumented = tablesMissingDocs(tables).filter((t) => t.missingTableComment || t.columnsWithoutComments.length > 0);
746
+ if (undocumented.length > 0) {
747
+ db.push({
748
+ status: "warn",
749
+ message: `${undocumented.length} table(s) missing comments, so tools have thin descriptions: ${undocumented.map((t) => t.name).join(", ")}`,
750
+ fix: "re-run `npx exposify-supabase init` and write descriptions, or add COMMENT ON TABLE/COLUMN"
751
+ });
752
+ }
753
+ }
754
+ }
755
+ sections.push({ title: "Database", checks: db });
756
+ const fn = [];
757
+ if (!conn) {
758
+ fn.push({ status: "skip", message: "skipped (no project URL)" });
759
+ } else {
760
+ const url = functionUrlFor(conn.url);
761
+ const outcome = await probeFunction(url, deps.fetch, { token: deps.token, anonKey: conn.anonKey });
762
+ fn.push(...probeChecks(url, outcome, authMode, conn.source === "local"));
763
+ }
764
+ if (authMode === "broker") fn.push(licenseSecretCheck(deps));
765
+ sections.push({ title: "Deployed function", checks: fn });
766
+ return sections;
767
+ }
768
+ function probeChecks(url, outcome, authMode, local) {
769
+ switch (outcome.kind) {
770
+ case "ok": {
771
+ const via = outcome.authUsed === "token" ? " with --token" : outcome.authUsed === "anon-key" ? " as the anon role" : "";
772
+ const checks = [
773
+ { status: outcome.tools.length > 0 ? "pass" : "warn", message: `${url} answers MCP${via}: ${outcome.tools.length} tool(s)` }
774
+ ];
775
+ if (outcome.diagnostics !== null) {
776
+ checks.push({
777
+ status: "warn",
778
+ message: `function is running degraded: ${outcome.diagnostics.replace(/\s+/g, " ").slice(0, 300)}`,
779
+ fix: "see the README's degradation section; usually a missing migration or SUPABASE_SERVICE_ROLE_KEY in the function"
780
+ });
781
+ }
782
+ return checks;
783
+ }
784
+ case "not-found":
785
+ return [
786
+ {
787
+ status: "fail",
788
+ message: `${url} returned 404 (function not deployed)`,
789
+ fix: local ? "supabase functions serve" : "supabase functions deploy mcp" + (bypassesGatewayJwt2(authMode) ? " --no-verify-jwt" : "")
790
+ }
791
+ ];
792
+ case "gateway-jwt":
793
+ return [
794
+ bypassesGatewayJwt2(authMode) ? {
795
+ status: "fail",
796
+ message: `the gateway rejected the request before the function ran: ${outcome.body}`,
797
+ fix: "supabase functions deploy mcp --no-verify-jwt (and keep verify_jwt = false in config.toml)"
798
+ } : {
799
+ status: "warn",
800
+ message: `${url} is deployed but the gateway wants a JWT to go further`,
801
+ fix: "pass --token <a user's access token> to list tools"
802
+ }
803
+ ];
804
+ case "auth-required":
805
+ if (bypassesGatewayJwt2(authMode) && outcome.authUsed === "anon-key") {
806
+ return [
807
+ {
808
+ status: "fail",
809
+ message: authMode === "oauth" ? `the gateway still checks JWTs for ${url}, so MCP clients never reach the function's OAuth login challenge` : `the gateway still checks JWTs for ${url}, so every extb_... broker token is rejected before the function runs`,
810
+ fix: "supabase functions deploy mcp --no-verify-jwt (and keep verify_jwt = false in config.toml)"
811
+ }
812
+ ];
813
+ }
814
+ return [
815
+ outcome.authUsed === "token" ? { status: "fail", message: `${url} rejected --token with 401`, fix: "check the token hasn't expired and belongs to this project" } : {
816
+ status: "warn",
817
+ message: `${url} is deployed and requires auth (authRequired)`,
818
+ fix: `pass --token <${authMode === "broker" ? "an extb_... broker token" : authMode === "oauth" ? "an OAuth access token" : "a user's access token"}> to list tools`
819
+ }
820
+ ];
821
+ case "error":
822
+ return [{ status: "fail", message: `couldn't talk MCP to ${url}: ${outcome.detail}` }];
823
+ }
824
+ }
825
+ function licenseSecretCheck(deps) {
826
+ if (!deps.runSupabase || !deps.projectRef) {
827
+ return { status: "skip", message: `${KEY_SECRET} secret not checked (needs the Supabase CLI and a linked project)` };
828
+ }
829
+ let names = null;
830
+ try {
831
+ names = parseSecretNames(deps.runSupabase(["secrets", "list", "--project-ref", deps.projectRef, "-o", "json"]));
832
+ } catch {
833
+ }
834
+ if (names === null) return { status: "skip", message: `${KEY_SECRET} secret not checked (\`supabase secrets list\` failed)` };
835
+ return names.includes(KEY_SECRET) ? { status: "pass", message: `${KEY_SECRET} secret is set` } : {
836
+ status: "fail",
837
+ message: `${KEY_SECRET} secret is missing; every request will 401`,
838
+ fix: `supabase secrets set ${KEY_SECRET}=<your secret key from the Exposify dashboard (exk_...)>`
839
+ };
840
+ }
841
+ var SYMBOL = { pass: "\u2713", warn: "!", fail: "\u2717", skip: "-" };
842
+ function formatReport(sections) {
843
+ const lines = [];
844
+ for (const section of sections) {
845
+ lines.push(section.title);
846
+ for (const c of section.checks) {
847
+ lines.push(` ${SYMBOL[c.status]} ${c.message}`);
848
+ if (c.fix && (c.status === "warn" || c.status === "fail")) lines.push(` \u2192 ${c.fix}`);
849
+ }
850
+ lines.push("");
851
+ }
852
+ const all = sections.flatMap((s) => s.checks);
853
+ const count = (s) => all.filter((c) => c.status === s).length;
854
+ lines.push(`${count("pass")} passed, ${count("warn")} warning(s), ${count("fail")} failed`);
855
+ return lines.join("\n");
856
+ }
857
+ function hasFailures(sections) {
858
+ return sections.some((s) => s.checks.some((c) => c.status === "fail"));
859
+ }
860
+
861
+ // src/naming.ts
862
+ function sanitizeIdentifier(name) {
863
+ return name.toLowerCase().replace(/[^a-z0-9_]/g, "_").replace(/_+/g, "_").replace(/^_|_$/g, "");
864
+ }
865
+
866
+ // bin/plan.ts
867
+ var FUNCTION_DIR = "supabase/functions/mcp";
868
+ var FUNCTION_PATH2 = `${FUNCTION_DIR}/index.ts`;
869
+ var MIGRATIONS_DIR2 = "supabase/migrations";
870
+ var CONFIG_TOML2 = "supabase/config.toml";
871
+ var META_SUFFIX = "_exposify_meta.sql";
872
+ var COMMENTS_SUFFIX = "_exposify_comments.sql";
873
+ function authLabel(config) {
874
+ return config.broker ? "hosted broker" : config.oauth ? "Supabase Auth OAuth" : "user JWT";
875
+ }
876
+ function describeConfigChange(from, to) {
877
+ const parts = [];
878
+ const diff = (label, a, b) => {
879
+ const added = b.filter((x) => !a.includes(x)).map((x) => `+${x}`);
880
+ const removed = a.filter((x) => !b.includes(x)).map((x) => `-${x}`);
881
+ if (added.length || removed.length) parts.push(`${label}: ${[...added, ...removed].join(" ")}`);
882
+ };
883
+ diff("writes", from.writes, to.writes);
884
+ diff("rpc", from.rpc, to.rpc);
885
+ if (from.anonymousSignIn !== to.anonymousSignIn) {
886
+ parts.push(to.anonymousSignIn ? "+anonymous sign-in warning" : "-anonymous sign-in warning");
887
+ }
888
+ if (authLabel(from) !== authLabel(to)) {
889
+ parts.push(`auth: ${authLabel(from)} -> ${authLabel(to)}`);
890
+ } else if (from.broker?.brokerUrl !== to.broker?.brokerUrl) {
891
+ parts.push("hosted broker settings");
892
+ }
893
+ return parts.join("; ");
894
+ }
895
+ function planInit(input) {
896
+ const changes = [];
897
+ const warnings = [];
898
+ let mergeSnippet = null;
899
+ const taken = [...input.migrations];
900
+ if (!findExistingMigration(input.migrations, META_SUFFIX)) {
901
+ const name = uniqueMigrationFilename(taken, `${input.timestamp}${META_SUFFIX}`);
902
+ taken.push(name);
903
+ changes.push({
904
+ path: `${MIGRATIONS_DIR2}/${name}`,
905
+ action: "create",
906
+ summary: "exposify_meta() migration",
907
+ content: input.metaMigrationSql,
908
+ effect: "push"
909
+ });
910
+ }
911
+ const fn = input.functionFile;
912
+ const canWrite = !fn || fn.state.kind === "managed";
913
+ const fileConfig = canWrite ? input.desired : fn.state.config;
914
+ if (!fn) {
915
+ changes.push({
916
+ path: FUNCTION_PATH2,
917
+ action: "create",
918
+ summary: "MCP edge function",
919
+ content: buildFunctionFile(input.desired),
920
+ effect: "deploy"
921
+ });
922
+ } else if (!configsEqual(fn.state.config, input.desired)) {
923
+ const summary = describeConfigChange(fn.state.config, input.desired);
924
+ if (fn.state.kind === "managed") {
925
+ changes.push({
926
+ path: FUNCTION_PATH2,
927
+ action: "update",
928
+ summary,
929
+ content: replaceManagedBlock(fn.text, input.desired),
930
+ effect: "deploy"
931
+ });
932
+ } else {
933
+ mergeSnippet = buildManagedBlock(input.desired);
934
+ warnings.push(
935
+ fn.state.kind === "modified" ? `${FUNCTION_PATH2}'s exposify:begin/end block was edited by hand, so it was left untouched (${summary}). Merge the block below in yourself.` : `${FUNCTION_PATH2} has no exposify:begin/end block (hand-written, or generated by an older init), so it was left untouched (${summary}). Merge the block below in yourself.`
936
+ );
937
+ }
938
+ }
939
+ if (bypassesGatewayJwt(input.desired) && !bypassesGatewayJwt(fileConfig)) {
940
+ warnings.push(
941
+ `${CONFIG_TOML2} was left alone too: verify_jwt stays on until the ${input.desired.oauth ? "OAuth" : "broker"} wiring is actually in ${FUNCTION_PATH2} (turning it off first would let tokenless requests run as anon). Re-run init after merging.`
942
+ );
943
+ } else if (bypassesGatewayJwt(fileConfig)) {
944
+ if (input.configToml === null) {
945
+ warnings.push(
946
+ `no ${CONFIG_TOML2} to record verify_jwt = false in -- always deploy with \`supabase functions deploy mcp --no-verify-jwt\` (${gatewayJwtReason(fileConfig)}).`
947
+ );
948
+ } else {
949
+ const { toml, changed } = setFunctionVerifyJwtFalse(input.configToml);
950
+ if (changed) {
951
+ changes.push({
952
+ path: CONFIG_TOML2,
953
+ action: "update",
954
+ summary: `[functions.mcp] verify_jwt = false (${gatewayJwtReason(fileConfig)})`,
955
+ content: toml,
956
+ effect: "deploy"
957
+ });
958
+ }
959
+ }
960
+ } else if (fn && bypassesGatewayJwt(fn.state.config) && canWrite && input.configToml !== null && readFunctionVerifyJwt(input.configToml) === false) {
961
+ warnings.push(
962
+ `the ${fn.state.config.oauth ? "OAuth sign-in" : "hosted broker"} was removed, but ${CONFIG_TOML2} still has verify_jwt = false for mcp, so requests without a JWT reach the function and run as anon. If you want the gateway to require a JWT again, set verify_jwt = true there and redeploy.`
963
+ );
964
+ }
965
+ if (input.commentsSql) {
966
+ const name = uniqueMigrationFilename(taken, `${input.timestamp}${COMMENTS_SUFFIX}`);
967
+ changes.push({
968
+ path: `${MIGRATIONS_DIR2}/${name}`,
969
+ action: "create",
970
+ summary: "table/column descriptions",
971
+ content: input.commentsSql,
972
+ effect: "push"
973
+ });
974
+ }
975
+ return { changes, fileConfig, mergeSnippet, warnings };
976
+ }
977
+ function needsPush(changes) {
978
+ return changes.some((c) => c.effect === "push");
979
+ }
980
+ function needsDeploy(changes) {
981
+ return changes.some((c) => c.effect === "deploy");
982
+ }
983
+ function formatPlan(changes) {
984
+ return changes.map((c) => ` ${c.action === "create" ? "+" : "~"} ${c.path}${c.summary ? ` (${c.summary})` : ""}`).join("\n");
985
+ }
986
+ var IDENT = `(?:"(?:[^"]|"")+"|[A-Za-z_][A-Za-z0-9_$]*)`;
987
+ function unquote(ident) {
988
+ return ident.startsWith('"') ? ident.slice(1, -1).replace(/""/g, '"') : ident.toLowerCase();
989
+ }
990
+ function parseCommentMigration(sql) {
991
+ const keys = /* @__PURE__ */ new Set();
992
+ const value = `\\s+is\\s+(?:'|E'|\\$)`;
993
+ const table = new RegExp(`comment\\s+on\\s+table\\s+(?:public\\.)?(${IDENT})${value}`, "gi");
994
+ const column = new RegExp(`comment\\s+on\\s+column\\s+(?:public\\.)?(${IDENT})\\.(${IDENT})${value}`, "gi");
995
+ for (const m of sql.matchAll(table)) keys.add(unquote(m[1]));
996
+ for (const m of sql.matchAll(column)) keys.add(`${unquote(m[1])}.${unquote(m[2])}`);
997
+ return keys;
998
+ }
999
+ function withoutLocallyDocumented(status, documented) {
1000
+ return status.map((t) => ({
1001
+ name: t.name,
1002
+ missingTableComment: t.missingTableComment && !documented.has(t.name),
1003
+ columnsWithoutComments: t.columnsWithoutComments.filter((c) => !documented.has(`${t.name}.${c}`))
1004
+ }));
1005
+ }
1006
+ function pendingComments(tables, documented) {
1007
+ const pending = [];
1008
+ for (const t of tables) {
1009
+ if (documented.has(t.name) && !t.description) pending.push(t.name);
1010
+ for (const c of t.columns) if (documented.has(`${t.name}.${c.name}`) && !c.description) pending.push(`${t.name}.${c.name}`);
1011
+ }
1012
+ return pending;
1013
+ }
1014
+ function staleFunctionReason(probe, config, exposedTables) {
1015
+ if (bypassesGatewayJwt(config) && probe.kind === "ok" && probe.authUsed !== "token") {
1016
+ return config.oauth ? "the deployed function answers without a token, but the local config requires an OAuth sign-in." : "the deployed function answers without a token, but the local config requires the hosted broker's.";
1017
+ }
1018
+ if (bypassesGatewayJwt(config) && (probe.kind === "gateway-jwt" || "authUsed" in probe && probe.authUsed === "anon-key")) {
1019
+ return config.oauth ? "the gateway still checks JWTs for the function, so MCP clients never get the login challenge (deploy with --no-verify-jwt)." : "the gateway still checks JWTs for the function, so broker tokens are rejected (deploy with --no-verify-jwt).";
1020
+ }
1021
+ if (probe.kind !== "ok" || !exposedTables) return null;
1022
+ const exposed = new Set(exposedTables.map((t) => t.name));
1023
+ const expected = /* @__PURE__ */ new Set();
1024
+ for (const w of config.writes) {
1025
+ const dot = w.lastIndexOf(".");
1026
+ const table = w.slice(0, dot);
1027
+ if (!exposed.has(table)) continue;
1028
+ const op = w.slice(dot + 1);
1029
+ for (const o of op === "*" ? ["insert", "update", "delete"] : [op]) expected.add(`${o}_${sanitizeIdentifier(table)}`);
1030
+ }
1031
+ for (const fn of config.rpc) expected.add(`rpc_${sanitizeIdentifier(fn)}`);
1032
+ const live = new Set(probe.tools.filter((t) => /^(insert|update|delete|rpc)_/.test(t)));
1033
+ const missing = [...expected].filter((t) => !live.has(t));
1034
+ const extra = [...live].filter((t) => !expected.has(t));
1035
+ if (missing.length === 0 && extra.length === 0) return null;
1036
+ const parts = [missing.length && `missing ${missing.join(", ")}`, extra.length && `still has ${extra.join(", ")}`].filter(Boolean);
1037
+ return `the deployed function's tools don't match the local config (${parts.join("; ")}).`;
1038
+ }
1039
+ function liveVerdict(s) {
1040
+ const notes = [];
1041
+ let push = false;
1042
+ let deploy = false;
1043
+ if (s.migrationApplied === false) {
1044
+ push = true;
1045
+ notes.push("exposify_meta() isn't in the database yet (migration not pushed).");
1046
+ }
1047
+ if (s.pendingComments.length > 0) {
1048
+ push = true;
1049
+ notes.push(`descriptions written locally but not pushed: ${s.pendingComments.join(", ")}.`);
1050
+ }
1051
+ if (s.functionDeployed === false) {
1052
+ deploy = true;
1053
+ notes.push("the mcp function isn't deployed.");
1054
+ } else if (s.functionStale) {
1055
+ deploy = true;
1056
+ notes.push(s.functionStale);
1057
+ }
1058
+ if (s.migrationApplied === null) notes.push("couldn't check whether the migration is applied.");
1059
+ if (s.functionDeployed === null) notes.push("couldn't check whether the function is deployed.");
1060
+ const upToDate = !push && !deploy && s.migrationApplied === true && s.functionDeployed === true;
1061
+ return { upToDate, push, deploy, notes };
1062
+ }
1063
+ function manifestsEquivalent(a, b) {
1064
+ if (!b || typeof b !== "object") return false;
1065
+ const strip = (m) => JSON.stringify({ ...m, generatedAt: void 0 });
1066
+ return strip(a) === strip(b);
1067
+ }
1068
+
1069
+ // bin/cli.ts
1070
+ var PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
310
1071
  var FETCH_TIMEOUT_MS = 1e4;
311
1072
  function fetchWithTimeout(input, init) {
312
1073
  return fetch(input, { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
@@ -318,6 +1079,13 @@ async function listFilesOrEmpty(dir) {
318
1079
  return [];
319
1080
  }
320
1081
  }
1082
+ async function readOrNull(path) {
1083
+ try {
1084
+ return await readFile(path, "utf8");
1085
+ } catch {
1086
+ return null;
1087
+ }
1088
+ }
321
1089
  async function exists(path) {
322
1090
  try {
323
1091
  await access(path);
@@ -347,51 +1115,115 @@ async function promptYesNo(rl, question, defaultYes = false) {
347
1115
  async function promptText(rl, question) {
348
1116
  return (await rl.question(question)).trim();
349
1117
  }
350
- function deployArgs(broker) {
351
- return ["functions", "deploy", "mcp", ...broker ? ["--no-verify-jwt"] : []];
1118
+ var OAUTH_DASHBOARD_STEP = "In the Supabase dashboard (Authentication > OAuth Server, beta): enable the server and dynamic client registration, set the authorization path to a consent page your app hosts (built with supabase.auth.oauth.approveAuthorization), and make sure the project uses asymmetric JWT signing keys (supabaseAuth() verifies tokens via JWKS)";
1119
+ function deployArgs(auth) {
1120
+ return ["functions", "deploy", "mcp", ...bypassesGatewayJwt(auth) ? ["--no-verify-jwt"] : []];
352
1121
  }
353
- function clientCredential(broker) {
354
- return broker ? "a broker token (extb_..., minted by the hosted broker)" : "a user JWT";
1122
+ function clientStep(auth) {
1123
+ if (auth.oauth) return "Add the function URL to an MCP client; it opens a Supabase Auth login on connect.";
1124
+ const credential = auth.broker ? "a connection token (extb_..., from the hosted broker)" : "a user JWT";
1125
+ return `Paste the function URL + ${credential} into an MCP client.`;
355
1126
  }
356
- function printManualCommands(opts = {}) {
357
- const broker = opts.broker ?? null;
1127
+ function printManualCommands(steps, auth) {
358
1128
  console.log("\nRun these to finish setup:");
359
- const steps = [];
360
- if (!opts.alreadyPushed) steps.push("supabase db push");
361
- if (broker) steps.push(`supabase secrets set ${LICENSE_KEY_SECRET}=<your license key (expfy_...)>`);
362
- if (!opts.alreadyDeployed) steps.push(`supabase ${deployArgs(broker).join(" ")}`);
363
- steps.forEach((cmd, i) => console.log(` ${i + 1}. ${cmd}`));
364
- console.log(` ${steps.length + 1}. Paste the function URL + ${clientCredential(broker)} into an MCP client.`);
365
- }
366
- function printLicenseKeyReminder(broker) {
367
- if (!broker) return;
1129
+ const lines = [];
1130
+ if (steps.push) lines.push("supabase db push");
1131
+ if (auth.broker) lines.push(`supabase secrets set ${KEY_SECRET}=<your secret key from the Exposify dashboard (exk_...)>`);
1132
+ if (auth.oauth) lines.push(OAUTH_DASHBOARD_STEP);
1133
+ if (steps.deploy) lines.push(`supabase ${deployArgs(auth).join(" ")}`);
1134
+ lines.forEach((cmd, i) => console.log(` ${i + 1}. ${cmd}`));
1135
+ console.log(` ${lines.length + 1}. ${clientStep(auth)}`);
1136
+ }
1137
+ function printLicenseKeyReminder(auth) {
1138
+ if (!auth.broker) return;
368
1139
  console.log(
369
1140
  `
370
- If you haven't already: supabase secrets set ${LICENSE_KEY_SECRET}=<your license key (expfy_...)> -- until it's set, every request is rejected with 401.`
1141
+ If you haven't already: supabase secrets set ${KEY_SECRET}=<your secret key (exk_...)> -- until it's set, every request is rejected with 401.`
371
1142
  );
372
1143
  }
373
- function pushAndDeploy(broker) {
374
- let pushed = false;
1144
+ function runSteps(steps, auth) {
1145
+ const done = { push: false, deploy: false };
375
1146
  try {
376
- console.log("\n$ supabase db push");
377
- execFileSync("supabase", ["db", "push"], { stdio: "inherit" });
378
- pushed = true;
379
- const args = deployArgs(broker);
380
- console.log(`
1147
+ if (steps.push) {
1148
+ console.log("\n$ supabase db push");
1149
+ execFileSync("supabase", ["db", "push"], { stdio: "inherit" });
1150
+ done.push = true;
1151
+ }
1152
+ if (steps.deploy) {
1153
+ const args = deployArgs(auth);
1154
+ console.log(`
381
1155
  $ supabase ${args.join(" ")}`);
382
- execFileSync("supabase", args, { stdio: "inherit" });
383
- return { pushed: true, deployed: true };
1156
+ execFileSync("supabase", args, { stdio: "inherit" });
1157
+ done.deploy = true;
1158
+ }
1159
+ return { done };
384
1160
  } catch (err) {
385
- return { pushed, deployed: false, error: err };
1161
+ return { done, error: err };
386
1162
  }
387
1163
  }
388
- function resolveConnectionInfo() {
389
- const url = process.env.SUPABASE_URL ?? argValue("--url");
390
- const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? argValue("--key");
391
- if (!url || !serviceRoleKey) return null;
392
- return { url, serviceRoleKey };
1164
+ async function offerSteps(rl, steps, auth, defaultYes = false) {
1165
+ if (!steps.push && !steps.deploy) return true;
1166
+ if (!rl) {
1167
+ printManualCommands(steps, auth);
1168
+ return false;
1169
+ }
1170
+ if (!hasSupabaseCli()) {
1171
+ console.log("\nSupabase CLI not found on PATH -- install it to run the next steps automatically next time.");
1172
+ printManualCommands(steps, auth);
1173
+ return false;
1174
+ }
1175
+ const what = [steps.push && "`supabase db push`", steps.deploy && "`supabase functions deploy mcp`"].filter(Boolean).join(" and ");
1176
+ if (!await promptYesNo(rl, `
1177
+ Run ${what} against whatever Supabase project is currently linked, now?`, defaultYes)) {
1178
+ printManualCommands(steps, auth);
1179
+ return false;
1180
+ }
1181
+ const result = runSteps(steps, auth);
1182
+ if (result.error !== void 0) {
1183
+ console.error(`
1184
+ Supabase CLI command failed: ${result.error instanceof Error ? result.error.message : result.error}`);
1185
+ printManualCommands({ push: steps.push && !result.done.push, deploy: steps.deploy && !result.done.deploy }, auth);
1186
+ return false;
1187
+ }
1188
+ printLicenseKeyReminder(auth);
1189
+ return true;
1190
+ }
1191
+ function supabaseRunner() {
1192
+ if (!hasSupabaseCli()) return null;
1193
+ return (args) => execFileSync("supabase", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 3e4 });
1194
+ }
1195
+ async function readLinkedProjectRef() {
1196
+ return (await readOrNull(PROJECT_REF_FILE))?.trim() || null;
1197
+ }
1198
+ async function resolveConnection(rl, opts = {}) {
1199
+ const linkedRef = await readLinkedProjectRef();
1200
+ const explicit = explicitConnection({
1201
+ flagUrl: argValue("--url"),
1202
+ flagKey: argValue("--key"),
1203
+ envUrl: process.env.SUPABASE_URL,
1204
+ envKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
1205
+ envAnonKey: process.env.SUPABASE_ANON_KEY
1206
+ });
1207
+ if (explicit) return { connection: explicit, notes: [], projectRef: projectRefFromUrl(explicit.url) };
1208
+ const prefer = process.argv.includes("--local") ? "local" : process.argv.includes("--linked") ? "linked" : void 0;
1209
+ const { connection, notes } = await discoverConnection({
1210
+ run: supabaseRunner(),
1211
+ projectRef: linkedRef,
1212
+ prefer,
1213
+ choose: rl ? async ({ localUrl, projectRef }) => {
1214
+ const useLocal = await promptYesNo(
1215
+ rl,
1216
+ `
1217
+ Both a local stack (${localUrl}) and a linked project (${projectRef}) are available. Use the local stack?`
1218
+ );
1219
+ return useLocal ? "local" : "linked";
1220
+ } : void 0
1221
+ });
1222
+ if (connection && !opts.quiet) console.log(`Using credentials from ${connection.label}.`);
1223
+ return { connection, notes, projectRef: connection?.source === "local" ? null : linkedRef };
393
1224
  }
394
1225
  async function tryIntrospect(conn) {
1226
+ if (!conn) return { introspection: null, migrationApplied: null };
395
1227
  try {
396
1228
  const postgrestUrl = `${conn.url.replace(/\/+$/, "")}/rest/v1`;
397
1229
  const headers = new Headers({ authorization: `Bearer ${conn.serviceRoleKey}`, apikey: conn.serviceRoleKey });
@@ -399,13 +1231,12 @@ async function tryIntrospect(conn) {
399
1231
  fetchOpenApi(postgrestUrl, headers, fetchWithTimeout),
400
1232
  fetchExposifyMeta(postgrestUrl, headers, fetchWithTimeout)
401
1233
  ]);
402
- return { tables: introspectTables(openapi, metaRows), openapi };
1234
+ return { introspection: { tables: introspectTables(openapi, metaRows), openapi }, migrationApplied: true };
403
1235
  } catch (err) {
404
- if (!(err instanceof MissingExposifyMetaError)) {
405
- console.log(`
1236
+ if (err instanceof MissingExposifyMetaError) return { introspection: null, migrationApplied: false };
1237
+ console.log(`
406
1238
  Couldn't introspect the project: ${err instanceof Error ? err.message : String(err)}`);
407
- }
408
- return null;
1239
+ return { introspection: null, migrationApplied: null };
409
1240
  }
410
1241
  }
411
1242
  async function tryDetectAnonymousSignIn(conn) {
@@ -420,7 +1251,7 @@ async function tryDetectAnonymousSignIn(conn) {
420
1251
  return null;
421
1252
  }
422
1253
  }
423
- async function askAnonymousSignIn(rl, conn) {
1254
+ async function askAnonymousSignIn(rl, conn, current) {
424
1255
  const detected = conn ? await tryDetectAnonymousSignIn(conn) : null;
425
1256
  if (detected !== null) {
426
1257
  console.log(`
@@ -428,33 +1259,43 @@ Detected: anonymous sign-in is ${detected ? "enabled" : "disabled"} on this proj
428
1259
  const confirmed = await promptYesNo(rl, "Is that correct?", true);
429
1260
  if (confirmed) return detected;
430
1261
  }
431
- return promptYesNo(rl, "\nDoes this Supabase project have anonymous sign-in enabled (Authentication \u2192 Sign In / Providers \u2192 Anonymous)?");
1262
+ return promptYesNo(
1263
+ rl,
1264
+ "\nDoes this Supabase project have anonymous sign-in enabled (Authentication \u2192 Sign In / Providers \u2192 Anonymous)?",
1265
+ current
1266
+ );
432
1267
  }
433
- async function askWriteGrants(rl, tables) {
434
- if (tables.length === 0) return [];
435
- const configure = await promptYesNo(rl, `
436
- Configure write access for ${tables.length} table(s)?`);
437
- if (!configure) return [];
438
- const grants = [];
1268
+ async function askWriteGrants(rl, tables, current) {
1269
+ if (tables.length === 0) return current;
1270
+ const byTable = writesByTable(current);
1271
+ const intro = current.length > 0 ? ` (currently: ${current.join(", ")})` : "";
1272
+ if (!await promptYesNo(rl, `
1273
+ Configure write access for ${tables.length} table(s)${intro}?`)) return current;
1274
+ console.log(" i=insert, u=update, d=delete, comma-separated. Enter keeps the current value, - clears it.");
1275
+ const result = [];
439
1276
  for (const table of tables) {
440
- const answer = await promptText(
441
- rl,
442
- ` ${table.name} -- grant writes? (i=insert, u=update, d=delete, comma-separate, Enter=none): `
443
- );
444
- const ops = parseWriteOpsAnswer(answer);
445
- if (ops.length > 0) grants.push({ table: table.name, ops });
1277
+ const now = byTable.get(table.name) ?? [];
1278
+ const answer = await promptText(rl, ` ${table.name} [${writesLabel(now)}]: `);
1279
+ const { value, note } = resolveWritesAnswer(answer, table.name, now);
1280
+ if (note) console.log(` ${note}`);
1281
+ result.push(...value);
1282
+ byTable.delete(table.name);
446
1283
  }
447
- return grants;
1284
+ for (const rest of byTable.values()) result.push(...rest);
1285
+ return result;
448
1286
  }
449
- async function askRpcSelection(rl, candidates) {
450
- if (candidates.length === 0) return [];
1287
+ async function askRpcSelection(rl, candidates, current) {
1288
+ if (candidates.length === 0) return current;
451
1289
  console.log(`
452
1290
  Available Postgres functions: ${candidates.join(", ")}`);
453
- const answer = await promptText(rl, "Expose any as tools? (comma-separate, Enter=none): ");
454
- return parseSelectionAnswer(answer, candidates);
1291
+ const hint = current.length > 0 ? `Enter keeps [${current.join(", ")}], - clears` : "Enter=none";
1292
+ const answer = await promptText(rl, `Expose any as tools? (comma-separate, ${hint}): `);
1293
+ const { value, note } = resolveSelectionAnswer(answer, candidates, current);
1294
+ if (note) console.log(` ${note}`);
1295
+ return value;
455
1296
  }
456
- async function askDocumentation(rl, tables) {
457
- const missing = tablesMissingDocs(tables).filter((t) => t.missingTableComment || t.columnsWithoutComments.length > 0);
1297
+ async function askDocumentation(rl, status) {
1298
+ const missing = status.filter((t) => t.missingTableComment || t.columnsWithoutComments.length > 0);
458
1299
  if (missing.length === 0) return [];
459
1300
  const configure = await promptYesNo(
460
1301
  rl,
@@ -485,217 +1326,202 @@ ${missing.length} table(s) are missing documentation MCP tools would otherwise s
485
1326
  }
486
1327
  return entries;
487
1328
  }
488
- async function writeCommentsMigration(entries) {
489
- const sql = buildCommentStatements(entries);
490
- if (!sql) return null;
491
- await mkdir(MIGRATIONS_DIR, { recursive: true });
492
- const existing = await listFilesOrEmpty(MIGRATIONS_DIR);
493
- const filename = uniqueMigrationFilename(existing, `${timestamp()}_exposify_comments.sql`);
494
- const path = join(MIGRATIONS_DIR, filename);
495
- await writeFile(path, sql);
496
- return path;
497
- }
498
- async function ensureMetaMigration() {
499
- await mkdir(MIGRATIONS_DIR, { recursive: true });
500
- const existing = await listFilesOrEmpty(MIGRATIONS_DIR);
501
- const found = findExistingMigration(existing, "_exposify_meta.sql");
502
- if (found) return { path: join(MIGRATIONS_DIR, found), wasNew: false };
503
- const migrationSql = await readFile(join(PACKAGE_ROOT, "sql/exposify_meta.sql"), "utf8");
504
- const filename = uniqueMigrationFilename(existing, `${timestamp()}_exposify_meta.sql`);
505
- const path = join(MIGRATIONS_DIR, filename);
506
- await writeFile(path, migrationSql);
507
- return { path, wasNew: true };
508
- }
509
1329
  async function warnIfNotSupabaseProject() {
510
- if (!await exists("supabase/config.toml")) {
1330
+ if (!await exists(CONFIG_TOML2)) {
511
1331
  console.warn(
512
1332
  "warn: no supabase/config.toml found here -- this doesn't look like a Supabase project root (or you haven't run `supabase init` yet). Continuing, but double-check you're in the right directory."
513
1333
  );
514
1334
  }
515
1335
  }
516
1336
  function brokerFromFlags() {
517
- const flagUrl = process.env.EXPOSIFY_BROKER_URL ?? argValue("--broker-url");
518
- const flagProject = process.env.EXPOSIFY_PROJECT_ID ?? argValue("--broker-project");
519
- if (!flagUrl && !flagProject) return void 0;
520
- if (!flagUrl || !flagProject) {
521
- throw new Error("the hosted broker needs both --broker-url and --broker-project (or EXPOSIFY_BROKER_URL/EXPOSIFY_PROJECT_ID).");
522
- }
1337
+ const flagUrl = argValue("--broker-url") ?? process.env.EXPOSIFY_BROKER_URL;
1338
+ if (!flagUrl && !process.argv.includes("--broker")) return void 0;
1339
+ if (!flagUrl) return {};
523
1340
  const problem = brokerUrlProblem(flagUrl);
524
1341
  if (problem) throw new Error(problem);
525
- return { brokerUrl: normalizeBrokerUrl(flagUrl), projectId: flagProject.trim() };
1342
+ return { brokerUrl: normalizeBrokerUrl(flagUrl) };
1343
+ }
1344
+ function authFromFlags() {
1345
+ const broker = brokerFromFlags();
1346
+ const oauth = process.argv.includes("--oauth") || process.env.EXPOSIFY_OAUTH === "1";
1347
+ if (broker && oauth) throw new Error("--oauth can't be combined with --broker; pick one.");
1348
+ if (broker) return { broker, oauth: false };
1349
+ if (oauth) return { broker: null, oauth: true };
1350
+ return void 0;
1351
+ }
1352
+ async function askAuth(rl, current) {
1353
+ const broker = await askBroker(rl, current.broker);
1354
+ if (broker) return { broker, oauth: false };
1355
+ const oauth = await promptYesNo(
1356
+ rl,
1357
+ '\nLet MCP clients sign in with Supabase Auth ("click Connect and log in"; needs the OAuth 2.1 server enabled in the dashboard)?',
1358
+ current.oauth
1359
+ );
1360
+ return { broker: null, oauth };
526
1361
  }
527
- async function resolveBroker(rl, fromFlags) {
528
- if (fromFlags) return fromFlags;
529
- if (!rl) return null;
1362
+ async function askBroker(rl, current) {
530
1363
  const use = await promptYesNo(
531
1364
  rl,
532
- "\nUse the Exposify hosted broker for auth (MCP clients get a long-lived extb_... token instead of a Supabase JWT)?"
1365
+ "\nLet agents log in as your users through the Exposify hosted broker (they get a long-lived connection instead of an hourly Supabase JWT)?",
1366
+ current !== null
533
1367
  );
534
1368
  if (!use) return null;
535
- let brokerUrl = "";
536
- for (; ; ) {
537
- brokerUrl = normalizeBrokerUrl(await promptText(rl, " Broker URL (from the dashboard's Hosted broker card): "));
538
- const problem = brokerUrlProblem(brokerUrl);
539
- if (!problem) break;
540
- console.log(` ${problem}`);
541
- }
542
- let projectId = "";
543
- while (!projectId) projectId = await promptText(rl, " Project ID: ");
544
- return { brokerUrl, projectId };
545
- }
546
- async function disableJwtVerification() {
547
- if (!await exists(CONFIG_TOML)) {
548
- console.warn(
549
- `warn: no ${CONFIG_TOML} to record verify_jwt = false in -- always deploy with \`supabase functions deploy mcp --no-verify-jwt\`, or broker tokens will be rejected with "Invalid JWT".`
550
- );
551
- return;
1369
+ return current?.brokerUrl ? { brokerUrl: current.brokerUrl } : {};
1370
+ }
1371
+ async function readLocalState() {
1372
+ const migrations = await listFilesOrEmpty(MIGRATIONS_DIR2);
1373
+ const text = await readOrNull(FUNCTION_PATH2);
1374
+ const documented = /* @__PURE__ */ new Set();
1375
+ for (const name of migrations.filter((m) => m.endsWith(COMMENTS_SUFFIX))) {
1376
+ for (const key of parseCommentMigration(await readOrNull(join(MIGRATIONS_DIR2, name)) ?? "")) documented.add(key);
552
1377
  }
553
- const { toml, changed } = setFunctionVerifyJwtFalse(await readFile(CONFIG_TOML, "utf8"));
554
- if (!changed) {
555
- console.log(`Skipped ${CONFIG_TOML} (verify_jwt already false for mcp)`);
556
- return;
1378
+ return {
1379
+ migrations,
1380
+ functionFile: text === null ? null : { text, state: parseFunctionFile(text) },
1381
+ configToml: await readOrNull(CONFIG_TOML2),
1382
+ documented,
1383
+ metaMigrationSql: await readFile(join(PACKAGE_ROOT, "sql/exposify_meta.sql"), "utf8")
1384
+ };
1385
+ }
1386
+ async function applyPlan(plan) {
1387
+ for (const change of plan.changes) {
1388
+ await mkdir(dirname(change.path), { recursive: true });
1389
+ await writeFile(change.path, change.content);
1390
+ if (change.path === FUNCTION_PATH2 && change.action === "create") await mkdir(join(FUNCTION_DIR, "docs"), { recursive: true });
557
1391
  }
558
- await writeFile(CONFIG_TOML, toml);
559
- console.log(`Updated ${CONFIG_TOML}: [functions.mcp] verify_jwt = false (broker tokens aren't Supabase JWTs)`);
1392
+ if (plan.changes.length > 0) console.log(`
1393
+ Changes:
1394
+ ${formatPlan(plan.changes)}`);
1395
+ for (const w of plan.warnings) console.warn(`
1396
+ warn: ${w}`);
1397
+ if (plan.mergeSnippet) console.log(`
1398
+ ${plan.mergeSnippet}`);
1399
+ }
1400
+ async function runWizard(rl, introspection, base, documented) {
1401
+ const writes = await askWriteGrants(rl, introspection.tables, base.writes);
1402
+ const rpc = await askRpcSelection(rl, rpcCandidatesFromOpenApi(introspection.openapi), base.rpc);
1403
+ const docs = await askDocumentation(rl, withoutLocallyDocumented(tablesMissingDocs(introspection.tables), documented));
1404
+ return { config: { ...base, writes, rpc }, commentsSql: buildCommentStatements(docs) };
1405
+ }
1406
+ async function checkLive(conn, known, local, config) {
1407
+ if (!conn) return { migrationApplied: null, functionDeployed: null, pendingComments: [] };
1408
+ const intro = known?.introspection ? known : await tryIntrospect(conn);
1409
+ const probe = await probeFunction(functionUrlFor(conn.url), fetchWithTimeout, { anonKey: conn.anonKey });
1410
+ return {
1411
+ migrationApplied: intro.migrationApplied,
1412
+ functionDeployed: probe.kind === "not-found" ? false : probe.kind === "error" ? null : true,
1413
+ pendingComments: intro.introspection ? pendingComments(intro.introspection.tables, local.documented) : [],
1414
+ // Only meaningful for a file init manages -- for anything else, `config` is a best-effort guess.
1415
+ functionStale: local.functionFile?.state.kind === "managed" ? staleFunctionReason(probe, config, intro.introspection?.tables ?? null) : null
1416
+ };
560
1417
  }
561
1418
  async function cmdInit() {
562
- const brokerFlags = brokerFromFlags();
1419
+ const check = process.argv.includes("--check");
1420
+ const authFlags = authFromFlags();
563
1421
  await warnIfNotSupabaseProject();
564
- const { path: migrationPath, wasNew } = await ensureMetaMigration();
565
- console.log(wasNew ? `Wrote ${migrationPath}` : `Skipped ${migrationPath} (already scaffolded)`);
566
- await mkdir(FUNCTION_DIR, { recursive: true });
567
- const functionPath = join(FUNCTION_DIR, "index.ts");
568
- const functionExists = await exists(functionPath);
569
- await mkdir(join(FUNCTION_DIR, "docs"), { recursive: true });
570
1422
  const connWarning = partialConnectionWarning(
571
- process.env.SUPABASE_URL ?? argValue("--url"),
572
- process.env.SUPABASE_SERVICE_ROLE_KEY ?? argValue("--key")
1423
+ argValue("--url") ?? process.env.SUPABASE_URL,
1424
+ argValue("--key") ?? process.env.SUPABASE_SERVICE_ROLE_KEY
573
1425
  );
574
1426
  if (connWarning) console.warn(`warn: ${connWarning}`);
575
- const rl = openInteractiveReadline();
576
- if (!rl) {
577
- const broker = await resolveBroker(null, brokerFlags);
578
- if (broker) await disableJwtVerification();
579
- if (!functionExists) {
580
- await writeFile(functionPath, buildFunctionTemplate({ broker: broker ?? void 0 }));
581
- console.log(`Wrote ${functionPath}`);
582
- } else {
583
- console.log(`Skipped ${functionPath} (already exists)`);
584
- if (broker) printBrokerMergeHint(functionPath, broker);
585
- }
586
- printManualCommands({ broker });
587
- return;
588
- }
1427
+ const rl = check ? null : openInteractiveReadline();
589
1428
  try {
590
- const conn = resolveConnectionInfo();
591
- const broker = await resolveBroker(rl, brokerFlags);
592
- if (broker) await disableJwtVerification();
593
- const anonymousSignIn = await askAnonymousSignIn(rl, conn);
594
- let introspection = conn ? await tryIntrospect(conn) : null;
595
- let alreadyPushed = false;
596
- let alreadyDeployed = false;
597
- let brokerHintShown = false;
598
- if (!introspection) {
599
- if (!functionExists) {
600
- await writeFile(functionPath, buildFunctionTemplate({ anonymousSignIn, broker: broker ?? void 0 }));
601
- console.log(`Wrote ${functionPath}`);
602
- } else {
603
- console.log(`Skipped ${functionPath} (already exists)`);
604
- if (broker) {
605
- printBrokerMergeHint(functionPath, broker);
606
- brokerHintShown = true;
607
- }
1429
+ const local = await readLocalState();
1430
+ const current = local.functionFile?.state.config ?? EMPTY_CONFIG;
1431
+ let conn;
1432
+ const getConn = async () => conn !== void 0 ? conn : conn = (await resolveConnection(rl)).connection;
1433
+ const auth = authFlags ?? (rl ? await askAuth(rl, current) : { broker: current.broker, oauth: current.oauth });
1434
+ const anonymousSignIn = rl ? await askAnonymousSignIn(rl, await getConn(), current.anonymousSignIn) : current.anonymousSignIn;
1435
+ let desired = { ...current, ...auth, anonymousSignIn };
1436
+ let intro = null;
1437
+ let commentsSql = "";
1438
+ if (rl) {
1439
+ intro = await tryIntrospect(await getConn());
1440
+ if (intro.introspection) ({ config: desired, commentsSql } = await runWizard(rl, intro.introspection, desired, local.documented));
1441
+ }
1442
+ const plan = planInit({ ...local, desired, commentsSql, timestamp: timestamp() });
1443
+ if (check) {
1444
+ if (plan.changes.length === 0 && !plan.mergeSnippet) {
1445
+ console.log("init --check: up to date, a run would change nothing.");
1446
+ return;
1447
+ }
1448
+ console.log("init --check: a run would change:");
1449
+ if (plan.changes.length > 0) console.log(formatPlan(plan.changes));
1450
+ for (const w of plan.warnings) console.log(` ! ${w}`);
1451
+ process.exitCode = 1;
1452
+ return;
1453
+ }
1454
+ await applyPlan(plan);
1455
+ const effectiveAuth = plan.fileConfig;
1456
+ let steps;
1457
+ if (plan.changes.length === 0 && plan.mergeSnippet) {
1458
+ console.log(`
1459
+ No files were changed. Merge the block above into ${FUNCTION_PATH2}, then re-run init.`);
1460
+ return;
1461
+ }
1462
+ if (plan.changes.length > 0) {
1463
+ steps = { push: needsPush(plan.changes), deploy: needsDeploy(plan.changes) };
1464
+ } else {
1465
+ const c = await getConn();
1466
+ const verdict = liveVerdict(await checkLive(c, intro, local, plan.fileConfig));
1467
+ if (verdict.upToDate) {
1468
+ console.log("\nAlready up to date: no file changes, the migration is applied and the function is deployed. Nothing to do.");
1469
+ return;
608
1470
  }
609
- if (!hasSupabaseCli()) {
610
- console.log("\nSupabase CLI not found on PATH -- install it to run the next steps automatically next time.");
611
- printManualCommands({ broker });
1471
+ if (!c) {
1472
+ console.log(
1473
+ "\nNothing changed locally. Couldn't check the project itself (no --url/--key, env vars, or local/linked project) -- run `npx exposify-supabase doctor` once it's reachable."
1474
+ );
612
1475
  return;
613
1476
  }
614
- const connect = await promptYesNo(
615
- rl,
616
- "\nApply the migration and deploy the function to whatever Supabase project is currently linked, now?"
617
- );
618
- if (!connect) {
619
- printManualCommands({ broker });
1477
+ console.log("\nNothing changed locally, but the project is behind:");
1478
+ for (const n of verdict.notes) console.log(` - ${n}`);
1479
+ if (!verdict.push && !verdict.deploy) {
1480
+ console.log("Run `npx exposify-supabase doctor` for details.");
620
1481
  return;
621
1482
  }
622
- const result2 = pushAndDeploy(broker);
623
- alreadyPushed = result2.pushed;
624
- alreadyDeployed = result2.deployed;
625
- if (!result2.deployed) {
626
- console.error(`
627
- Supabase CLI command failed: ${result2.error instanceof Error ? result2.error.message : result2.error}`);
628
- printManualCommands({ alreadyPushed, broker });
1483
+ if (c.source === "local") {
1484
+ const todo = [verdict.push && "`supabase migration up`", verdict.deploy && `\`supabase functions serve mcp${bypassesGatewayJwt(effectiveAuth) ? " --no-verify-jwt" : ""}\``].filter(Boolean);
1485
+ console.log(`
1486
+ That's the local stack: run ${todo.join(" and ")}.`);
629
1487
  return;
630
1488
  }
631
- printLicenseKeyReminder(broker);
632
- introspection = conn ? await tryIntrospect(conn) : null;
633
- if (!introspection) {
1489
+ steps = { push: verdict.push, deploy: verdict.deploy };
1490
+ }
1491
+ if (!await offerSteps(rl, steps, effectiveAuth)) return;
1492
+ if (rl && !intro?.introspection && steps.push) {
1493
+ const again = await tryIntrospect(await getConn());
1494
+ if (!again.introspection) {
634
1495
  console.log(
635
- "\nDeployed, but couldn't reach the project to introspect tables (no SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY or --url/--key available). Re-run `exposify-supabase init` with those set to configure writes, RPCs, and documentation interactively."
1496
+ "\nDeployed, but couldn't reach the project to introspect tables (no --url/--key, no SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY, and no local stack or linked project the Supabase CLI could supply keys for). Re-run `exposify-supabase init` with those set to configure writes, RPCs, and documentation interactively."
636
1497
  );
637
1498
  console.log(`
638
- Done. Paste the function URL + ${clientCredential(broker)} into an MCP client to try it.`);
1499
+ Done. ${clientStep(effectiveAuth)}`);
1500
+ return;
1501
+ }
1502
+ const local2 = await readLocalState();
1503
+ const base = local2.functionFile?.state.config ?? desired;
1504
+ const wizard = await runWizard(rl, again.introspection, base, local2.documented);
1505
+ const plan2 = planInit({ ...local2, desired: wizard.config, commentsSql: wizard.commentsSql, timestamp: timestamp() });
1506
+ await applyPlan(plan2);
1507
+ if (plan2.changes.length === 0) {
1508
+ console.log("\nNothing more to change.");
1509
+ } else if (!await offerSteps(rl, { push: needsPush(plan2.changes), deploy: needsDeploy(plan2.changes) }, plan2.fileConfig, true)) {
639
1510
  return;
640
1511
  }
641
1512
  }
642
- const grants = await askWriteGrants(rl, introspection.tables);
643
- const rpcCandidates = rpcCandidatesFromOpenApi(introspection.openapi);
644
- const rpc = await askRpcSelection(rl, rpcCandidates);
645
- const docs = await askDocumentation(rl, introspection.tables);
646
- const writes = flattenWriteGrants(grants);
647
- let wroteFunctionFile = false;
648
- if (!functionExists) {
649
- await writeFile(functionPath, buildFunctionTemplate({ writes, rpc, anonymousSignIn, broker: broker ?? void 0 }));
650
- console.log(`
651
- Wrote ${functionPath} with your selections.`);
652
- wroteFunctionFile = true;
653
- } else if (writes.length > 0 || rpc.length > 0 || broker && !brokerHintShown) {
654
- console.log(`
655
- ${functionPath} already exists -- left untouched. Merge this into your aiReady() config:`);
656
- console.log(buildFunctionTemplate({ writes, rpc, anonymousSignIn, broker: broker ?? void 0 }));
657
- }
658
- const commentsPath = await writeCommentsMigration(docs);
659
- if (commentsPath) console.log(`Wrote ${commentsPath}`);
660
- const needsDeploy = wroteFunctionFile || commentsPath !== null;
661
- if (!needsDeploy) {
662
- console.log("\nNothing new to deploy.");
663
- return;
664
- }
665
- if (!hasSupabaseCli()) {
666
- console.log("\nSupabase CLI not found on PATH.");
667
- printManualCommands({ alreadyPushed: commentsPath === null, alreadyDeployed: !wroteFunctionFile, broker });
668
- return;
669
- }
670
- const redeploy = await promptYesNo(rl, "\nApply/deploy these changes now?", true);
671
- if (!redeploy) {
672
- printManualCommands({ alreadyPushed: commentsPath === null, alreadyDeployed: !wroteFunctionFile, broker });
673
- return;
674
- }
675
- const result = pushAndDeploy(broker);
676
- if (!result.deployed) {
677
- console.error(`
678
- Supabase CLI command failed: ${result.error instanceof Error ? result.error.message : result.error}`);
679
- printManualCommands({ alreadyPushed: result.pushed, broker });
680
- return;
681
- }
682
- printLicenseKeyReminder(broker);
683
1513
  console.log(`
684
- Done. Paste the function URL + ${clientCredential(broker)} into an MCP client to try it.`);
1514
+ Done. ${clientStep(effectiveAuth)}`);
685
1515
  } finally {
686
- rl.close();
1516
+ rl?.close();
687
1517
  }
688
1518
  }
689
- function printBrokerMergeHint(functionPath, broker) {
690
- console.log(`
691
- ${functionPath} already exists -- left untouched. Merge the broker wiring into your aiReady() config:`);
692
- console.log(buildFunctionTemplate({ broker }));
693
- }
694
1519
  async function cmdGenerate() {
695
- const conn = resolveConnectionInfo();
1520
+ const check = process.argv.includes("--check");
1521
+ const { connection: conn, notes } = await resolveConnection(null);
696
1522
  if (!conn) {
697
1523
  console.error(
698
- "generate needs SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY (env vars, or --url/--key flags) pointing at a running local or linked project (e.g. `supabase start`). The service role key is used only for this one-time introspection call, matching what runtime introspection itself uses -- never written into the generated manifest."
1524
+ `generate needs a project to introspect: --url/--key flags, SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY env vars, or a running local stack (\`supabase start\`) / linked project (\`supabase link\`) for the Supabase CLI to supply keys for${notes.length ? ` (${notes.join(" ")})` : ""}. The service role key is used only for this one-time introspection call, matching what runtime introspection itself uses -- never written into the generated manifest.`
699
1525
  );
700
1526
  process.exitCode = 1;
701
1527
  return;
@@ -711,9 +1537,40 @@ async function cmdGenerate() {
711
1537
  ]);
712
1538
  const manifest = buildManifest(openapi, metaRows, (/* @__PURE__ */ new Date()).toISOString());
713
1539
  const outPath = join(FUNCTION_DIR, "exposify.manifest.json");
1540
+ const existingRaw = await readOrNull(outPath);
1541
+ let existing = null;
1542
+ try {
1543
+ existing = existingRaw === null ? null : JSON.parse(existingRaw);
1544
+ } catch {
1545
+ }
1546
+ if (manifestsEquivalent(manifest, existing)) {
1547
+ console.log(`${outPath} is up to date (${manifest.tables.length} table(s)); nothing to do.`);
1548
+ return;
1549
+ }
1550
+ if (check) {
1551
+ console.log(`generate --check: ${outPath} is ${existingRaw === null ? "missing" : "out of date"}.`);
1552
+ process.exitCode = 1;
1553
+ return;
1554
+ }
714
1555
  await mkdir(dirname(outPath), { recursive: true });
715
1556
  await writeFile(outPath, JSON.stringify(manifest, null, 2));
716
- console.log(`Wrote ${outPath} (${manifest.tables.length} table(s))`);
1557
+ console.log(`${existingRaw === null ? "Wrote" : "Updated"} ${outPath} (${manifest.tables.length} table(s))`);
1558
+ }
1559
+ async function cmdDoctor() {
1560
+ const rl = openInteractiveReadline();
1561
+ const { connection, notes, projectRef } = await resolveConnection(rl, { quiet: true }).finally(() => rl?.close());
1562
+ const sections = await runDoctor({
1563
+ readText: readOrNull,
1564
+ listDir: listFilesOrEmpty,
1565
+ runSupabase: supabaseRunner(),
1566
+ connection,
1567
+ connectionNotes: notes,
1568
+ projectRef,
1569
+ fetch: fetchWithTimeout,
1570
+ token: argValue("--token")
1571
+ });
1572
+ console.log(formatReport(sections));
1573
+ if (hasFailures(sections)) process.exitCode = 1;
717
1574
  }
718
1575
  function argValue(flag) {
719
1576
  const i = process.argv.indexOf(flag);
@@ -728,18 +1585,26 @@ async function main() {
728
1585
  const [, , cmd] = process.argv;
729
1586
  if (cmd === "init") return cmdInit();
730
1587
  if (cmd === "generate") return cmdGenerate();
1588
+ if (cmd === "doctor") return cmdDoctor();
731
1589
  console.log(
732
- "Usage: exposify-supabase <init|generate> [--url <url>] [--key <service-role-key>] [--broker-url <url> --broker-project <id>]"
1590
+ "Usage: exposify-supabase <init|generate|doctor> [--url <url>] [--key <service-role-key>] [--local|--linked] [--broker [--broker-url <url>] | --oauth] [--token <token>] [--check]"
733
1591
  );
734
- console.log(" init Scaffold the exposify_meta() migration + supabase/functions/mcp/index.ts.");
735
- console.log(" Interactively offers to run `supabase db push` + `functions deploy`; if a");
736
- console.log(" project is reachable (SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY env vars, or --url/--key),");
737
- console.log(" also walks through anonymous sign-in, per-table writes, RPC exposure, and");
738
- console.log(" documentation for tables/columns missing a comment. Offers to wire in the hosted");
739
- console.log(" broker (or pass --broker-url/--broker-project): adds createHostedAuthResolver to the");
740
- console.log(" function, sets verify_jwt = false for it in supabase/config.toml, and deploys with");
741
- console.log(" --no-verify-jwt.");
742
- console.log(" generate Build exposify.manifest.json from a running local/linked project");
1592
+ console.log(" init Scaffold the exposify_meta() migration + supabase/functions/mcp/index.ts, then (when the");
1593
+ console.log(" project is reachable) walk through anonymous sign-in, per-table writes, RPC exposure, and");
1594
+ console.log(" documentation. Safe to re-run: answers default to what's already configured, only the");
1595
+ console.log(" exposify:begin/end block of index.ts is ever rewritten, and a run that changes nothing");
1596
+ console.log(" says so and stops. Offers the hosted broker (or --broker; it reads the EXPOSIFY_KEY secret), or");
1597
+ console.log(" Supabase Auth sign-in (--oauth): adds oauth: supabaseAuth(), so MCP clients open a login");
1598
+ console.log(" on connect. Either way it sets verify_jwt = false for mcp and deploys --no-verify-jwt.");
1599
+ console.log(" --check: change nothing, exit 1 if a run would change a file.");
1600
+ console.log(" generate Build exposify.manifest.json from a running local/linked project; rewritten only when");
1601
+ console.log(" the schema changed. --check: exit 1 if it's missing or out of date.");
1602
+ console.log(" doctor Read-only health check: local files, config.toml, the database migration and");
1603
+ console.log(" exposed tables, and the deployed function answering MCP (tool count). Exits 1 on any");
1604
+ console.log(" failure. --token <user JWT | extb_...> lets it list tools past authRequired.");
1605
+ console.log("");
1606
+ console.log(" Reaching the project: --url/--key, else SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY, else the Supabase");
1607
+ console.log(" CLI supplies keys for a running local stack or the linked project (--local/--linked to pick).");
743
1608
  process.exitCode = cmd ? 1 : 0;
744
1609
  }
745
1610
  main().catch((err) => {