@motherbase/cli 0.2.4 → 0.2.5

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.
Files changed (47) hide show
  1. package/README.md +247 -134
  2. package/package.json +2 -2
  3. package/src/api.js +14 -2
  4. package/src/commands/_util.js +49 -6
  5. package/src/commands/backups.js +16 -0
  6. package/src/commands/branches.js +113 -0
  7. package/src/commands/completion.js +90 -0
  8. package/src/commands/db-catalog.js +326 -0
  9. package/src/commands/db.js +16 -5
  10. package/src/commands/domains.js +124 -0
  11. package/src/commands/functions.js +297 -274
  12. package/src/commands/gen.js +172 -126
  13. package/src/commands/import.js +23 -3
  14. package/src/commands/index.js +27 -6
  15. package/src/commands/inspect.js +142 -5
  16. package/src/commands/keys.js +72 -72
  17. package/src/commands/local.js +273 -273
  18. package/src/commands/mcp.js +61 -43
  19. package/src/commands/migrate.js +179 -0
  20. package/src/commands/migration.js +81 -6
  21. package/src/commands/network.js +83 -0
  22. package/src/commands/orgs.js +40 -0
  23. package/src/commands/postgres-config.js +113 -0
  24. package/src/commands/projects.js +8 -3
  25. package/src/commands/queues.js +133 -0
  26. package/src/commands/secrets.js +29 -8
  27. package/src/commands/signing.js +130 -0
  28. package/src/commands/snippets.js +44 -0
  29. package/src/commands/storage.js +192 -44
  30. package/src/commands/test.js +9 -0
  31. package/src/commands/users.js +30 -1
  32. package/src/commands/vault.js +82 -0
  33. package/src/commands/wrappers.js +150 -0
  34. package/src/edge-templates.js +194 -0
  35. package/src/format.js +92 -0
  36. package/src/inspect/csv.js +22 -0
  37. package/src/inspect/queries.js +562 -0
  38. package/src/main.js +41 -7
  39. package/src/mcp/platform-tools.js +233 -0
  40. package/src/mcp/protocol.js +149 -149
  41. package/src/mcp/storage-tools.js +93 -0
  42. package/src/mcp/tools.js +478 -166
  43. package/src/migration/commands.js +375 -0
  44. package/src/migration/files.js +245 -0
  45. package/src/prompt.js +1 -1
  46. package/src/storage/remote.js +62 -0
  47. package/src/commands/migrations.js +0 -85
@@ -104,10 +104,39 @@ const subcommands = {
104
104
  output(ctx, data, (d, ui) => ui.success(`Sessions revoked for ${id}`));
105
105
  },
106
106
  },
107
+ audit: {
108
+ summary: 'Read the auth audit log, newest first',
109
+ usage: 'motherbase users audit [--action <a>] [--actor <email|id>] [--type <log type>] [--from <iso>] [--to <iso>] [--limit <n>]',
110
+ async run(ctx, args, flags) {
111
+ const query = {
112
+ action: flags.action,
113
+ actor: flags.actor,
114
+ log_type: flags.type,
115
+ from: flags.from,
116
+ to: flags.to,
117
+ per_page: flags.limit || 50,
118
+ };
119
+ const data = await api.adminRequest(ctx, 'GET', '/auth/audit-logs', { query });
120
+ const entries = ((data && data.entries) || []).map(e => ({
121
+ at: e.created_at,
122
+ action: e.payload.action,
123
+ actor: e.payload.actor_username || e.payload.actor_id,
124
+ ip: e.ip_address,
125
+ }));
126
+ output(ctx, data, (_d, ui) =>
127
+ ui.table(entries, [
128
+ { key: 'at', label: 'AT' },
129
+ { key: 'action', label: 'ACTION' },
130
+ { key: 'actor', label: 'ACTOR' },
131
+ { key: 'ip', label: 'IP' },
132
+ ]),
133
+ );
134
+ },
135
+ },
107
136
  };
108
137
 
