@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/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
- throw new Error(`PostgREST OpenAPI fetch failed: HTTP ${res.status}`);
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 MissingAgentifyMetaError = class extends Error {
29
+ var MissingExposifyMetaError = class extends Error {
23
30
  constructor(cause) {
24
31
  super(cause);
25
- this.name = "MissingAgentifyMetaError";
32
+ this.name = "MissingExposifyMetaError";
26
33
  }
27
34
  };
28
- async function fetchAgentifyMeta(postgrestUrl, headers, fetchImpl) {
29
- const res = await fetchImpl(`${postgrestUrl}/rpc/agentify_meta`, {
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 MissingAgentifyMetaError(
36
- "agentify_meta() is not reachable at /rpc/agentify_meta (404). Run `npx agentify-supabase init` (or apply sql/agentify_meta.sql directly) and `supabase db push`, then redeploy the function."
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 MissingAgentifyMetaError(
43
- "agentify_meta() is missing from the database (PostgREST error PGRST202). Apply the migration in sql/agentify_meta.sql via `supabase db push`, then redeploy the function."
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(`agentify_meta() call failed: HTTP ${res.status} ${body}`.trim());
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. agentify_meta()'s copy wins when both exist anyway
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
- // agentify_meta()'s comment wins over OpenAPI's mirrored copy (spec:
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 FUNCTION_TEMPLATE = `import { aiReady } from "npm:@exposify/supabase";
137
-
138
- Deno.serve(aiReady({
139
- // writes: ["orders.insert", "orders.update"],
140
- // rpc: ["place_order"],
141
- // functions: {
142
- // refund: { description: "Refund an order", input: { type: "object", properties: {} } },
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
- async function promptYesNo(question) {
163
- if (!process.stdin.isTTY) return false;
164
- const rl = createInterface({ input: process.stdin, output: process.stdout });
165
- try {
166
- const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
167
- return answer === "y" || answer === "yes";
168
- } finally {
169
- rl.close();
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
- const migrationSql = await readFile(join(PACKAGE_ROOT, "sql/agentify_meta.sql"), "utf8");
182
- const migrationsDir = "supabase/migrations";
183
- await mkdir(migrationsDir, { recursive: true });
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
- if (await exists(functionPath)) {
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
- if (!hasSupabaseCli()) {
197
- console.log("\nSupabase CLI not found on PATH -- install it to run the next steps automatically next time.");
198
- printManualCommands();
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 (!connect) {
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
- console.log("\n$ supabase db push");
211
- execFileSync("supabase", ["db", "push"], { stdio: "inherit" });
212
- pushed = true;
213
- console.log("\n$ supabase functions deploy mcp");
214
- execFileSync("supabase", ["functions", "deploy", "mcp"], { stdio: "inherit" });
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
- } catch (err) {
217
- console.error(`
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 supabaseUrl = process.env.SUPABASE_URL ?? argValue("--url");
224
- const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? argValue("--key");
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 = `${supabaseUrl.replace(/\/+$/, "")}/rest/v1`;
233
- const headers = new Headers({ authorization: `Bearer ${serviceKey}`, apikey: serviceKey });
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, fetch),
236
- fetchAgentifyMeta(postgrestUrl, headers, fetch)
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, "agentify.manifest.json");
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: agentify-supabase <init|generate>");
258
- console.log(" init Scaffold the agentify_meta() migration + supabase/functions/mcp/index.ts,");
259
- console.log(" then interactively offer to run `supabase db push` + `functions deploy`");
260
- console.log(" generate Build agentify.manifest.json from a running local/linked project");
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) => {