@exposify/supabase 0.1.0 → 0.3.1
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 +21 -0
- package/README.md +122 -23
- package/dist/cli.js +412 -75
- package/dist/index.cjs +34 -25
- package/dist/index.d.cts +40 -19
- package/dist/index.d.ts +40 -19
- package/dist/index.js +32 -23
- package/package.json +12 -3
- package/sql/{agentify_meta.sql → exposify_meta.sql} +12 -4
package/dist/cli.js
CHANGED
|
@@ -1,49 +1,56 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// bin/cli.ts
|
|
4
|
-
import { mkdir, readFile, writeFile, access } from "fs/promises";
|
|
4
|
+
import { mkdir, readFile, writeFile, access, readdir } from "fs/promises";
|
|
5
5
|
import { dirname, join } from "path";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
7
|
import { execFileSync } from "child_process";
|
|
8
8
|
import { createInterface } from "readline/promises";
|
|
9
9
|
|
|
10
10
|
// src/fetch-openapi.ts
|
|
11
|
+
var WRONG_KEY_TIER_RE = /service_role|secret api key/i;
|
|
11
12
|
async function fetchOpenApi(postgrestUrl, headers, fetchImpl) {
|
|
12
13
|
const res = await fetchImpl(postgrestUrl, {
|
|
13
14
|
headers: { ...Object.fromEntries(headers), accept: "application/openapi+json" }
|
|
14
15
|
});
|
|
15
16
|
if (!res.ok) {
|
|
16
|
-
|
|
17
|
+
const body = await res.text().catch(() => "");
|
|
18
|
+
if (res.status === 401 && WRONG_KEY_TIER_RE.test(body)) {
|
|
19
|
+
throw new Error(
|
|
20
|
+
`PostgREST OpenAPI fetch failed: HTTP 401 -- Supabase rejected this request because it wasn't made with the service role key (response: "${body.trim()}"). Schema introspection always needs SUPABASE_SERVICE_ROLE_KEY, never the anon key -- see this package's README, "Why introspection needs the service role key". In a deployed function this is auto-injected automatically; for local dev/testing pass { serviceRoleKey } explicitly (or --key for the CLI).`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
throw new Error(`PostgREST OpenAPI fetch failed: HTTP ${res.status}${body ? `: ${body}` : ""}`);
|
|
17
24
|
}
|
|
18
25
|
return await res.json();
|
|
19
26
|
}
|
|
20
27
|
|
|
21
28
|
// src/meta.ts
|
|
22
|
-
var
|
|
29
|
+
var MissingExposifyMetaError = class extends Error {
|
|
23
30
|
constructor(cause) {
|
|
24
31
|
super(cause);
|
|
25
|
-
this.name = "
|
|
32
|
+
this.name = "MissingExposifyMetaError";
|
|
26
33
|
}
|
|
27
34
|
};
|
|
28
|
-
async function
|
|
29
|
-
const res = await fetchImpl(`${postgrestUrl}/rpc/
|
|
35
|
+
async function fetchExposifyMeta(postgrestUrl, headers, fetchImpl) {
|
|
36
|
+
const res = await fetchImpl(`${postgrestUrl}/rpc/exposify_meta`, {
|
|
30
37
|
method: "POST",
|
|
31
38
|
headers: { ...Object.fromEntries(headers), "content-type": "application/json" },
|
|
32
39
|
body: "{}"
|
|
33
40
|
});
|
|
34
41
|
if (res.status === 404) {
|
|
35
|
-
throw new
|
|
36
|
-
"
|
|
42
|
+
throw new MissingExposifyMetaError(
|
|
43
|
+
"exposify_meta() is not reachable at /rpc/exposify_meta (404). Run `npx exposify-supabase init` (or apply sql/exposify_meta.sql directly) and `supabase db push`, then redeploy the function."
|
|
37
44
|
);
|
|
38
45
|
}
|
|
39
46
|
if (!res.ok) {
|
|
40
47
|
const body = await res.text().catch(() => "");
|
|
41
48
|
if (/PGRST202|function .* does not exist/i.test(body)) {
|
|
42
|
-
throw new
|
|
43
|
-
"
|
|
49
|
+
throw new MissingExposifyMetaError(
|
|
50
|
+
"exposify_meta() is missing from the database (PostgREST error PGRST202). Apply the migration in sql/exposify_meta.sql via `supabase db push`, then redeploy the function."
|
|
44
51
|
);
|
|
45
52
|
}
|
|
46
|
-
throw new Error(`
|
|
53
|
+
throw new Error(`exposify_meta() call failed: HTTP ${res.status} ${body}`.trim());
|
|
47
54
|
}
|
|
48
55
|
return await res.json();
|
|
49
56
|
}
|
|
@@ -74,7 +81,7 @@ function columnsFromOpenApi(doc, table) {
|
|
|
74
81
|
// ON COLUMN` into `description`, then appends its own "Note:\nThis is a
|
|
75
82
|
// Primary/Foreign Key...<pk/|fk/>" boilerplate) -- strip that boilerplate
|
|
76
83
|
// so a column with no real comment ends up `undefined`, not the
|
|
77
|
-
// boilerplate text.
|
|
84
|
+
// boilerplate text. exposify_meta()'s copy wins when both exist anyway
|
|
78
85
|
// -- see introspect.ts's merge.
|
|
79
86
|
description: prop.description?.replace(/\n*Note:[\s\S]*$/, "").trim() || void 0
|
|
80
87
|
}));
|
|
@@ -99,7 +106,7 @@ function introspectTables(openapi, metaRows) {
|
|
|
99
106
|
if (!fromOpenApi) continue;
|
|
100
107
|
columns.push({
|
|
101
108
|
...fromOpenApi,
|
|
102
|
-
//
|
|
109
|
+
// exposify_meta()'s comment wins over OpenAPI's mirrored copy (spec:
|
|
103
110
|
// "Column comments → param descriptions" sourced from the migration).
|
|
104
111
|
description: row.column_comment ?? fromOpenApi.description
|
|
105
112
|
});
|
|
@@ -130,19 +137,129 @@ function buildManifest(openapi, metaRows, generatedAt) {
|
|
|
130
137
|
return { version: MANIFEST_VERSION, generatedAt, tables: introspectTables(openapi, metaRows) };
|
|
131
138
|
}
|
|
132
139
|
|
|
140
|
+
// bin/wizard.ts
|
|
141
|
+
function parseAnonymousSignInSetting(settings) {
|
|
142
|
+
if (!settings || typeof settings !== "object") return null;
|
|
143
|
+
const s = settings;
|
|
144
|
+
const external = s.external;
|
|
145
|
+
if (external && typeof external.anonymous_users === "boolean") return external.anonymous_users;
|
|
146
|
+
if (typeof s.anonymous_users === "boolean") return s.anonymous_users;
|
|
147
|
+
if (typeof s.external_anonymous_users === "boolean") return s.external_anonymous_users;
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
function rpcCandidatesFromOpenApi(doc) {
|
|
151
|
+
return Object.keys(doc.paths ?? {}).filter((p) => p.startsWith("/rpc/")).map((p) => p.slice("/rpc/".length)).filter((name) => name.length > 0 && name !== "exposify_meta").sort();
|
|
152
|
+
}
|
|
153
|
+
function flattenWriteGrants(grants) {
|
|
154
|
+
return grants.flatMap((g) => g.ops.map((op) => `${g.table}.${op}`));
|
|
155
|
+
}
|
|
156
|
+
function parseWriteOpsAnswer(answer) {
|
|
157
|
+
const seen = /* @__PURE__ */ new Set();
|
|
158
|
+
for (const raw of answer.split(",")) {
|
|
159
|
+
const token = raw.trim().toLowerCase();
|
|
160
|
+
if (token === "i" || token === "insert") seen.add("insert");
|
|
161
|
+
else if (token === "u" || token === "update") seen.add("update");
|
|
162
|
+
else if (token === "d" || token === "delete") seen.add("delete");
|
|
163
|
+
}
|
|
164
|
+
return ["insert", "update", "delete"].filter((op) => seen.has(op));
|
|
165
|
+
}
|
|
166
|
+
function parseSelectionAnswer(answer, candidates) {
|
|
167
|
+
const wanted = new Set(
|
|
168
|
+
answer.split(",").map((s) => s.trim()).filter(Boolean)
|
|
169
|
+
);
|
|
170
|
+
return candidates.filter((c) => wanted.has(c));
|
|
171
|
+
}
|
|
172
|
+
function tablesMissingDocs(tables) {
|
|
173
|
+
return tables.map((t) => ({
|
|
174
|
+
name: t.name,
|
|
175
|
+
missingTableComment: !t.description,
|
|
176
|
+
columnsWithoutComments: t.columns.filter((c) => !c.description).map((c) => c.name)
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
function sqlStringLiteral(text) {
|
|
180
|
+
return `'${text.replace(/'/g, "''")}'`;
|
|
181
|
+
}
|
|
182
|
+
function sqlIdentifier(name) {
|
|
183
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
184
|
+
}
|
|
185
|
+
function buildCommentStatements(entries) {
|
|
186
|
+
const lines = [];
|
|
187
|
+
for (const entry of entries) {
|
|
188
|
+
const table = sqlIdentifier(entry.table);
|
|
189
|
+
if (entry.tableDescription) {
|
|
190
|
+
lines.push(`comment on table public.${table} is ${sqlStringLiteral(entry.tableDescription)};`);
|
|
191
|
+
}
|
|
192
|
+
for (const col of entry.columns) {
|
|
193
|
+
lines.push(
|
|
194
|
+
`comment on column public.${table}.${sqlIdentifier(col.name)} is ${sqlStringLiteral(col.description)};`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return lines.length > 0 ? `${lines.join("\n")}
|
|
199
|
+
` : "";
|
|
200
|
+
}
|
|
201
|
+
var ANONYMOUS_SIGN_IN_WARNING = `// NOTE: this project has anonymous sign-in enabled. An anonymous Supabase
|
|
202
|
+
// Auth session still gets role: authenticated (with an is_anonymous claim),
|
|
203
|
+
// NOT role: anon -- so it passes any RLS policy scoped to "authenticated"
|
|
204
|
+
// unless that policy explicitly excludes auth.jwt()->>'is_anonymous' = 'true'.
|
|
205
|
+
// Double-check your RLS policies (especially on tables granted writes below)
|
|
206
|
+
// account for this before trusting "authenticated" to mean "a real user".
|
|
207
|
+
`;
|
|
208
|
+
function buildFunctionTemplate(opts = {}) {
|
|
209
|
+
const lines = [];
|
|
210
|
+
if (opts.anonymousSignIn) lines.push(ANONYMOUS_SIGN_IN_WARNING);
|
|
211
|
+
lines.push(`import { aiReady } from "npm:@exposify/supabase";`, "");
|
|
212
|
+
lines.push("Deno.serve(aiReady({");
|
|
213
|
+
lines.push(
|
|
214
|
+
opts.writes && opts.writes.length > 0 ? ` writes: ${JSON.stringify(opts.writes)},` : ` // writes: ["orders.insert", "orders.update"],`
|
|
215
|
+
);
|
|
216
|
+
lines.push(
|
|
217
|
+
opts.rpc && opts.rpc.length > 0 ? ` rpc: ${JSON.stringify(opts.rpc)},` : ` // rpc: ["place_order"],`
|
|
218
|
+
);
|
|
219
|
+
lines.push(
|
|
220
|
+
" // functions: {",
|
|
221
|
+
' // refund: { description: "Refund an order", input: { type: "object", properties: {} } },',
|
|
222
|
+
" // },",
|
|
223
|
+
"}));",
|
|
224
|
+
""
|
|
225
|
+
);
|
|
226
|
+
return lines.join("\n");
|
|
227
|
+
}
|
|
228
|
+
function findExistingMigration(filenames, suffix) {
|
|
229
|
+
const matches = filenames.filter((f) => f.endsWith(suffix)).sort();
|
|
230
|
+
return matches[0] ?? null;
|
|
231
|
+
}
|
|
232
|
+
function uniqueMigrationFilename(existing, desired) {
|
|
233
|
+
if (!existing.includes(desired)) return desired;
|
|
234
|
+
const dot = desired.lastIndexOf(".");
|
|
235
|
+
const base = dot === -1 ? desired : desired.slice(0, dot);
|
|
236
|
+
const ext = dot === -1 ? "" : desired.slice(dot);
|
|
237
|
+
let i = 2;
|
|
238
|
+
let candidate = `${base}-${i}${ext}`;
|
|
239
|
+
while (existing.includes(candidate)) candidate = `${base}-${++i}${ext}`;
|
|
240
|
+
return candidate;
|
|
241
|
+
}
|
|
242
|
+
function partialConnectionWarning(url, key) {
|
|
243
|
+
if (url && !key) return "SUPABASE_URL is set but SUPABASE_SERVICE_ROLE_KEY (or --key) is not -- both are required to introspect.";
|
|
244
|
+
if (!url && key) return "SUPABASE_SERVICE_ROLE_KEY (or --key) is set but SUPABASE_URL is not -- both are required to introspect.";
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
133
248
|
// bin/cli.ts
|
|
134
249
|
var PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
135
250
|
var FUNCTION_DIR = "supabase/functions/mcp";
|
|
136
|
-
var
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
|
|
251
|
+
var MIGRATIONS_DIR = "supabase/migrations";
|
|
252
|
+
var FETCH_TIMEOUT_MS = 1e4;
|
|
253
|
+
function fetchWithTimeout(input, init) {
|
|
254
|
+
return fetch(input, { ...init, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
255
|
+
}
|
|
256
|
+
async function listFilesOrEmpty(dir) {
|
|
257
|
+
try {
|
|
258
|
+
return await readdir(dir);
|
|
259
|
+
} catch {
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
}
|
|
146
263
|
async function exists(path) {
|
|
147
264
|
try {
|
|
148
265
|
await access(path);
|
|
@@ -159,15 +276,18 @@ function hasSupabaseCli() {
|
|
|
159
276
|
return false;
|
|
160
277
|
}
|
|
161
278
|
}
|
|
162
|
-
|
|
163
|
-
if (!process.stdin.isTTY) return
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
279
|
+
function openInteractiveReadline() {
|
|
280
|
+
if (!process.stdin.isTTY) return null;
|
|
281
|
+
return createInterface({ input: process.stdin, output: process.stdout });
|
|
282
|
+
}
|
|
283
|
+
async function promptYesNo(rl, question, defaultYes = false) {
|
|
284
|
+
const suffix = defaultYes ? "[Y/n]" : "[y/N]";
|
|
285
|
+
const answer = (await rl.question(`${question} ${suffix} `)).trim().toLowerCase();
|
|
286
|
+
if (answer === "") return defaultYes;
|
|
287
|
+
return answer === "y" || answer === "yes";
|
|
288
|
+
}
|
|
289
|
+
async function promptText(rl, question) {
|
|
290
|
+
return (await rl.question(question)).trim();
|
|
171
291
|
}
|
|
172
292
|
function printManualCommands(opts = {}) {
|
|
173
293
|
console.log("\nRun these to finish setup:");
|
|
@@ -177,66 +297,280 @@ function printManualCommands(opts = {}) {
|
|
|
177
297
|
steps.forEach((cmd, i) => console.log(` ${i + 1}. ${cmd}`));
|
|
178
298
|
console.log(` ${steps.length + 1}. Paste the function URL + a user JWT into an MCP client.`);
|
|
179
299
|
}
|
|
300
|
+
function pushAndDeploy() {
|
|
301
|
+
let pushed = false;
|
|
302
|
+
try {
|
|
303
|
+
console.log("\n$ supabase db push");
|
|
304
|
+
execFileSync("supabase", ["db", "push"], { stdio: "inherit" });
|
|
305
|
+
pushed = true;
|
|
306
|
+
console.log("\n$ supabase functions deploy mcp");
|
|
307
|
+
execFileSync("supabase", ["functions", "deploy", "mcp"], { stdio: "inherit" });
|
|
308
|
+
return { pushed: true, deployed: true };
|
|
309
|
+
} catch (err) {
|
|
310
|
+
return { pushed, deployed: false, error: err };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function resolveConnectionInfo() {
|
|
314
|
+
const url = process.env.SUPABASE_URL ?? argValue("--url");
|
|
315
|
+
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? argValue("--key");
|
|
316
|
+
if (!url || !serviceRoleKey) return null;
|
|
317
|
+
return { url, serviceRoleKey };
|
|
318
|
+
}
|
|
319
|
+
async function tryIntrospect(conn) {
|
|
320
|
+
try {
|
|
321
|
+
const postgrestUrl = `${conn.url.replace(/\/+$/, "")}/rest/v1`;
|
|
322
|
+
const headers = new Headers({ authorization: `Bearer ${conn.serviceRoleKey}`, apikey: conn.serviceRoleKey });
|
|
323
|
+
const [openapi, metaRows] = await Promise.all([
|
|
324
|
+
fetchOpenApi(postgrestUrl, headers, fetchWithTimeout),
|
|
325
|
+
fetchExposifyMeta(postgrestUrl, headers, fetchWithTimeout)
|
|
326
|
+
]);
|
|
327
|
+
return { tables: introspectTables(openapi, metaRows), openapi };
|
|
328
|
+
} catch (err) {
|
|
329
|
+
if (!(err instanceof MissingExposifyMetaError)) {
|
|
330
|
+
console.log(`
|
|
331
|
+
Couldn't introspect the project: ${err instanceof Error ? err.message : String(err)}`);
|
|
332
|
+
}
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
async function tryDetectAnonymousSignIn(conn) {
|
|
337
|
+
try {
|
|
338
|
+
const url = `${conn.url.replace(/\/+$/, "")}/auth/v1/settings`;
|
|
339
|
+
const res = await fetchWithTimeout(url, {
|
|
340
|
+
headers: { apikey: conn.serviceRoleKey, authorization: `Bearer ${conn.serviceRoleKey}` }
|
|
341
|
+
});
|
|
342
|
+
if (!res.ok) return null;
|
|
343
|
+
return parseAnonymousSignInSetting(await res.json());
|
|
344
|
+
} catch {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async function askAnonymousSignIn(rl, conn) {
|
|
349
|
+
const detected = conn ? await tryDetectAnonymousSignIn(conn) : null;
|
|
350
|
+
if (detected !== null) {
|
|
351
|
+
console.log(`
|
|
352
|
+
Detected: anonymous sign-in is ${detected ? "enabled" : "disabled"} on this project.`);
|
|
353
|
+
const confirmed = await promptYesNo(rl, "Is that correct?", true);
|
|
354
|
+
if (confirmed) return detected;
|
|
355
|
+
}
|
|
356
|
+
return promptYesNo(rl, "\nDoes this Supabase project have anonymous sign-in enabled (Authentication \u2192 Sign In / Providers \u2192 Anonymous)?");
|
|
357
|
+
}
|
|
358
|
+
async function askWriteGrants(rl, tables) {
|
|
359
|
+
if (tables.length === 0) return [];
|
|
360
|
+
const configure = await promptYesNo(rl, `
|
|
361
|
+
Configure write access for ${tables.length} table(s)?`);
|
|
362
|
+
if (!configure) return [];
|
|
363
|
+
const grants = [];
|
|
364
|
+
for (const table of tables) {
|
|
365
|
+
const answer = await promptText(
|
|
366
|
+
rl,
|
|
367
|
+
` ${table.name} -- grant writes? (i=insert, u=update, d=delete, comma-separate, Enter=none): `
|
|
368
|
+
);
|
|
369
|
+
const ops = parseWriteOpsAnswer(answer);
|
|
370
|
+
if (ops.length > 0) grants.push({ table: table.name, ops });
|
|
371
|
+
}
|
|
372
|
+
return grants;
|
|
373
|
+
}
|
|
374
|
+
async function askRpcSelection(rl, candidates) {
|
|
375
|
+
if (candidates.length === 0) return [];
|
|
376
|
+
console.log(`
|
|
377
|
+
Available Postgres functions: ${candidates.join(", ")}`);
|
|
378
|
+
const answer = await promptText(rl, "Expose any as tools? (comma-separate, Enter=none): ");
|
|
379
|
+
return parseSelectionAnswer(answer, candidates);
|
|
380
|
+
}
|
|
381
|
+
async function askDocumentation(rl, tables) {
|
|
382
|
+
const missing = tablesMissingDocs(tables).filter((t) => t.missingTableComment || t.columnsWithoutComments.length > 0);
|
|
383
|
+
if (missing.length === 0) return [];
|
|
384
|
+
const configure = await promptYesNo(
|
|
385
|
+
rl,
|
|
386
|
+
`
|
|
387
|
+
${missing.length} table(s) are missing documentation MCP tools would otherwise show agents -- write some now?`
|
|
388
|
+
);
|
|
389
|
+
if (!configure) return [];
|
|
390
|
+
const entries = [];
|
|
391
|
+
for (const t of missing) {
|
|
392
|
+
const entry = { table: t.name, columns: [] };
|
|
393
|
+
if (t.missingTableComment) {
|
|
394
|
+
const desc = await promptText(rl, ` ${t.name} -- what is this table for? (Enter to skip): `);
|
|
395
|
+
if (desc) entry.tableDescription = desc;
|
|
396
|
+
}
|
|
397
|
+
if (t.columnsWithoutComments.length > 0) {
|
|
398
|
+
const addColumns = await promptYesNo(
|
|
399
|
+
rl,
|
|
400
|
+
` Add descriptions for ${t.name}'s ${t.columnsWithoutComments.length} undocumented column(s)?`
|
|
401
|
+
);
|
|
402
|
+
if (addColumns) {
|
|
403
|
+
for (const col of t.columnsWithoutComments) {
|
|
404
|
+
const desc = await promptText(rl, ` ${t.name}.${col}: `);
|
|
405
|
+
if (desc) entry.columns.push({ name: col, description: desc });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (entry.tableDescription || entry.columns.length > 0) entries.push(entry);
|
|
410
|
+
}
|
|
411
|
+
return entries;
|
|
412
|
+
}
|
|
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
|
+
async function warnIfNotSupabaseProject() {
|
|
435
|
+
if (!await exists("supabase/config.toml")) {
|
|
436
|
+
console.warn(
|
|
437
|
+
"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
|
+
);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
180
441
|
async function cmdInit() {
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
const migrationPath = join(migrationsDir, `${timestamp()}_agentify_meta.sql`);
|
|
185
|
-
await writeFile(migrationPath, migrationSql);
|
|
186
|
-
console.log(`Wrote ${migrationPath}`);
|
|
442
|
+
await warnIfNotSupabaseProject();
|
|
443
|
+
const { path: migrationPath, wasNew } = await ensureMetaMigration();
|
|
444
|
+
console.log(wasNew ? `Wrote ${migrationPath}` : `Skipped ${migrationPath} (already scaffolded)`);
|
|
187
445
|
await mkdir(FUNCTION_DIR, { recursive: true });
|
|
188
446
|
const functionPath = join(FUNCTION_DIR, "index.ts");
|
|
189
|
-
|
|
190
|
-
console.log(`Skipped ${functionPath} (already exists)`);
|
|
191
|
-
} else {
|
|
192
|
-
await writeFile(functionPath, FUNCTION_TEMPLATE);
|
|
193
|
-
console.log(`Wrote ${functionPath}`);
|
|
194
|
-
}
|
|
447
|
+
const functionExists = await exists(functionPath);
|
|
195
448
|
await mkdir(join(FUNCTION_DIR, "docs"), { recursive: true });
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
const connect = await promptYesNo(
|
|
202
|
-
"\nApply the migration and deploy the function to whatever Supabase project is currently linked, now?"
|
|
449
|
+
const connWarning = partialConnectionWarning(
|
|
450
|
+
process.env.SUPABASE_URL ?? argValue("--url"),
|
|
451
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY ?? argValue("--key")
|
|
203
452
|
);
|
|
204
|
-
if (
|
|
453
|
+
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
|
+
}
|
|
205
462
|
printManualCommands();
|
|
206
463
|
return;
|
|
207
464
|
}
|
|
208
|
-
let pushed = false;
|
|
209
465
|
try {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
466
|
+
const conn = resolveConnectionInfo();
|
|
467
|
+
const anonymousSignIn = await askAnonymousSignIn(rl, conn);
|
|
468
|
+
let introspection = conn ? await tryIntrospect(conn) : null;
|
|
469
|
+
let alreadyPushed = false;
|
|
470
|
+
let alreadyDeployed = false;
|
|
471
|
+
if (!introspection) {
|
|
472
|
+
if (!functionExists) {
|
|
473
|
+
await writeFile(functionPath, buildFunctionTemplate({ anonymousSignIn }));
|
|
474
|
+
console.log(`Wrote ${functionPath}`);
|
|
475
|
+
} else {
|
|
476
|
+
console.log(`Skipped ${functionPath} (already exists)`);
|
|
477
|
+
}
|
|
478
|
+
if (!hasSupabaseCli()) {
|
|
479
|
+
console.log("\nSupabase CLI not found on PATH -- install it to run the next steps automatically next time.");
|
|
480
|
+
printManualCommands();
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const connect = await promptYesNo(
|
|
484
|
+
rl,
|
|
485
|
+
"\nApply the migration and deploy the function to whatever Supabase project is currently linked, now?"
|
|
486
|
+
);
|
|
487
|
+
if (!connect) {
|
|
488
|
+
printManualCommands();
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const result2 = pushAndDeploy();
|
|
492
|
+
alreadyPushed = result2.pushed;
|
|
493
|
+
alreadyDeployed = result2.deployed;
|
|
494
|
+
if (!result2.deployed) {
|
|
495
|
+
console.error(`
|
|
496
|
+
Supabase CLI command failed: ${result2.error instanceof Error ? result2.error.message : result2.error}`);
|
|
497
|
+
printManualCommands({ alreadyPushed });
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
introspection = conn ? await tryIntrospect(conn) : null;
|
|
501
|
+
if (!introspection) {
|
|
502
|
+
console.log(
|
|
503
|
+
"\nDeployed, but couldn't reach the project to introspect tables (no SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY or --url/--key available). Re-run `exposify-supabase init` with those set to configure writes, RPCs, and documentation interactively."
|
|
504
|
+
);
|
|
505
|
+
console.log("\nDone. Paste the function URL + a user JWT into an MCP client to try it.");
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const grants = await askWriteGrants(rl, introspection.tables);
|
|
510
|
+
const rpcCandidates = rpcCandidatesFromOpenApi(introspection.openapi);
|
|
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
|
+
}
|
|
215
549
|
console.log("\nDone. Paste the function URL + a user JWT into an MCP client to try it.");
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
Supabase CLI command failed: ${err instanceof Error ? err.message : err}`);
|
|
219
|
-
printManualCommands({ alreadyPushed: pushed });
|
|
550
|
+
} finally {
|
|
551
|
+
rl.close();
|
|
220
552
|
}
|
|
221
553
|
}
|
|
222
554
|
async function cmdGenerate() {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
if (!supabaseUrl || !serviceKey) {
|
|
555
|
+
const conn = resolveConnectionInfo();
|
|
556
|
+
if (!conn) {
|
|
226
557
|
console.error(
|
|
227
|
-
"generate needs SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY (env vars, or --url/--key flags) pointing at a running local or linked project (e.g. `supabase start`)."
|
|
558
|
+
"generate needs SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY (env vars, or --url/--key flags) pointing at a running local or linked project (e.g. `supabase start`). The service role key is used only for this one-time introspection call, matching what runtime introspection itself uses -- never written into the generated manifest."
|
|
228
559
|
);
|
|
229
560
|
process.exitCode = 1;
|
|
230
561
|
return;
|
|
231
562
|
}
|
|
232
|
-
const postgrestUrl = `${
|
|
233
|
-
const headers = new Headers({
|
|
563
|
+
const postgrestUrl = `${conn.url.replace(/\/+$/, "")}/rest/v1`;
|
|
564
|
+
const headers = new Headers({
|
|
565
|
+
authorization: `Bearer ${conn.serviceRoleKey}`,
|
|
566
|
+
apikey: conn.serviceRoleKey
|
|
567
|
+
});
|
|
234
568
|
const [openapi, metaRows] = await Promise.all([
|
|
235
|
-
fetchOpenApi(postgrestUrl, headers,
|
|
236
|
-
|
|
569
|
+
fetchOpenApi(postgrestUrl, headers, fetchWithTimeout),
|
|
570
|
+
fetchExposifyMeta(postgrestUrl, headers, fetchWithTimeout)
|
|
237
571
|
]);
|
|
238
572
|
const manifest = buildManifest(openapi, metaRows, (/* @__PURE__ */ new Date()).toISOString());
|
|
239
|
-
const outPath = join(FUNCTION_DIR, "
|
|
573
|
+
const outPath = join(FUNCTION_DIR, "exposify.manifest.json");
|
|
240
574
|
await mkdir(dirname(outPath), { recursive: true });
|
|
241
575
|
await writeFile(outPath, JSON.stringify(manifest, null, 2));
|
|
242
576
|
console.log(`Wrote ${outPath} (${manifest.tables.length} table(s))`);
|
|
@@ -254,10 +588,13 @@ async function main() {
|
|
|
254
588
|
const [, , cmd] = process.argv;
|
|
255
589
|
if (cmd === "init") return cmdInit();
|
|
256
590
|
if (cmd === "generate") return cmdGenerate();
|
|
257
|
-
console.log("Usage:
|
|
258
|
-
console.log(" init Scaffold the
|
|
259
|
-
console.log("
|
|
260
|
-
console.log("
|
|
591
|
+
console.log("Usage: exposify-supabase <init|generate> [--url <url>] [--key <service-role-key>]");
|
|
592
|
+
console.log(" init Scaffold the exposify_meta() migration + supabase/functions/mcp/index.ts.");
|
|
593
|
+
console.log(" Interactively offers to run `supabase db push` + `functions deploy`; if a");
|
|
594
|
+
console.log(" project is reachable (SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY env vars, or --url/--key),");
|
|
595
|
+
console.log(" also walks through anonymous sign-in, per-table writes, RPC exposure, and");
|
|
596
|
+
console.log(" documentation for tables/columns missing a comment.");
|
|
597
|
+
console.log(" generate Build exposify.manifest.json from a running local/linked project");
|
|
261
598
|
process.exitCode = cmd ? 1 : 0;
|
|
262
599
|
}
|
|
263
600
|
main().catch((err) => {
|