109
138
  module.exports = {
110
139
  summary: 'Project auth users',
111
- usage: 'motherbase users <list|get|create|update|delete|reset-password|promote|demote|revoke-sessions> ...',
140
+ usage: 'motherbase users <list|get|create|update|delete|reset-password|promote|demote|revoke-sessions|audit> ...',
112
141
  subcommands,
113
142
  };
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ const api = require('../api');
4
+ const { output, requireArg } = require('./_util');
5
+ const { CliError } = require('../context');
6
+
7
+ const subcommands = {
8
+ list: {
9
+ summary: 'List Vault secrets (names and metadata, never values)',
10
+ usage: 'motherbase vault list',
11
+ async run(ctx) {
12
+ const data = await api.adminRequest(ctx, 'GET', '/vault');
13
+ const secrets = (data && data.secrets) || [];
14
+ output(ctx, data, (_d, ui) =>
15
+ ui.table(secrets, [
16
+ { key: 'id', label: 'ID' },
17
+ { key: 'name', label: 'NAME' },
18
+ { key: 'description', label: 'DESCRIPTION' },
19
+ { key: 'updatedAt', label: 'UPDATED' },
20
+ ]),
21
+ );
22
+ },
23
+ },
24
+ enable: {
25
+ summary: 'Install the Vault for this project',
26
+ usage: 'motherbase vault enable',
27
+ async run(ctx) {
28
+ const data = await api.adminRequest(ctx, 'POST', '/vault/enable');
29
+ output(ctx, data, (_d, ui) => ui.success('Vault enabled'));
30
+ },
31
+ },
32
+ create: {
33
+ summary: 'Store an encrypted secret',
34
+ usage: 'motherbase vault create <name> --secret <value> [--description <text>]',
35
+ async run(ctx, args, flags) {
36
+ const name = requireArg(args[0], 'name');
37
+ if (typeof flags.secret !== 'string') throw new CliError('--secret is required');
38
+ const body = { name, secret: flags.secret };
39
+ if (typeof flags.description === 'string') body.description = flags.description;
40
+ const data = await api.adminRequest(ctx, 'POST', '/vault/secrets', { body });
41
+ output(ctx, data, (_d, ui) => ui.success(`Created secret ${name}`));
42
+ },
43
+ },
44
+ update: {
45
+ summary: 'Rename a secret, change its value or its description',
46
+ usage: 'motherbase vault update <id> [--name <n>] [--secret <value>] [--description <text>]',
47
+ async run(ctx, args, flags) {
48
+ const id = requireArg(args[0], 'id');
49
+ const body = {};
50
+ for (const key of ['name', 'secret', 'description']) {
51
+ if (typeof flags[key] === 'string') body[key] = flags[key];
52
+ }
53
+ if (!Object.keys(body).length) throw new CliError('nothing to update: pass --name, --secret or --description');
54
+ const data = await api.adminRequest(ctx, 'PATCH', `/vault/secrets/${encodeURIComponent(id)}`, { body });
55
+ output(ctx, data, (_d, ui) => ui.success(`Updated secret ${id}`));
56
+ },
57
+ },
58
+ reveal: {
59
+ summary: 'Print the decrypted value of a secret',
60
+ usage: 'motherbase vault reveal <id>',
61
+ async run(ctx, args) {
62
+ const id = requireArg(args[0], 'id');
63
+ const data = await api.adminRequest(ctx, 'GET', `/vault/secrets/${encodeURIComponent(id)}/reveal`);
64
+ output(ctx, data, (d, ui) => ui.print(String((d && d.secret) ?? '')));
65
+ },
66
+ },
67
+ delete: {
68
+ summary: 'Delete a secret',
69
+ usage: 'motherbase vault delete <id>',
70
+ async run(ctx, args) {
71
+ const id = requireArg(args[0], 'id');
72
+ const data = await api.adminRequest(ctx, 'DELETE', `/vault/secrets/${encodeURIComponent(id)}`);
73
+ output(ctx, data, (_d, ui) => ui.success(`Deleted secret ${id}`));
74
+ },
75
+ },
76
+ };
77
+
78
+ module.exports = {
79
+ summary: 'Encrypted secrets (Vault)',
80
+ usage: 'motherbase vault <list|enable|create|update|reveal|delete> ...',
81
+ subcommands,
82
+ };
@@ -0,0 +1,150 @@
1
+ 'use strict';
2
+
3
+ const api = require('../api');
4
+ const { output, requireArg, parseKeyValues } = require('./_util');
5
+ const { CliError } = require('../context');
6
+
7
+ const enc = encodeURIComponent;
8
+
9
+ // `--option host=db.example.com --option port=5432`, repeatable.
10
+ function options(flags) {
11
+ return parseKeyValues([].concat(flags.option || []), { coerce: false });
12
+ }
13
+
14
+ // `schema.name` -> [schema, name]
15
+ function qualified(ref) {
16
+ const [schema, name, ...rest] = requireArg(ref, 'schema.table').split('.');
17
+ if (!schema || !name || rest.length) throw new CliError('expected <schema>.<table>');
18
+ return [schema, name];
19
+ }
20
+
21
+ // `--column id:integer --column label:text`, repeatable.
22
+ function columns(flags) {
23
+ const specs = [].concat(flags.column || []);
24
+ if (!specs.length) throw new CliError('at least one --column <name>:<type> is required');
25
+ return specs.map(spec => {
26
+ const idx = String(spec).indexOf(':');
27
+ if (idx <= 0) throw new CliError(`expected <name>:<type>, got "${spec}"`);
28
+ return { name: spec.slice(0, idx), type: spec.slice(idx + 1) };
29
+ });
30
+ }
31
+
32
+ const subcommands = {
33
+ list: {
34
+ summary: 'List wrappers, foreign servers and foreign tables',
35
+ usage: 'motherbase wrappers list',
36
+ async run(ctx) {
37
+ const data = await api.adminRequest(ctx, 'GET', '/wrappers');
38
+ output(ctx, data, (d, ui) => {
39
+ ui.table((d && d.wrappers) || [], [
40
+ { key: 'name', label: 'WRAPPER' },
41
+ { key: 'available', label: 'AVAILABLE' },
42
+ { key: 'installed', label: 'INSTALLED' },
43
+ ]);
44
+ ui.table((d && d.servers) || [], [
45
+ { key: 'name', label: 'SERVER' },
46
+ { key: 'wrapper', label: 'WRAPPER' },
47
+ { key: 'remoteUser', label: 'REMOTE USER' },
48
+ { key: 'foreignTables', label: 'TABLES' },
49
+ ]);
50
+ ui.table((d && d.tables) || [], [
51
+ { key: 'schema', label: 'SCHEMA' },
52
+ { key: 'name', label: 'TABLE' },
53
+ { key: 'server', label: 'SERVER' },
54
+ { key: 'columns', label: 'COLUMNS' },
55
+ ]);
56
+ });
57
+ },
58
+ },
59
+ enable: {
60
+ summary: 'Install a wrapper extension',
61
+ usage: 'motherbase wrappers enable <postgres_fdw|file_fdw>',
62
+ async run(ctx, args) {
63
+ const wrapper = requireArg(args[0], 'wrapper');
64
+ const data = await api.adminRequest(ctx, 'POST', `/wrappers/${enc(wrapper)}/enable`);
65
+ output(ctx, data, (_d, ui) => ui.success(`Enabled ${wrapper}`));
66
+ },
67
+ },
68
+ 'create-server': {
69
+ summary: 'Create a foreign server; the password goes to the Vault',
70
+ usage:
71
+ 'motherbase wrappers create-server <name> --wrapper <postgres_fdw|file_fdw> [--option k=v ...] [--user <u> --password <p>]',
72
+ async run(ctx, args, flags) {
73
+ const name = requireArg(args[0], 'name');
74
+ const body = { name, wrapper: requireArg(flags.wrapper, '--wrapper'), options: options(flags) };
75
+ if (typeof flags.user === 'string') body.user = flags.user;
76
+ if (typeof flags.password === 'string') body.password = flags.password;
77
+ const data = await api.adminRequest(ctx, 'POST', '/wrappers/servers', { body });
78
+ output(ctx, data, (_d, ui) => ui.success(`Created foreign server ${name}`));
79
+ },
80
+ },
81
+ 'drop-server': {
82
+ summary: 'Drop a foreign server',
83
+ usage: 'motherbase wrappers drop-server <name>',
84
+ async run(ctx, args) {
85
+ const name = requireArg(args[0], 'name');
86
+ const data = await api.adminRequest(ctx, 'DELETE', `/wrappers/servers/${enc(name)}`);
87
+ output(ctx, data, (_d, ui) => ui.success(`Dropped foreign server ${name}`));
88
+ },
89
+ },
90
+ credentials: {
91
+ summary: 'Replace the remote credentials of a foreign server',
92
+ usage: 'motherbase wrappers credentials <server> --user <u> --password <p>',
93
+ async run(ctx, args, flags) {
94
+ const name = requireArg(args[0], 'server');
95
+ if (typeof flags.user !== 'string' || typeof flags.password !== 'string') {
96
+ throw new CliError('--user and --password are required');
97
+ }
98
+ const body = { user: flags.user, password: flags.password };
99
+ const data = await api.adminRequest(ctx, 'PUT', `/wrappers/servers/${enc(name)}/credentials`, { body });
100
+ output(ctx, data, (_d, ui) => ui.success(`Updated credentials of ${name}`));
101
+ },
102
+ },
103
+ import: {
104
+ summary: 'Import a remote schema as foreign tables',
105
+ usage:
106
+ 'motherbase wrappers import <server> --remote-schema <s> --local-schema <s> [--tables a,b]',
107
+ async run(ctx, args, flags) {
108
+ const name = requireArg(args[0], 'server');
109
+ const body = {
110
+ remoteSchema: requireArg(flags['remote-schema'], '--remote-schema'),
111
+ localSchema: requireArg(flags['local-schema'], '--local-schema'),
112
+ };
113
+ if (typeof flags.tables === 'string') body.tables = flags.tables.split(',').map(t => t.trim()).filter(Boolean);
114
+ const data = await api.adminRequest(ctx, 'POST', `/wrappers/servers/${enc(name)}/import`, { body });
115
+ output(ctx, data, (_d, ui) => ui.success(`Imported ${body.remoteSchema} into ${body.localSchema}`));
116
+ },
117
+ },
118
+ 'create-table': {
119
+ summary: 'Create one foreign table',
120
+ usage:
121
+ 'motherbase wrappers create-table <schema>.<table> --server <s> --column <name>:<type> ... [--option k=v ...]',
122
+ async run(ctx, args, flags) {
123
+ const [schema, name] = qualified(args[0]);
124
+ const body = {
125
+ server: requireArg(flags.server, '--server'),
126
+ schema,
127
+ name,
128
+ columns: columns(flags),
129
+ options: options(flags),
130
+ };
131
+ const data = await api.adminRequest(ctx, 'POST', '/wrappers/tables', { body });
132
+ output(ctx, data, (_d, ui) => ui.success(`Created foreign table ${schema}.${name}`));
133
+ },
134
+ },
135
+ 'drop-table': {
136
+ summary: 'Drop a foreign table',
137
+ usage: 'motherbase wrappers drop-table <schema>.<table>',
138
+ async run(ctx, args) {
139
+ const [schema, name] = qualified(args[0]);
140
+ const data = await api.adminRequest(ctx, 'DELETE', `/wrappers/tables/${enc(schema)}/${enc(name)}`);
141
+ output(ctx, data, (_d, ui) => ui.success(`Dropped foreign table ${schema}.${name}`));
142
+ },
143
+ },
144
+ };
145
+
146
+ module.exports = {
147
+ summary: 'Foreign data wrappers (postgres_fdw, file_fdw)',
148
+ usage: 'motherbase wrappers <list|enable|create-server|drop-server|credentials|import|create-table|drop-table> ...',
149
+ subcommands,
150
+ };
@@ -0,0 +1,194 @@
1
+ 'use strict';
2
+
3
+ // Starting points for `motherbase functions new --template <id>`. Pure data.
4
+ // The Studio's "new function" picker offers the same list
5
+ // (apps/studio/src/features/api-tools/lib/function-templates.ts): keep the ids
6
+ // and sources in step.
7
+ //
8
+ // Every source is a module-shaped function (`export default handler`) that runs
9
+ // on the Deno edge runtime. `%NAME%` is replaced with the function's name.
10
+
11
+ const templates = [
12
+ {
13
+ id: 'hello-world',
14
+ label: 'Hello world',
15
+ description: 'Echo the request body and the time.',
16
+ source: `// Edge function: %NAME%
17
+ //
18
+ // Runs on Deno with the whole Web platform: Request, Response, fetch, streams,
19
+ // crypto.subtle (for webhook signature checks) and friends. npm:, jsr: and URL
20
+ // imports load as they do on Deno; a bare package name is read as npm:
21
+ //
22
+ // import { z } from 'zod';
23
+ //
24
+ // \`env\` holds this project's edge secrets, and \`sendMail()\` sends through the
25
+ // project's own SMTP settings (the same transport auth uses):
26
+ //
27
+ // await sendMail({ to: 'sales@example.com', subject: 'New lead', text: '...' });
28
+
29
+ export default async (req: Request) => {
30
+ const body = req.method === 'GET' ? {} : await req.json().catch(() => ({}));
31
+ return Response.json({ hello: '%NAME%', received: body, at: new Date().toISOString() });
32
+ };
33
+ `,
34
+ },
35
+ {
36
+ id: 'stripe-webhook',
37
+ label: 'Stripe webhook',
38
+ description: 'Verify a Stripe signature, then handle the event.',
39
+ source: `// Edge function: %NAME%
40
+ // Secrets to set: STRIPE_WEBHOOK_SECRET (the endpoint's "whsec_..." signing secret).
41
+
42
+ const encoder = new TextEncoder();
43
+
44
+ async function sign(secret: string, payload: string): Promise<string> {
45
+ const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
46
+ const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(payload));
47
+ return [...new Uint8Array(mac)].map(byte => byte.toString(16).padStart(2, '0')).join('');
48
+ }
49
+
50
+ export default async (req: Request) => {
51
+ // The raw body: a re-serialised one no longer matches the signature.
52
+ const payload = await req.text();
53
+ const header = req.headers.get('stripe-signature') ?? '';
54
+ const parts = Object.fromEntries(header.split(',').map(part => part.split('=') as [string, string]));
55
+ const expected = await sign(env.STRIPE_WEBHOOK_SECRET, \`\${parts.t}.\${payload}\`);
56
+ if (!parts.v1 || parts.v1 !== expected) return new Response('Invalid signature', { status: 400 });
57
+
58
+ const event = JSON.parse(payload);
59
+ switch (event.type) {
60
+ case 'checkout.session.completed':
61
+ console.log('checkout completed', event.data.object.id);
62
+ break;
63
+ default:
64
+ console.log('ignored event', event.type);
65
+ }
66
+ return Response.json({ received: true });
67
+ };
68
+ `,
69
+ },
70
+ {
71
+ id: 'openai',
72
+ label: 'OpenAI',
73
+ description: 'Send a prompt to OpenAI and return the answer.',
74
+ source: `// Edge function: %NAME%
75
+ // Secrets to set: OPENAI_API_KEY.
76
+
77
+ export default async (req: Request) => {
78
+ const { prompt } = await req.json();
79
+ if (typeof prompt !== 'string' || !prompt) return Response.json({ error: 'prompt is required' }, { status: 400 });
80
+
81
+ const response = await fetch('https://api.openai.com/v1/chat/completions', {
82
+ method: 'POST',
83
+ headers: { 'content-type': 'application/json', authorization: \`Bearer \${env.OPENAI_API_KEY}\` },
84
+ body: JSON.stringify({ model: 'gpt-4o-mini', messages: [{ role: 'user', content: prompt }] }),
85
+ });
86
+ if (!response.ok) return Response.json({ error: 'OpenAI request failed' }, { status: 502 });
87
+
88
+ const completion = await response.json();
89
+ return Response.json({ answer: completion.choices[0].message.content });
90
+ };
91
+ `,
92
+ },
93
+ {
94
+ id: 'resend-email',
95
+ label: 'Send email',
96
+ description: 'Send a transactional email with Resend, or with sendMail.',
97
+ source: `// Edge function: %NAME%
98
+ // Secrets to set: RESEND_API_KEY. Prefer this project's own SMTP? Replace the
99
+ // fetch below with: await sendMail({ to, subject, html });
100
+
101
+ export default async (req: Request) => {
102
+ const { to, subject, html } = await req.json();
103
+ if (!to || !subject || !html) return Response.json({ error: 'to, subject and html are required' }, { status: 400 });
104
+
105
+ const response = await fetch('https://api.resend.com/emails', {
106
+ method: 'POST',
107
+ headers: { 'content-type': 'application/json', authorization: \`Bearer \${env.RESEND_API_KEY}\` },
108
+ body: JSON.stringify({ from: 'Acme <onboarding@resend.dev>', to, subject, html }),
109
+ });
110
+ return Response.json(await response.json(), { status: response.ok ? 200 : 502 });
111
+ };
112
+ `,
113
+ },
114
+ {
115
+ id: 'image-transform',
116
+ label: 'Image transform',
117
+ description: 'Resize an image fetched from a URL.',
118
+ source: `// Edge function: %NAME%
119
+ // GET ?url=<image>&width=<px>. Uses ImageScript, a pure-JS image library.
120
+
121
+ import { Image } from 'npm:imagescript@1';
122
+
123
+ export default async (req: Request) => {
124
+ const params = new URL(req.url).searchParams;
125
+ const source = params.get('url');
126
+ const width = Math.min(Number(params.get('width') ?? 320), 2000);
127
+ if (!source || !Number.isFinite(width) || width < 1) return new Response('url and a positive width are required', { status: 400 });
128
+
129
+ const upstream = await fetch(source);
130
+ if (!upstream.ok) return new Response('Could not fetch the image', { status: 502 });
131
+
132
+ const image = await Image.decode(new Uint8Array(await upstream.arrayBuffer()));
133
+ const resized = image.resize(width, Image.RESIZE_AUTO);
134
+ return new Response(await resized.encode(), { headers: { 'content-type': 'image/png' } });
135
+ };
136
+ `,
137
+ },
138
+ {
139
+ id: 'websocket',
140
+ label: 'WebSocket',
141
+ description: 'Upgrade the request and echo every message.',
142
+ source: `// Edge function: %NAME%
143
+
144
+ export default (req: Request) => {
145
+ if (req.headers.get('upgrade') !== 'websocket') return new Response('Expected a WebSocket upgrade', { status: 426 });
146
+
147
+ const { socket, response } = Deno.upgradeWebSocket(req);
148
+ socket.onopen = () => console.log('client connected');
149
+ socket.onmessage = event => socket.send(\`echo: \${event.data}\`);
150
+ socket.onclose = () => console.log('client disconnected');
151
+ return response;
152
+ };
153
+ `,
154
+ },
155
+ {
156
+ id: 'storage-upload',
157
+ label: 'Storage upload',
158
+ description: 'Store a request body as a file in Storage.',
159
+ source: `// Edge function: %NAME%
160
+ // Secrets to set: SERVICE_TOKEN (a token allowed to write files). API_URL is
161
+ // optional and defaults to this project's own API.
162
+
163
+ export default async (req: Request) => {
164
+ const { path, text } = await req.json();
165
+ if (!path || typeof text !== 'string') return Response.json({ error: 'path and text are required' }, { status: 400 });
166
+
167
+ const form = new FormData();
168
+ form.append('file', new Blob([text], { type: 'text/plain' }), path);
169
+ form.append('path', path);
170
+
171
+ const response = await fetch(\`\${env.API_URL ?? 'http://api:5001'}/api/files/\`, {
172
+ method: 'POST',
173
+ headers: { authorization: \`Bearer \${env.SERVICE_TOKEN}\` },
174
+ body: form,
175
+ });
176
+ return Response.json(await response.json(), { status: response.ok ? 200 : 502 });
177
+ };
178
+ `,
179
+ },
180
+ ];
181
+
182
+ const DEFAULT_TEMPLATE = 'hello-world';
183
+
184
+ /** The template with this id, or undefined. */
185
+ function findTemplate(id) {
186
+ return templates.find(template => template.id === id);
187
+ }
188
+
189
+ /** The source of a template for a function of this name. */
190
+ function renderTemplate(template, name) {
191
+ return template.source.replace(/%NAME%/g, name);
192
+ }
193
+
194
+ module.exports = { templates, DEFAULT_TEMPLATE, findTemplate, renderTemplate };
package/src/format.js ADDED
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ // Machine output formats for `-o/--output`, matching the Supabase CLI's set
4
+ // (minus toml). `pretty` is each command's own human renderer; the rest are
5
+ // rendered here from the command's data, so every command gets them for free.
6
+
7
+ const { CliError } = require('./context');
8
+
9
+ const FORMATS = ['pretty', 'json', 'yaml', 'env'];
10
+
11
+ // Resolved once in main (`-o json` also sets `json`), but commands and tests
12
+ // that only pass `json: true` still land on the right format.
13
+ function outputFormat(flags = {}) {
14
+ return flags.format || (flags.json ? 'json' : 'pretty');
15
+ }
16
+
17
+ // ── YAML ─────────────────────────────────────────────────────────────────────
18
+
19
+ const YAML_RESERVED = /^(true|false|yes|no|on|off|null|~)$/i;
20
+
21
+ // A string stays plain only when YAML would read it back as that same string;
22
+ // anything else is double-quoted, and JSON's quoting is valid YAML.
23
+ function yamlScalar(value) {
24
+ if (value === null || value === undefined) return 'null';
25
+ if (typeof value !== 'string') return String(value);
26
+ const plain =
27
+ /^[A-Za-z0-9_./@-][A-Za-z0-9_ ./@:-]*$/.test(value) &&
28
+ !/(: |:$| $)/.test(value) &&
29
+ !YAML_RESERVED.test(value) &&
30
+ Number.isNaN(Number(value)) &&
31
+ !/^\d{4}-\d\d-\d\d/.test(value);
32
+ return plain ? value : JSON.stringify(value);
33
+ }
34
+
35
+ function isNonEmptyCollection(value) {
36
+ return value !== null && typeof value === 'object' && Object.keys(value).length > 0;
37
+ }
38
+
39
+ function yamlLines(value) {
40
+ if (Array.isArray(value)) {
41
+ if (!value.length) return ['[]'];
42
+ return value.flatMap(item => {
43
+ const lines = yamlLines(item);
44
+ return [`- ${lines[0]}`, ...lines.slice(1).map(l => ` ${l}`)];
45
+ });
46
+ }
47
+ if (value !== null && typeof value === 'object') {
48
+ const keys = Object.keys(value).filter(k => value[k] !== undefined);
49
+ if (!keys.length) return ['{}'];
50
+ return keys.flatMap(key => {
51
+ const child = value[key];
52
+ const lines = yamlLines(child);
53
+ return isNonEmptyCollection(child)
54
+ ? [`${yamlScalar(key)}:`, ...lines.map(l => ` ${l}`)]
55
+ : [`${yamlScalar(key)}: ${lines[0]}`];
56
+ });
57
+ }
58
+ return [yamlScalar(value)];
59
+ }
60
+
61
+ function toYaml(value) {
62
+ return yamlLines(value).join('\n') + '\n';
63
+ }
64
+
65
+ // ── env ──────────────────────────────────────────────────────────────────────
66
+
67
+ // `apiUrl` → `API_URL`, nested keys joined with `_`, as `supabase status -o env`.
68
+ function envKey(key) {
69
+ return String(key)
70
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
71
+ .replace(/[^A-Za-z0-9]+/g, '_')
72
+ .toUpperCase();
73
+ }
74
+
75
+ function flattenEnv(value, prefix, out) {
76
+ for (const [key, child] of Object.entries(value)) {
77
+ if (child === undefined) continue;
78
+ const name = prefix ? `${prefix}_${envKey(key)}` : envKey(key);
79
+ if (child !== null && typeof child === 'object') flattenEnv(child, name, out);
80
+ else out.push(`${name}=${JSON.stringify(child === null ? '' : String(child))}`);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ function toEnv(value) {
86
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
87
+ throw new CliError('The env output format needs a single object; this command returns a list. Use -o json or -o yaml.');
88
+ }
89
+ return flattenEnv(value, '', []).join('\n') + '\n';
90
+ }
91
+
92
+ module.exports = { FORMATS, outputFormat, toYaml, toEnv };
@@ -0,0 +1,22 @@
1
+ 'use strict';
2
+
3
+ // CSV for `inspect report`, written the way Postgres' `COPY ... TO STDOUT WITH
4
+ // CSV HEADER` writes it (which is how the Supabase CLI produces its report), so
5
+ // the files read the same whichever tool made them: NULL is an empty field, an
6
+ // empty string is `""`, booleans are `t`/`f`.
7
+
8
+ function field(value) {
9
+ if (value === null || value === undefined) return '';
10
+ if (typeof value === 'boolean') return value ? 't' : 'f';
11
+ const text = typeof value === 'object' ? JSON.stringify(value) : String(value);
12
+ return text === '' || /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
13
+ }
14
+
15
+ /** One CSV document: a header of column keys, then one line per row. */
16
+ function toCsv(columns, rows) {
17
+ const lines = [columns.map(c => field(c.key)).join(',')];
18
+ for (const row of rows) lines.push(columns.map(c => field(row[c.key])).join(','));
19
+ return lines.join('\n') + '\n';
20
+ }
21
+
22
+ module.exports = { toCsv };