@exposify/supabase 0.3.6 → 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/LICENSE +1 -1
- package/README.md +282 -20
- package/dist/cli.js +1226 -199
- 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 -11
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,32 +228,49 @@ function buildCommentStatements(entries) {
|
|
|
198
228
|
return lines.length > 0 ? `${lines.join("\n")}
|
|
199
229
|
` : "";
|
|
200
230
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
"
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
231
|
+
function brokerUrlProblem(brokerUrl) {
|
|
232
|
+
let url;
|
|
233
|
+
try {
|
|
234
|
+
url = new URL(brokerUrl);
|
|
235
|
+
} catch {
|
|
236
|
+
return `"${brokerUrl}" is not a valid URL.`;
|
|
237
|
+
}
|
|
238
|
+
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
239
|
+
if (url.protocol === "https:" || url.protocol === "http:" && local) return null;
|
|
240
|
+
return `broker URL must be https (http is only allowed for localhost): ${brokerUrl}`;
|
|
241
|
+
}
|
|
242
|
+
function normalizeBrokerUrl(brokerUrl) {
|
|
243
|
+
return brokerUrl.trim().replace(/\/+$/, "");
|
|
244
|
+
}
|
|
245
|
+
function setFunctionVerifyJwtFalse(toml, name = "mcp") {
|
|
246
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
247
|
+
const header = new RegExp(`^\\s*\\[\\s*functions\\s*\\.\\s*("?)${escaped}\\1\\s*\\]\\s*(#.*)?$`);
|
|
248
|
+
const anyHeader = /^\s*\[/;
|
|
249
|
+
const verifyLine = /^(\s*)verify_jwt\s*=\s*([^#\s]*)(.*)$/;
|
|
250
|
+
const lines = toml.split("\n");
|
|
251
|
+
const start = lines.findIndex((l) => header.test(l));
|
|
252
|
+
if (start === -1) {
|
|
253
|
+
const sep = toml === "" || toml.endsWith("\n\n") ? "" : toml.endsWith("\n") ? "\n" : "\n\n";
|
|
254
|
+
return { toml: `${toml}${sep}[functions.${name}]
|
|
255
|
+
verify_jwt = false
|
|
256
|
+
`, changed: true };
|
|
257
|
+
}
|
|
258
|
+
let end = lines.length;
|
|
259
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
260
|
+
if (anyHeader.test(lines[i])) {
|
|
261
|
+
end = i;
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
for (let i = start + 1; i < end; i++) {
|
|
266
|
+
const m = lines[i].match(verifyLine);
|
|
267
|
+
if (!m) continue;
|
|
268
|
+
if (m[2] === "false") return { toml, changed: false };
|
|
269
|
+
lines[i] = `${m[1]}verify_jwt = false${m[3]}`;
|
|
270
|
+
return { toml: lines.join("\n"), changed: true };
|
|
271
|
+
}
|
|
272
|
+
lines.splice(start + 1, 0, "verify_jwt = false");
|
|
273
|
+
return { toml: lines.join("\n"), changed: true };
|
|
227
274
|
}
|
|
228
275
|
function findExistingMigration(filenames, suffix) {
|
|
229
276
|
const matches = filenames.filter((f) => f.endsWith(suffix)).sort();
|
|
@@ -245,10 +292,782 @@ function partialConnectionWarning(url, key) {
|
|
|
245
292
|
return null;
|
|
246
293
|
}
|
|
247
294
|
|
|
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`;
|
|
531
|
+
var MIGRATIONS_DIR = "supabase/migrations";
|
|
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
|
+
|
|
248
1069
|
// bin/cli.ts
|
|
249
1070
|
var PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
250
|
-
var FUNCTION_DIR = "supabase/functions/mcp";
|
|
251
|
-
var MIGRATIONS_DIR = "supabase/migrations";
|
|
252
1071
|
var FETCH_TIMEOUT_MS = 1e4;
|
|
253
1072
|
function fetchWithTimeout(input, init) {
|
|
254
1073
|
return fetch(input, { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
@@ -260,6 +1079,13 @@ async function listFilesOrEmpty(dir) {
|
|
|
260
1079
|
return [];
|
|
261
1080
|
}
|
|
262
1081
|
}
|
|
1082
|
+
async function readOrNull(path) {
|
|
1083
|
+
try {
|
|
1084
|
+
return await readFile(path, "utf8");
|
|
1085
|
+
} catch {
|
|
1086
|
+
return null;
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
263
1089
|
async function exists(path) {
|
|
264
1090
|
try {
|
|
265
1091
|
await access(path);
|
|
@@ -289,34 +1115,115 @@ async function promptYesNo(rl, question, defaultYes = false) {
|
|
|
289
1115
|
async function promptText(rl, question) {
|
|
290
1116
|
return (await rl.question(question)).trim();
|
|
291
1117
|
}
|
|
292
|
-
|
|
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"] : []];
|
|
1121
|
+
}
|
|
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.`;
|
|
1126
|
+
}
|
|
1127
|
+
function printManualCommands(steps, auth) {
|
|
293
1128
|
console.log("\nRun these to finish setup:");
|
|
294
|
-
const
|
|
295
|
-
if (
|
|
296
|
-
if (
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
|
|
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;
|
|
1139
|
+
console.log(
|
|
1140
|
+
`
|
|
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.`
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
function runSteps(steps, auth) {
|
|
1145
|
+
const done = { push: false, deploy: false };
|
|
302
1146
|
try {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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(`
|
|
1155
|
+
$ supabase ${args.join(" ")}`);
|
|
1156
|
+
execFileSync("supabase", args, { stdio: "inherit" });
|
|
1157
|
+
done.deploy = true;
|
|
1158
|
+
}
|
|
1159
|
+
return { done };
|
|
309
1160
|
} catch (err) {
|
|
310
|
-
return {
|
|
1161
|
+
return { done, error: err };
|
|
311
1162
|
}
|
|
312
1163
|
}
|
|
313
|
-
function
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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 };
|
|
318
1224
|
}
|
|
319
1225
|
async function tryIntrospect(conn) {
|
|
1226
|
+
if (!conn) return { introspection: null, migrationApplied: null };
|
|
320
1227
|
try {
|
|
321
1228
|
const postgrestUrl = `${conn.url.replace(/\/+$/, "")}/rest/v1`;
|
|
322
1229
|
const headers = new Headers({ authorization: `Bearer ${conn.serviceRoleKey}`, apikey: conn.serviceRoleKey });
|
|
@@ -324,13 +1231,12 @@ async function tryIntrospect(conn) {
|
|
|
324
1231
|
fetchOpenApi(postgrestUrl, headers, fetchWithTimeout),
|
|
325
1232
|
fetchExposifyMeta(postgrestUrl, headers, fetchWithTimeout)
|
|
326
1233
|
]);
|
|
327
|
-
return { tables: introspectTables(openapi, metaRows), openapi };
|
|
1234
|
+
return { introspection: { tables: introspectTables(openapi, metaRows), openapi }, migrationApplied: true };
|
|
328
1235
|
} catch (err) {
|
|
329
|
-
if (
|
|
330
|
-
|
|
1236
|
+
if (err instanceof MissingExposifyMetaError) return { introspection: null, migrationApplied: false };
|
|
1237
|
+
console.log(`
|
|
331
1238
|
Couldn't introspect the project: ${err instanceof Error ? err.message : String(err)}`);
|
|
332
|
-
}
|
|
333
|
-
return null;
|
|
1239
|
+
return { introspection: null, migrationApplied: null };
|
|
334
1240
|
}
|
|
335
1241
|
}
|
|
336
1242
|
async function tryDetectAnonymousSignIn(conn) {
|
|
@@ -345,7 +1251,7 @@ async function tryDetectAnonymousSignIn(conn) {
|
|
|
345
1251
|
return null;
|
|
346
1252
|
}
|
|
347
1253
|
}
|
|
348
|
-
async function askAnonymousSignIn(rl, conn) {
|
|
1254
|
+
async function askAnonymousSignIn(rl, conn, current) {
|
|
349
1255
|
const detected = conn ? await tryDetectAnonymousSignIn(conn) : null;
|
|
350
1256
|
if (detected !== null) {
|
|
351
1257
|
console.log(`
|
|
@@ -353,33 +1259,43 @@ Detected: anonymous sign-in is ${detected ? "enabled" : "disabled"} on this proj
|
|
|
353
1259
|
const confirmed = await promptYesNo(rl, "Is that correct?", true);
|
|
354
1260
|
if (confirmed) return detected;
|
|
355
1261
|
}
|
|
356
|
-
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
|
+
);
|
|
357
1267
|
}
|
|
358
|
-
async function askWriteGrants(rl, tables) {
|
|
359
|
-
if (tables.length === 0) return
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
if (!
|
|
363
|
-
|
|
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 = [];
|
|
364
1276
|
for (const table of tables) {
|
|
365
|
-
const
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
);
|
|
369
|
-
|
|
370
|
-
|
|
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);
|
|
371
1283
|
}
|
|
372
|
-
|
|
1284
|
+
for (const rest of byTable.values()) result.push(...rest);
|
|
1285
|
+
return result;
|
|
373
1286
|
}
|
|
374
|
-
async function askRpcSelection(rl, candidates) {
|
|
375
|
-
if (candidates.length === 0) return
|
|
1287
|
+
async function askRpcSelection(rl, candidates, current) {
|
|
1288
|
+
if (candidates.length === 0) return current;
|
|
376
1289
|
console.log(`
|
|
377
1290
|
Available Postgres functions: ${candidates.join(", ")}`);
|
|
378
|
-
const
|
|
379
|
-
|
|
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;
|
|
380
1296
|
}
|
|
381
|
-
async function askDocumentation(rl,
|
|
382
|
-
const missing =
|
|
1297
|
+
async function askDocumentation(rl, status) {
|
|
1298
|
+
const missing = status.filter((t) => t.missingTableComment || t.columnsWithoutComments.length > 0);
|
|
383
1299
|
if (missing.length === 0) return [];
|
|
384
1300
|
const configure = await promptYesNo(
|
|
385
1301
|
rl,
|
|
@@ -410,152 +1326,219 @@ ${missing.length} table(s) are missing documentation MCP tools would otherwise s
|
|
|
410
1326
|
}
|
|
411
1327
|
return entries;
|
|
412
1328
|
}
|
|
413
|
-
async function writeCommentsMigration(entries) {
|
|
414
|
-
const sql = buildCommentStatements(entries);
|
|
415
|
-
if (!sql) return null;
|
|
416
|
-
await mkdir(MIGRATIONS_DIR, { recursive: true });
|
|
417
|
-
const existing = await listFilesOrEmpty(MIGRATIONS_DIR);
|
|
418
|
-
const filename = uniqueMigrationFilename(existing, `${timestamp()}_exposify_comments.sql`);
|
|
419
|
-
const path = join(MIGRATIONS_DIR, filename);
|
|
420
|
-
await writeFile(path, sql);
|
|
421
|
-
return path;
|
|
422
|
-
}
|
|
423
|
-
async function ensureMetaMigration() {
|
|
424
|
-
await mkdir(MIGRATIONS_DIR, { recursive: true });
|
|
425
|
-
const existing = await listFilesOrEmpty(MIGRATIONS_DIR);
|
|
426
|
-
const found = findExistingMigration(existing, "_exposify_meta.sql");
|
|
427
|
-
if (found) return { path: join(MIGRATIONS_DIR, found), wasNew: false };
|
|
428
|
-
const migrationSql = await readFile(join(PACKAGE_ROOT, "sql/exposify_meta.sql"), "utf8");
|
|
429
|
-
const filename = uniqueMigrationFilename(existing, `${timestamp()}_exposify_meta.sql`);
|
|
430
|
-
const path = join(MIGRATIONS_DIR, filename);
|
|
431
|
-
await writeFile(path, migrationSql);
|
|
432
|
-
return { path, wasNew: true };
|
|
433
|
-
}
|
|
434
1329
|
async function warnIfNotSupabaseProject() {
|
|
435
|
-
if (!await exists(
|
|
1330
|
+
if (!await exists(CONFIG_TOML2)) {
|
|
436
1331
|
console.warn(
|
|
437
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."
|
|
438
1333
|
);
|
|
439
1334
|
}
|
|
440
1335
|
}
|
|
1336
|
+
function brokerFromFlags() {
|
|
1337
|
+
const flagUrl = argValue("--broker-url") ?? process.env.EXPOSIFY_BROKER_URL;
|
|
1338
|
+
const flagProject = argValue("--broker-project") ?? process.env.EXPOSIFY_PROJECT_ID;
|
|
1339
|
+
if (!flagUrl && !flagProject) return void 0;
|
|
1340
|
+
if (!flagUrl || !flagProject) {
|
|
1341
|
+
throw new Error("the hosted broker needs both --broker-url and --broker-project (or EXPOSIFY_BROKER_URL/EXPOSIFY_PROJECT_ID).");
|
|
1342
|
+
}
|
|
1343
|
+
const problem = brokerUrlProblem(flagUrl);
|
|
1344
|
+
if (problem) throw new Error(problem);
|
|
1345
|
+
return { brokerUrl: normalizeBrokerUrl(flagUrl), projectId: flagProject.trim() };
|
|
1346
|
+
}
|
|
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) {
|
|
1366
|
+
const use = await promptYesNo(
|
|
1367
|
+
rl,
|
|
1368
|
+
"\nUse the Exposify hosted broker for auth (MCP clients get a long-lived extb_... token instead of a Supabase JWT)?",
|
|
1369
|
+
current !== null
|
|
1370
|
+
);
|
|
1371
|
+
if (!use) return null;
|
|
1372
|
+
let brokerUrl = "";
|
|
1373
|
+
for (; ; ) {
|
|
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 || "");
|
|
1377
|
+
const problem = brokerUrlProblem(brokerUrl);
|
|
1378
|
+
if (!problem) break;
|
|
1379
|
+
console.log(` ${problem}`);
|
|
1380
|
+
}
|
|
1381
|
+
let projectId = "";
|
|
1382
|
+
while (!projectId) {
|
|
1383
|
+
const hint = current ? ` [${current.projectId}]` : "";
|
|
1384
|
+
projectId = await promptText(rl, ` Project ID${hint}: `) || current?.projectId || "";
|
|
1385
|
+
}
|
|
1386
|
+
return { brokerUrl, projectId };
|
|
1387
|
+
}
|
|
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);
|
|
1394
|
+
}
|
|
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 });
|
|
1408
|
+
}
|
|
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
|
+
};
|
|
1434
|
+
}
|
|
441
1435
|
async function cmdInit() {
|
|
1436
|
+
const check = process.argv.includes("--check");
|
|
1437
|
+
const authFlags = authFromFlags();
|
|
442
1438
|
await warnIfNotSupabaseProject();
|
|
443
|
-
const { path: migrationPath, wasNew } = await ensureMetaMigration();
|
|
444
|
-
console.log(wasNew ? `Wrote ${migrationPath}` : `Skipped ${migrationPath} (already scaffolded)`);
|
|
445
|
-
await mkdir(FUNCTION_DIR, { recursive: true });
|
|
446
|
-
const functionPath = join(FUNCTION_DIR, "index.ts");
|
|
447
|
-
const functionExists = await exists(functionPath);
|
|
448
|
-
await mkdir(join(FUNCTION_DIR, "docs"), { recursive: true });
|
|
449
1439
|
const connWarning = partialConnectionWarning(
|
|
450
|
-
|
|
451
|
-
|
|
1440
|
+
argValue("--url") ?? process.env.SUPABASE_URL,
|
|
1441
|
+
argValue("--key") ?? process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
452
1442
|
);
|
|
453
1443
|
if (connWarning) console.warn(`warn: ${connWarning}`);
|
|
454
|
-
const rl = openInteractiveReadline();
|
|
455
|
-
if (!rl) {
|
|
456
|
-
if (!functionExists) {
|
|
457
|
-
await writeFile(functionPath, buildFunctionTemplate());
|
|
458
|
-
console.log(`Wrote ${functionPath}`);
|
|
459
|
-
} else {
|
|
460
|
-
console.log(`Skipped ${functionPath} (already exists)`);
|
|
461
|
-
}
|
|
462
|
-
printManualCommands();
|
|
463
|
-
return;
|
|
464
|
-
}
|
|
1444
|
+
const rl = check ? null : openInteractiveReadline();
|
|
465
1445
|
try {
|
|
466
|
-
const
|
|
467
|
-
const
|
|
468
|
-
let
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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;
|
|
477
1464
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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.");
|
|
481
1486
|
return;
|
|
482
1487
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
if (!connect) {
|
|
488
|
-
printManualCommands();
|
|
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
|
+
);
|
|
489
1492
|
return;
|
|
490
1493
|
}
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
console.error(`
|
|
496
|
-
Supabase CLI command failed: ${result2.error instanceof Error ? result2.error.message : result2.error}`);
|
|
497
|
-
printManualCommands({ alreadyPushed });
|
|
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.");
|
|
498
1498
|
return;
|
|
499
1499
|
}
|
|
500
|
-
|
|
501
|
-
|
|
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) {
|
|
502
1512
|
console.log(
|
|
503
|
-
"\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."
|
|
504
1514
|
);
|
|
505
|
-
console.log(
|
|
1515
|
+
console.log(`
|
|
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)) {
|
|
506
1527
|
return;
|
|
507
1528
|
}
|
|
508
1529
|
}
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const rpc = await askRpcSelection(rl, rpcCandidates);
|
|
512
|
-
const docs = await askDocumentation(rl, introspection.tables);
|
|
513
|
-
const writes = flattenWriteGrants(grants);
|
|
514
|
-
let wroteFunctionFile = false;
|
|
515
|
-
if (!functionExists) {
|
|
516
|
-
await writeFile(functionPath, buildFunctionTemplate({ writes, rpc, anonymousSignIn }));
|
|
517
|
-
console.log(`
|
|
518
|
-
Wrote ${functionPath} with your selections.`);
|
|
519
|
-
wroteFunctionFile = true;
|
|
520
|
-
} else if (writes.length > 0 || rpc.length > 0) {
|
|
521
|
-
console.log(`
|
|
522
|
-
${functionPath} already exists -- left untouched. Merge this into your aiReady() config:`);
|
|
523
|
-
console.log(buildFunctionTemplate({ writes, rpc, anonymousSignIn }));
|
|
524
|
-
}
|
|
525
|
-
const commentsPath = await writeCommentsMigration(docs);
|
|
526
|
-
if (commentsPath) console.log(`Wrote ${commentsPath}`);
|
|
527
|
-
const needsDeploy = wroteFunctionFile || commentsPath !== null;
|
|
528
|
-
if (!needsDeploy) {
|
|
529
|
-
console.log("\nNothing new to deploy.");
|
|
530
|
-
return;
|
|
531
|
-
}
|
|
532
|
-
if (!hasSupabaseCli()) {
|
|
533
|
-
console.log("\nSupabase CLI not found on PATH.");
|
|
534
|
-
printManualCommands({ alreadyPushed: commentsPath === null, alreadyDeployed: !wroteFunctionFile });
|
|
535
|
-
return;
|
|
536
|
-
}
|
|
537
|
-
const redeploy = await promptYesNo(rl, "\nApply/deploy these changes now?", true);
|
|
538
|
-
if (!redeploy) {
|
|
539
|
-
printManualCommands({ alreadyPushed: commentsPath === null, alreadyDeployed: !wroteFunctionFile });
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
542
|
-
const result = pushAndDeploy();
|
|
543
|
-
if (!result.deployed) {
|
|
544
|
-
console.error(`
|
|
545
|
-
Supabase CLI command failed: ${result.error instanceof Error ? result.error.message : result.error}`);
|
|
546
|
-
printManualCommands({ alreadyPushed: result.pushed });
|
|
547
|
-
return;
|
|
548
|
-
}
|
|
549
|
-
console.log("\nDone. Paste the function URL + a user JWT into an MCP client to try it.");
|
|
1530
|
+
console.log(`
|
|
1531
|
+
Done. ${clientStep(effectiveAuth)}`);
|
|
550
1532
|
} finally {
|
|
551
|
-
rl
|
|
1533
|
+
rl?.close();
|
|
552
1534
|
}
|
|
553
1535
|
}
|
|
554
1536
|
async function cmdGenerate() {
|
|
555
|
-
const
|
|
1537
|
+
const check = process.argv.includes("--check");
|
|
1538
|
+
const { connection: conn, notes } = await resolveConnection(null);
|
|
556
1539
|
if (!conn) {
|
|
557
1540
|
console.error(
|
|
558
|
-
|
|
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.`
|
|
559
1542
|
);
|
|
560
1543
|
process.exitCode = 1;
|
|
561
1544
|
return;
|
|
@@ -571,9 +1554,40 @@ async function cmdGenerate() {
|
|
|
571
1554
|
]);
|
|
572
1555
|
const manifest = buildManifest(openapi, metaRows, (/* @__PURE__ */ new Date()).toISOString());
|
|
573
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
|
+
}
|
|
574
1572
|
await mkdir(dirname(outPath), { recursive: true });
|
|
575
1573
|
await writeFile(outPath, JSON.stringify(manifest, null, 2));
|
|
576
|
-
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;
|
|
577
1591
|
}
|
|
578
1592
|
function argValue(flag) {
|
|
579
1593
|
const i = process.argv.indexOf(flag);
|
|
@@ -588,13 +1602,26 @@ async function main() {
|
|
|
588
1602
|
const [, , cmd] = process.argv;
|
|
589
1603
|
if (cmd === "init") return cmdInit();
|
|
590
1604
|
if (cmd === "generate") return cmdGenerate();
|
|
591
|
-
|
|
592
|
-
console.log(
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
console.log("
|
|
596
|
-
console.log("
|
|
597
|
-
console.log("
|
|
1605
|
+
if (cmd === "doctor") return cmdDoctor();
|
|
1606
|
+
console.log(
|
|
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]"
|
|
1608
|
+
);
|
|
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).");
|
|
598
1625
|
process.exitCode = cmd ? 1 : 0;
|
|
599
1626
|
}
|
|
600
1627
|
main().catch((err) => {
|