@exposify/supabase 0.3.7 → 0.3.8
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/README.md +240 -23
- package/dist/cli.js +1146 -264
- package/dist/index.cjs +148 -8
- package/dist/index.d.cts +51 -2
- package/dist/index.d.ts +51 -2
- package/dist/index.js +145 -6
- package/package.json +3 -2
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/
|
|
306
|
-
var
|
|
307
|
-
|
|
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 LICENSE_KEY_SECRET = "EXPOSIFY_LICENSE_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
|
+
lines.push(
|
|
430
|
+
config.oauth ? `import { aiReady, supabaseAuth } from "npm:@exposify/supabase";` : `import { aiReady } from "npm:@exposify/supabase";`
|
|
431
|
+
);
|
|
432
|
+
if (config.broker) lines.push(`import { createHostedAuthResolver } from "npm:@exposify/token-broker";`);
|
|
433
|
+
lines.push("", "const generated = {");
|
|
434
|
+
if (config.broker) {
|
|
435
|
+
lines.push(
|
|
436
|
+
" auth: createHostedAuthResolver({",
|
|
437
|
+
` brokerUrl: ${JSON.stringify(config.broker.brokerUrl)},`,
|
|
438
|
+
` projectId: ${JSON.stringify(config.broker.projectId)},`,
|
|
439
|
+
` licenseKey: Deno.env.get(${JSON.stringify(LICENSE_KEY_SECRET)}) ?? "",`,
|
|
440
|
+
" }),",
|
|
441
|
+
" authRequired: true,"
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
if (config.oauth) {
|
|
445
|
+
lines.push(" oauth: supabaseAuth(),");
|
|
446
|
+
}
|
|
447
|
+
lines.push(
|
|
448
|
+
` // "<table>.insert" | "<table>.update" | "<table>.delete" | "<table>.*"`,
|
|
449
|
+
` writes: ${JSON.stringify(config.writes)},`,
|
|
450
|
+
" // Postgres functions exposed as rpc_<fn> tools",
|
|
451
|
+
` rpc: ${JSON.stringify(config.rpc)},`,
|
|
452
|
+
"};",
|
|
453
|
+
END
|
|
454
|
+
);
|
|
455
|
+
return lines.join("\n");
|
|
456
|
+
}
|
|
457
|
+
function buildFunctionFile(config) {
|
|
458
|
+
return [
|
|
459
|
+
buildManagedBlock(config),
|
|
460
|
+
"",
|
|
461
|
+
"Deno.serve(aiReady({",
|
|
462
|
+
" ...generated,",
|
|
463
|
+
" // functions: {",
|
|
464
|
+
' // refund: { description: "Refund an order", input: { type: "object", properties: {} } },',
|
|
465
|
+
" // },",
|
|
466
|
+
"}));",
|
|
467
|
+
""
|
|
468
|
+
].join("\n");
|
|
469
|
+
}
|
|
470
|
+
function readStringArray(text, key) {
|
|
471
|
+
const m = new RegExp(`^\\s*${key}:\\s*(\\[.*\\])\\s*,?\\s*$`, "m").exec(text);
|
|
472
|
+
if (!m) return null;
|
|
473
|
+
try {
|
|
474
|
+
const value = JSON.parse(m[1]);
|
|
475
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string") ? value : null;
|
|
476
|
+
} catch {
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function readStringField(text, key) {
|
|
481
|
+
const m = new RegExp(`^\\s*${key}:\\s*("(?:[^"\\\\]|\\\\.)*")\\s*,?\\s*$`, "m").exec(text);
|
|
482
|
+
if (!m) return null;
|
|
483
|
+
try {
|
|
484
|
+
return JSON.parse(m[1]);
|
|
485
|
+
} catch {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function readConfig(text) {
|
|
490
|
+
const brokerUrl = readStringField(text, "brokerUrl");
|
|
491
|
+
const projectId = readStringField(text, "projectId");
|
|
492
|
+
return {
|
|
493
|
+
writes: readStringArray(text, "writes") ?? [],
|
|
494
|
+
rpc: readStringArray(text, "rpc") ?? [],
|
|
495
|
+
anonymousSignIn: text.includes(ANONYMOUS_MARKER),
|
|
496
|
+
broker: /\bcreateHostedAuthResolver\s*\(/.test(text) && brokerUrl && projectId ? { brokerUrl, projectId } : 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?.brokerUrl === b.broker?.brokerUrl && a.broker?.projectId === b.broker?.projectId && 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 (/\bcreateHostedAuthResolver\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: `${LICENSE_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: `${LICENSE_KEY_SECRET} secret not checked (\`supabase secrets list\` failed)` };
|
|
835
|
+
return names.includes(LICENSE_KEY_SECRET) ? { status: "pass", message: `${LICENSE_KEY_SECRET} secret is set` } : {
|
|
836
|
+
status: "fail",
|
|
837
|
+
message: `${LICENSE_KEY_SECRET} secret is missing; every request will 401`,
|
|
838
|
+
fix: `supabase secrets set ${LICENSE_KEY_SECRET}=<your license key (expfy_...)>`
|
|
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 || from.broker?.projectId !== to.broker?.projectId) {
|
|
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
|
-
|
|
351
|
-
|
|
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
|
|
354
|
-
|
|
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 broker token (extb_..., minted by the hosted broker)" : "a user JWT";
|
|
1125
|
+
return `Paste the function URL + ${credential} into an MCP client.`;
|
|
355
1126
|
}
|
|
356
|
-
function printManualCommands(
|
|
357
|
-
const broker = opts.broker ?? null;
|
|
1127
|
+
function printManualCommands(steps, auth) {
|
|
358
1128
|
console.log("\nRun these to finish setup:");
|
|
359
|
-
const
|
|
360
|
-
if (
|
|
361
|
-
if (broker)
|
|
362
|
-
if (
|
|
363
|
-
steps.
|
|
364
|
-
console.log(` ${
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
|
|
1129
|
+
const lines = [];
|
|
1130
|
+
if (steps.push) lines.push("supabase db push");
|
|
1131
|
+
if (auth.broker) lines.push(`supabase secrets set ${LICENSE_KEY_SECRET}=<your license key (expfy_...)>`);
|
|
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
1141
|
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.`
|
|
371
1142
|
);
|
|
372
1143
|
}
|
|
373
|
-
function
|
|
374
|
-
|
|
1144
|
+
function runSteps(steps, auth) {
|
|
1145
|
+
const done = { push: false, deploy: false };
|
|
375
1146
|
try {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
383
|
-
|
|
1156
|
+
execFileSync("supabase", args, { stdio: "inherit" });
|
|
1157
|
+
done.deploy = true;
|
|
1158
|
+
}
|
|
1159
|
+
return { done };
|
|
384
1160
|
} catch (err) {
|
|
385
|
-
return {
|
|
1161
|
+
return { done, error: err };
|
|
386
1162
|
}
|
|
387
1163
|
}
|
|
388
|
-
function
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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 (
|
|
405
|
-
|
|
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(
|
|
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
|
|
436
|
-
|
|
437
|
-
if (!
|
|
438
|
-
|
|
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
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
);
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
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
|
|
454
|
-
|
|
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,
|
|
457
|
-
const missing =
|
|
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,37 +1326,16 @@ ${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(
|
|
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 =
|
|
518
|
-
const flagProject =
|
|
1337
|
+
const flagUrl = argValue("--broker-url") ?? process.env.EXPOSIFY_BROKER_URL;
|
|
1338
|
+
const flagProject = argValue("--broker-project") ?? process.env.EXPOSIFY_PROJECT_ID;
|
|
519
1339
|
if (!flagUrl && !flagProject) return void 0;
|
|
520
1340
|
if (!flagUrl || !flagProject) {
|
|
521
1341
|
throw new Error("the hosted broker needs both --broker-url and --broker-project (or EXPOSIFY_BROKER_URL/EXPOSIFY_PROJECT_ID).");
|
|
@@ -524,178 +1344,201 @@ function brokerFromFlags() {
|
|
|
524
1344
|
if (problem) throw new Error(problem);
|
|
525
1345
|
return { brokerUrl: normalizeBrokerUrl(flagUrl), projectId: flagProject.trim() };
|
|
526
1346
|
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
1347
|
+
function authFromFlags() {
|
|
1348
|
+
const broker = brokerFromFlags();
|
|
1349
|
+
const oauth = process.argv.includes("--oauth") || process.env.EXPOSIFY_OAUTH === "1";
|
|
1350
|
+
if (broker && oauth) throw new Error("--oauth can't be combined with the hosted broker flags; pick one.");
|
|
1351
|
+
if (broker) return { broker, oauth: false };
|
|
1352
|
+
if (oauth) return { broker: null, oauth: true };
|
|
1353
|
+
return void 0;
|
|
1354
|
+
}
|
|
1355
|
+
async function askAuth(rl, current) {
|
|
1356
|
+
const broker = await askBroker(rl, current.broker);
|
|
1357
|
+
if (broker) return { broker, oauth: false };
|
|
1358
|
+
const oauth = await promptYesNo(
|
|
1359
|
+
rl,
|
|
1360
|
+
'\nLet MCP clients sign in with Supabase Auth ("click Connect and log in"; needs the OAuth 2.1 server enabled in the dashboard)?',
|
|
1361
|
+
current.oauth
|
|
1362
|
+
);
|
|
1363
|
+
return { broker: null, oauth };
|
|
1364
|
+
}
|
|
1365
|
+
async function askBroker(rl, current) {
|
|
530
1366
|
const use = await promptYesNo(
|
|
531
1367
|
rl,
|
|
532
|
-
"\nUse the Exposify hosted broker for auth (MCP clients get a long-lived extb_... token instead of a Supabase JWT)?"
|
|
1368
|
+
"\nUse the Exposify hosted broker for auth (MCP clients get a long-lived extb_... token instead of a Supabase JWT)?",
|
|
1369
|
+
current !== null
|
|
533
1370
|
);
|
|
534
1371
|
if (!use) return null;
|
|
535
1372
|
let brokerUrl = "";
|
|
536
1373
|
for (; ; ) {
|
|
537
|
-
|
|
1374
|
+
const hint = current ? ` [${current.brokerUrl}]` : "";
|
|
1375
|
+
const answer = await promptText(rl, ` Broker URL (from the dashboard's Hosted broker card)${hint}: `);
|
|
1376
|
+
brokerUrl = normalizeBrokerUrl(answer || current?.brokerUrl || "");
|
|
538
1377
|
const problem = brokerUrlProblem(brokerUrl);
|
|
539
1378
|
if (!problem) break;
|
|
540
1379
|
console.log(` ${problem}`);
|
|
541
1380
|
}
|
|
542
1381
|
let projectId = "";
|
|
543
|
-
while (!projectId)
|
|
1382
|
+
while (!projectId) {
|
|
1383
|
+
const hint = current ? ` [${current.projectId}]` : "";
|
|
1384
|
+
projectId = await promptText(rl, ` Project ID${hint}: `) || current?.projectId || "";
|
|
1385
|
+
}
|
|
544
1386
|
return { brokerUrl, projectId };
|
|
545
1387
|
}
|
|
546
|
-
async function
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
1388
|
+
async function readLocalState() {
|
|
1389
|
+
const migrations = await listFilesOrEmpty(MIGRATIONS_DIR2);
|
|
1390
|
+
const text = await readOrNull(FUNCTION_PATH2);
|
|
1391
|
+
const documented = /* @__PURE__ */ new Set();
|
|
1392
|
+
for (const name of migrations.filter((m) => m.endsWith(COMMENTS_SUFFIX))) {
|
|
1393
|
+
for (const key of parseCommentMigration(await readOrNull(join(MIGRATIONS_DIR2, name)) ?? "")) documented.add(key);
|
|
552
1394
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
1395
|
+
return {
|
|
1396
|
+
migrations,
|
|
1397
|
+
functionFile: text === null ? null : { text, state: parseFunctionFile(text) },
|
|
1398
|
+
configToml: await readOrNull(CONFIG_TOML2),
|
|
1399
|
+
documented,
|
|
1400
|
+
metaMigrationSql: await readFile(join(PACKAGE_ROOT, "sql/exposify_meta.sql"), "utf8")
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
async function applyPlan(plan) {
|
|
1404
|
+
for (const change of plan.changes) {
|
|
1405
|
+
await mkdir(dirname(change.path), { recursive: true });
|
|
1406
|
+
await writeFile(change.path, change.content);
|
|
1407
|
+
if (change.path === FUNCTION_PATH2 && change.action === "create") await mkdir(join(FUNCTION_DIR, "docs"), { recursive: true });
|
|
557
1408
|
}
|
|
558
|
-
|
|
559
|
-
|
|
1409
|
+
if (plan.changes.length > 0) console.log(`
|
|
1410
|
+
Changes:
|
|
1411
|
+
${formatPlan(plan.changes)}`);
|
|
1412
|
+
for (const w of plan.warnings) console.warn(`
|
|
1413
|
+
warn: ${w}`);
|
|
1414
|
+
if (plan.mergeSnippet) console.log(`
|
|
1415
|
+
${plan.mergeSnippet}`);
|
|
1416
|
+
}
|
|
1417
|
+
async function runWizard(rl, introspection, base, documented) {
|
|
1418
|
+
const writes = await askWriteGrants(rl, introspection.tables, base.writes);
|
|
1419
|
+
const rpc = await askRpcSelection(rl, rpcCandidatesFromOpenApi(introspection.openapi), base.rpc);
|
|
1420
|
+
const docs = await askDocumentation(rl, withoutLocallyDocumented(tablesMissingDocs(introspection.tables), documented));
|
|
1421
|
+
return { config: { ...base, writes, rpc }, commentsSql: buildCommentStatements(docs) };
|
|
1422
|
+
}
|
|
1423
|
+
async function checkLive(conn, known, local, config) {
|
|
1424
|
+
if (!conn) return { migrationApplied: null, functionDeployed: null, pendingComments: [] };
|
|
1425
|
+
const intro = known?.introspection ? known : await tryIntrospect(conn);
|
|
1426
|
+
const probe = await probeFunction(functionUrlFor(conn.url), fetchWithTimeout, { anonKey: conn.anonKey });
|
|
1427
|
+
return {
|
|
1428
|
+
migrationApplied: intro.migrationApplied,
|
|
1429
|
+
functionDeployed: probe.kind === "not-found" ? false : probe.kind === "error" ? null : true,
|
|
1430
|
+
pendingComments: intro.introspection ? pendingComments(intro.introspection.tables, local.documented) : [],
|
|
1431
|
+
// Only meaningful for a file init manages -- for anything else, `config` is a best-effort guess.
|
|
1432
|
+
functionStale: local.functionFile?.state.kind === "managed" ? staleFunctionReason(probe, config, intro.introspection?.tables ?? null) : null
|
|
1433
|
+
};
|
|
560
1434
|
}
|
|
561
1435
|
async function cmdInit() {
|
|
562
|
-
const
|
|
1436
|
+
const check = process.argv.includes("--check");
|
|
1437
|
+
const authFlags = authFromFlags();
|
|
563
1438
|
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
1439
|
const connWarning = partialConnectionWarning(
|
|
571
|
-
|
|
572
|
-
|
|
1440
|
+
argValue("--url") ?? process.env.SUPABASE_URL,
|
|
1441
|
+
argValue("--key") ?? process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
573
1442
|
);
|
|
574
1443
|
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
|
-
}
|
|
1444
|
+
const rl = check ? null : openInteractiveReadline();
|
|
589
1445
|
try {
|
|
590
|
-
const
|
|
591
|
-
const
|
|
592
|
-
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
let
|
|
597
|
-
let
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
1446
|
+
const local = await readLocalState();
|
|
1447
|
+
const current = local.functionFile?.state.config ?? EMPTY_CONFIG;
|
|
1448
|
+
let conn;
|
|
1449
|
+
const getConn = async () => conn !== void 0 ? conn : conn = (await resolveConnection(rl)).connection;
|
|
1450
|
+
const auth = authFlags ?? (rl ? await askAuth(rl, current) : { broker: current.broker, oauth: current.oauth });
|
|
1451
|
+
const anonymousSignIn = rl ? await askAnonymousSignIn(rl, await getConn(), current.anonymousSignIn) : current.anonymousSignIn;
|
|
1452
|
+
let desired = { ...current, ...auth, anonymousSignIn };
|
|
1453
|
+
let intro = null;
|
|
1454
|
+
let commentsSql = "";
|
|
1455
|
+
if (rl) {
|
|
1456
|
+
intro = await tryIntrospect(await getConn());
|
|
1457
|
+
if (intro.introspection) ({ config: desired, commentsSql } = await runWizard(rl, intro.introspection, desired, local.documented));
|
|
1458
|
+
}
|
|
1459
|
+
const plan = planInit({ ...local, desired, commentsSql, timestamp: timestamp() });
|
|
1460
|
+
if (check) {
|
|
1461
|
+
if (plan.changes.length === 0 && !plan.mergeSnippet) {
|
|
1462
|
+
console.log("init --check: up to date, a run would change nothing.");
|
|
1463
|
+
return;
|
|
608
1464
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
1465
|
+
console.log("init --check: a run would change:");
|
|
1466
|
+
if (plan.changes.length > 0) console.log(formatPlan(plan.changes));
|
|
1467
|
+
for (const w of plan.warnings) console.log(` ! ${w}`);
|
|
1468
|
+
process.exitCode = 1;
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
await applyPlan(plan);
|
|
1472
|
+
const effectiveAuth = plan.fileConfig;
|
|
1473
|
+
let steps;
|
|
1474
|
+
if (plan.changes.length === 0 && plan.mergeSnippet) {
|
|
1475
|
+
console.log(`
|
|
1476
|
+
No files were changed. Merge the block above into ${FUNCTION_PATH2}, then re-run init.`);
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
if (plan.changes.length > 0) {
|
|
1480
|
+
steps = { push: needsPush(plan.changes), deploy: needsDeploy(plan.changes) };
|
|
1481
|
+
} else {
|
|
1482
|
+
const c = await getConn();
|
|
1483
|
+
const verdict = liveVerdict(await checkLive(c, intro, local, plan.fileConfig));
|
|
1484
|
+
if (verdict.upToDate) {
|
|
1485
|
+
console.log("\nAlready up to date: no file changes, the migration is applied and the function is deployed. Nothing to do.");
|
|
612
1486
|
return;
|
|
613
1487
|
}
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
if (!connect) {
|
|
619
|
-
printManualCommands({ broker });
|
|
1488
|
+
if (!c) {
|
|
1489
|
+
console.log(
|
|
1490
|
+
"\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."
|
|
1491
|
+
);
|
|
620
1492
|
return;
|
|
621
1493
|
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
console.error(`
|
|
627
|
-
Supabase CLI command failed: ${result2.error instanceof Error ? result2.error.message : result2.error}`);
|
|
628
|
-
printManualCommands({ alreadyPushed, broker });
|
|
1494
|
+
console.log("\nNothing changed locally, but the project is behind:");
|
|
1495
|
+
for (const n of verdict.notes) console.log(` - ${n}`);
|
|
1496
|
+
if (!verdict.push && !verdict.deploy) {
|
|
1497
|
+
console.log("Run `npx exposify-supabase doctor` for details.");
|
|
629
1498
|
return;
|
|
630
1499
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
1500
|
+
if (c.source === "local") {
|
|
1501
|
+
const todo = [verdict.push && "`supabase migration up`", verdict.deploy && `\`supabase functions serve mcp${bypassesGatewayJwt(effectiveAuth) ? " --no-verify-jwt" : ""}\``].filter(Boolean);
|
|
1502
|
+
console.log(`
|
|
1503
|
+
That's the local stack: run ${todo.join(" and ")}.`);
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
steps = { push: verdict.push, deploy: verdict.deploy };
|
|
1507
|
+
}
|
|
1508
|
+
if (!await offerSteps(rl, steps, effectiveAuth)) return;
|
|
1509
|
+
if (rl && !intro?.introspection && steps.push) {
|
|
1510
|
+
const again = await tryIntrospect(await getConn());
|
|
1511
|
+
if (!again.introspection) {
|
|
634
1512
|
console.log(
|
|
635
|
-
"\nDeployed, but couldn't reach the project to introspect tables (no SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY or
|
|
1513
|
+
"\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
1514
|
);
|
|
637
1515
|
console.log(`
|
|
638
|
-
Done.
|
|
1516
|
+
Done. ${clientStep(effectiveAuth)}`);
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
const local2 = await readLocalState();
|
|
1520
|
+
const base = local2.functionFile?.state.config ?? desired;
|
|
1521
|
+
const wizard = await runWizard(rl, again.introspection, base, local2.documented);
|
|
1522
|
+
const plan2 = planInit({ ...local2, desired: wizard.config, commentsSql: wizard.commentsSql, timestamp: timestamp() });
|
|
1523
|
+
await applyPlan(plan2);
|
|
1524
|
+
if (plan2.changes.length === 0) {
|
|
1525
|
+
console.log("\nNothing more to change.");
|
|
1526
|
+
} else if (!await offerSteps(rl, { push: needsPush(plan2.changes), deploy: needsDeploy(plan2.changes) }, plan2.fileConfig, true)) {
|
|
639
1527
|
return;
|
|
640
1528
|
}
|
|
641
1529
|
}
|
|
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
1530
|
console.log(`
|
|
684
|
-
Done.
|
|
1531
|
+
Done. ${clientStep(effectiveAuth)}`);
|
|
685
1532
|
} finally {
|
|
686
|
-
rl
|
|
1533
|
+
rl?.close();
|
|
687
1534
|
}
|
|
688
1535
|
}
|
|
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
1536
|
async function cmdGenerate() {
|
|
695
|
-
const
|
|
1537
|
+
const check = process.argv.includes("--check");
|
|
1538
|
+
const { connection: conn, notes } = await resolveConnection(null);
|
|
696
1539
|
if (!conn) {
|
|
697
1540
|
console.error(
|
|
698
|
-
|
|
1541
|
+
`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
1542
|
);
|
|
700
1543
|
process.exitCode = 1;
|
|
701
1544
|
return;
|
|
@@ -711,9 +1554,40 @@ async function cmdGenerate() {
|
|
|
711
1554
|
]);
|
|
712
1555
|
const manifest = buildManifest(openapi, metaRows, (/* @__PURE__ */ new Date()).toISOString());
|
|
713
1556
|
const outPath = join(FUNCTION_DIR, "exposify.manifest.json");
|
|
1557
|
+
const existingRaw = await readOrNull(outPath);
|
|
1558
|
+
let existing = null;
|
|
1559
|
+
try {
|
|
1560
|
+
existing = existingRaw === null ? null : JSON.parse(existingRaw);
|
|
1561
|
+
} catch {
|
|
1562
|
+
}
|
|
1563
|
+
if (manifestsEquivalent(manifest, existing)) {
|
|
1564
|
+
console.log(`${outPath} is up to date (${manifest.tables.length} table(s)); nothing to do.`);
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
if (check) {
|
|
1568
|
+
console.log(`generate --check: ${outPath} is ${existingRaw === null ? "missing" : "out of date"}.`);
|
|
1569
|
+
process.exitCode = 1;
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
714
1572
|
await mkdir(dirname(outPath), { recursive: true });
|
|
715
1573
|
await writeFile(outPath, JSON.stringify(manifest, null, 2));
|
|
716
|
-
console.log(
|
|
1574
|
+
console.log(`${existingRaw === null ? "Wrote" : "Updated"} ${outPath} (${manifest.tables.length} table(s))`);
|
|
1575
|
+
}
|
|
1576
|
+
async function cmdDoctor() {
|
|
1577
|
+
const rl = openInteractiveReadline();
|
|
1578
|
+
const { connection, notes, projectRef } = await resolveConnection(rl, { quiet: true }).finally(() => rl?.close());
|
|
1579
|
+
const sections = await runDoctor({
|
|
1580
|
+
readText: readOrNull,
|
|
1581
|
+
listDir: listFilesOrEmpty,
|
|
1582
|
+
runSupabase: supabaseRunner(),
|
|
1583
|
+
connection,
|
|
1584
|
+
connectionNotes: notes,
|
|
1585
|
+
projectRef,
|
|
1586
|
+
fetch: fetchWithTimeout,
|
|
1587
|
+
token: argValue("--token")
|
|
1588
|
+
});
|
|
1589
|
+
console.log(formatReport(sections));
|
|
1590
|
+
if (hasFailures(sections)) process.exitCode = 1;
|
|
717
1591
|
}
|
|
718
1592
|
function argValue(flag) {
|
|
719
1593
|
const i = process.argv.indexOf(flag);
|
|
@@ -728,18 +1602,26 @@ async function main() {
|
|
|
728
1602
|
const [, , cmd] = process.argv;
|
|
729
1603
|
if (cmd === "init") return cmdInit();
|
|
730
1604
|
if (cmd === "generate") return cmdGenerate();
|
|
1605
|
+
if (cmd === "doctor") return cmdDoctor();
|
|
731
1606
|
console.log(
|
|
732
|
-
"Usage: exposify-supabase <init|generate> [--url <url>] [--key <service-role-key>] [--broker-url <url> --broker-project <id>]"
|
|
1607
|
+
"Usage: exposify-supabase <init|generate|doctor> [--url <url>] [--key <service-role-key>] [--local|--linked] [--broker-url <url> --broker-project <id> | --oauth] [--token <token>] [--check]"
|
|
733
1608
|
);
|
|
734
|
-
console.log(" init Scaffold the exposify_meta() migration + supabase/functions/mcp/index.ts
|
|
735
|
-
console.log("
|
|
736
|
-
console.log("
|
|
737
|
-
console.log("
|
|
738
|
-
console.log("
|
|
739
|
-
console.log("
|
|
740
|
-
console.log("
|
|
741
|
-
console.log(" --
|
|
742
|
-
console.log(" generate Build exposify.manifest.json from a running local/linked project");
|
|
1609
|
+
console.log(" init Scaffold the exposify_meta() migration + supabase/functions/mcp/index.ts, then (when the");
|
|
1610
|
+
console.log(" project is reachable) walk through anonymous sign-in, per-table writes, RPC exposure, and");
|
|
1611
|
+
console.log(" documentation. Safe to re-run: answers default to what's already configured, only the");
|
|
1612
|
+
console.log(" exposify:begin/end block of index.ts is ever rewritten, and a run that changes nothing");
|
|
1613
|
+
console.log(" says so and stops. Offers the hosted broker (or --broker-url/--broker-project), or");
|
|
1614
|
+
console.log(" Supabase Auth sign-in (--oauth): adds oauth: supabaseAuth(), so MCP clients open a login");
|
|
1615
|
+
console.log(" on connect. Either way it sets verify_jwt = false for mcp and deploys --no-verify-jwt.");
|
|
1616
|
+
console.log(" --check: change nothing, exit 1 if a run would change a file.");
|
|
1617
|
+
console.log(" generate Build exposify.manifest.json from a running local/linked project; rewritten only when");
|
|
1618
|
+
console.log(" the schema changed. --check: exit 1 if it's missing or out of date.");
|
|
1619
|
+
console.log(" doctor Read-only health check: local files, config.toml, the database migration and");
|
|
1620
|
+
console.log(" exposed tables, and the deployed function answering MCP (tool count). Exits 1 on any");
|
|
1621
|
+
console.log(" failure. --token <user JWT | extb_...> lets it list tools past authRequired.");
|
|
1622
|
+
console.log("");
|
|
1623
|
+
console.log(" Reaching the project: --url/--key, else SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY, else the Supabase");
|
|
1624
|
+
console.log(" CLI supplies keys for a running local stack or the linked project (--local/--linked to pick).");
|
|
743
1625
|
process.exitCode = cmd ? 1 : 0;
|
|
744
1626
|
}
|
|
745
1627
|
main().catch((err) => {
|