@motherbase/cli 0.2.4 → 0.2.6

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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -18
  3. package/bin/motherbase.js +10 -10
  4. package/package.json +35 -26
  5. package/src/api.js +233 -229
  6. package/src/commands/_util.js +106 -50
  7. package/src/commands/backups.js +16 -0
  8. package/src/commands/branches.js +113 -0
  9. package/src/commands/completion.js +90 -0
  10. package/src/commands/config.js +47 -47
  11. package/src/commands/cron.js +65 -65
  12. package/src/commands/db-catalog.js +326 -0
  13. package/src/commands/db.js +103 -92
  14. package/src/commands/domains.js +124 -0
  15. package/src/commands/functions.js +63 -33
  16. package/src/commands/gen.js +63 -17
  17. package/src/commands/import.js +55 -35
  18. package/src/commands/index.js +85 -64
  19. package/src/commands/inspect.js +178 -41
  20. package/src/commands/link.js +35 -35
  21. package/src/commands/local.js +23 -2
  22. package/src/commands/login.js +39 -39
  23. package/src/commands/logout.js +16 -16
  24. package/src/commands/mcp.js +27 -9
  25. package/src/commands/migrate.js +207 -0
  26. package/src/commands/migration.js +108 -33
  27. package/src/commands/network.js +83 -0
  28. package/src/commands/orgs.js +40 -0
  29. package/src/commands/postgres-config.js +113 -0
  30. package/src/commands/projects.js +130 -125
  31. package/src/commands/queues.js +133 -0
  32. package/src/commands/realtime.js +35 -35
  33. package/src/commands/rls.js +176 -176
  34. package/src/commands/secrets.js +66 -45
  35. package/src/commands/signing.js +130 -0
  36. package/src/commands/snippets.js +44 -0
  37. package/src/commands/status.js +42 -42
  38. package/src/commands/storage.js +261 -113
  39. package/src/commands/test.js +9 -0
  40. package/src/commands/unlink.js +15 -15
  41. package/src/commands/users.js +142 -113
  42. package/src/commands/vault.js +82 -0
  43. package/src/commands/webhooks.js +62 -62
  44. package/src/commands/whoami.js +20 -20
  45. package/src/commands/wrappers.js +150 -0
  46. package/src/config.js +98 -98
  47. package/src/context.js +106 -102
  48. package/src/docs.js +68 -0
  49. package/src/edge-templates.js +194 -0
  50. package/src/format.js +92 -0
  51. package/src/inspect/csv.js +22 -0
  52. package/src/inspect/queries.js +562 -0
  53. package/src/main.js +224 -160
  54. package/src/mcp/platform-tools.js +233 -0
  55. package/src/mcp/protocol.js +4 -1
  56. package/src/mcp/storage-tools.js +93 -0
  57. package/src/mcp/tools.js +318 -6
  58. package/src/migration/commands.js +375 -0
  59. package/src/migration/files.js +245 -0
  60. package/src/prompt.js +91 -91
  61. package/src/storage/remote.js +62 -0
  62. package/src/ui.js +111 -111
  63. package/src/commands/migrations.js +0 -85
@@ -1,176 +1,176 @@
1
- 'use strict';
2
-
3
- const api = require('../api');
4
- const { output, requireArg } = require('./_util');
5
- const { CliError } = require('../context');
6
-
7
- const policy = {
8
- summary: 'Manage RLS policies',
9
- usage: 'motherbase rls policy create --table <t> --name <n> --command <SELECT|INSERT|UPDATE|DELETE|ALL> [--using <expr>] [--check <expr>] [--roles a,b] | motherbase rls policy delete <table> <name>',
10
- subcommands: {
11
- create: {
12
- summary: 'Create an RLS policy',
13
- usage: 'motherbase rls policy create --table <t> --name <n> --command <cmd> [--using <expr>] [--check <expr>] [--roles a,b]',
14
- async run(ctx, args, flags) {
15
- const body = {
16
- table: flags.table,
17
- name: flags.name,
18
- command: (flags.command || 'ALL').toUpperCase(),
19
- };
20
- if (!body.table || !body.name) throw new CliError('--table and --name are required');
21
- if (flags.using) body.using = flags.using;
22
- if (flags.check) body.check = flags.check;
23
- if (flags.roles) body.roles = String(flags.roles).split(',').map(s => s.trim()).filter(Boolean);
24
- const data = await api.adminRequest(ctx, 'POST', '/rls/policies', { body });
25
- output(ctx, data, (d, ui) => ui.success(`Created policy ${body.name} on ${body.table}`));
26
- },
27
- },
28
- delete: {
29
- summary: 'Delete an RLS policy',
30
- usage: 'motherbase rls policy delete <table> <name>',
31
- async run(ctx, args) {
32
- const table = requireArg(args[0], 'table');
33
- const name = requireArg(args[1], 'name');
34
- const data = await api.adminRequest(ctx, 'DELETE', '/rls/policies', { body: { table, name } });
35
- output(ctx, data, (d, ui) => ui.success(`Dropped policy ${name} on ${table}`));
36
- },
37
- },
38
- },
39
- };
40
-
41
- const subcommands = {
42
- list: {
43
- summary: 'List tables with RLS status and policies',
44
- usage: 'motherbase rls list',
45
- async run(ctx) {
46
- const data = await api.adminRequest(ctx, 'GET', '/rls/tables');
47
- output(ctx, data, (rows, ui) =>
48
- ui.table((Array.isArray(rows) ? rows : []).map(r => ({
49
- table: r.table,
50
- rls: r.rlsEnabled ? 'on' : 'off',
51
- policies: Array.isArray(r.policies) ? r.policies.length : 0,
52
- })), [
53
- { key: 'table', label: 'TABLE' },
54
- { key: 'rls', label: 'RLS' },
55
- { key: 'policies', label: 'POLICIES' },
56
- ]),
57
- );
58
- },
59
- },
60
- enable: {
61
- summary: 'Enable RLS on a table',
62
- usage: 'motherbase rls enable <table>',
63
- async run(ctx, args) {
64
- const table = requireArg(args[0], 'table');
65
- const data = await api.adminRequest(ctx, 'POST', `/rls/tables/${table}/rls`, { body: { enabled: true } });
66
- output(ctx, data, (d, ui) => ui.success(`RLS enabled on ${table}`));
67
- },
68
- },
69
- disable: {
70
- summary: 'Disable RLS on a table',
71
- usage: 'motherbase rls disable <table>',
72
- async run(ctx, args) {
73
- const table = requireArg(args[0], 'table');
74
- const data = await api.adminRequest(ctx, 'POST', `/rls/tables/${table}/rls`, { body: { enabled: false } });
75
- output(ctx, data, (d, ui) => ui.success(`RLS disabled on ${table}`));
76
- },
77
- },
78
- policy,
79
- preset: {
80
- summary: 'Apply a policy preset to a table',
81
- usage:
82
- 'motherbase rls preset <table> <starter|public_crud|authenticated_crud|admin_only|blocked|owner_only|org_scoped|public_read_owner_write|soft_delete> [--owner-column c] [--org-column c] [--org-claim c] [--deleted-column c] [--replace]',
83
- async run(ctx, args, flags) {
84
- const table = requireArg(args[0], 'table');
85
- const preset = requireArg(args[1], 'preset');
86
- const body = { preset };
87
- if (flags['owner-column']) body.ownerColumn = flags['owner-column'];
88
- if (flags['org-column']) body.orgColumn = flags['org-column'];
89
- if (flags['org-claim']) body.orgClaim = flags['org-claim'];
90
- if (flags['deleted-column']) body.deletedColumn = flags['deleted-column'];
91
- if (flags.replace) body.replace = true;
92
- const data = await api.adminRequest(ctx, 'POST', `/rls/tables/${table}/preset`, { body });
93
- output(ctx, data, (d, ui) =>
94
- ui.success(`Applied ${preset} to ${table}: ${(d && d.policies ? d.policies : []).join(', ')}`)
95
- );
96
- },
97
- },
98
- simulate: {
99
- summary: 'Run a query as a given caller and show what RLS lets through',
100
- usage:
101
- 'motherbase rls simulate --sql "select * from t" [--as anon|authenticated|<userId>] [--claims \'{"org_id":"x"}\'] [--limit n]',
102
- async run(ctx, args, flags) {
103
- const sql = flags.sql || args.join(' ');
104
- if (!sql) throw new CliError('--sql is required');
105
-
106
- // `--as` takes a role or a user id. A value that is not one of the two
107
- // roles is treated as a user id, which is what makes "as this person"
108
- // spellable without looking their claims up by hand.
109
- const as = flags.as || 'anon';
110
- const body = { sql };
111
- if (as === 'anon' || as === 'authenticated') body.role = as;
112
- else body.userId = as;
113
- if (flags.claims) {
114
- try {
115
- body.claims = JSON.parse(flags.claims);
116
- } catch {
117
- throw new CliError('--claims must be a JSON object');
118
- }
119
- }
120
- if (flags.limit) body.limit = Number(flags.limit);
121
-
122
- const data = await api.adminRequest(ctx, 'POST', '/rls/simulate', { body });
123
- output(ctx, data, (d, ui) => {
124
- if (d.denied) {
125
- ui.warn(`Refused by Postgres: ${d.denied.message}`);
126
- }
127
- if (d.rows && d.rows.length) {
128
- ui.table(d.rows, (d.columns || []).map(key => ({ key, label: key.toUpperCase() })));
129
- } else {
130
- ui.info('0 rows');
131
- }
132
- if (d.truncated) ui.info(`Truncated at ${d.rowCount} rows.`);
133
- for (const t of d.tables || []) {
134
- const reason =
135
- t.deniedBy === 'grant'
136
- ? 'no privilege for this role'
137
- : t.deniedBy === 'policy'
138
- ? 'RLS on, no policy grants this role read access'
139
- : null;
140
- ui.info(`${t.schema}.${t.table}: RLS ${t.rlsEnabled ? 'on' : 'off'}${reason ? ` (${reason})` : ''}`);
141
- for (const p of t.policies || []) {
142
- ui.info(` ${p.applies ? '*' : ' '} ${p.name} ${p.command} to ${p.roles.join(',')}: ${p.using || 'true'}`);
143
- }
144
- }
145
- });
146
- },
147
- },
148
- lint: {
149
- summary: 'Run the security and performance advisor',
150
- usage: 'motherbase rls lint [--level critical|warning|info]',
151
- async run(ctx, args, flags) {
152
- const data = await api.adminRequest(ctx, 'GET', '/advisor');
153
- const findings = (data && data.findings ? data.findings : []).filter(
154
- f => !flags.level || f.level === flags.level
155
- );
156
- output(ctx, { ...data, findings }, (d, ui) => {
157
- if (!findings.length) return ui.success('No findings.');
158
- ui.table(
159
- findings.map(f => ({ level: f.level, id: f.id, target: f.target, title: f.title })),
160
- [
161
- { key: 'level', label: 'LEVEL' },
162
- { key: 'id', label: 'RULE' },
163
- { key: 'target', label: 'TARGET' },
164
- { key: 'title', label: 'FINDING' },
165
- ]
166
- );
167
- });
168
- },
169
- },
170
- };
171
-
172
- module.exports = {
173
- summary: 'Row-level security (tables, policies, presets, simulator, lint)',
174
- usage: 'motherbase rls <list|enable|disable|policy|preset|simulate|lint> ...',
175
- subcommands,
176
- };
1
+ 'use strict';
2
+
3
+ const api = require('../api');
4
+ const { output, requireArg } = require('./_util');
5
+ const { CliError } = require('../context');
6
+
7
+ const policy = {
8
+ summary: 'Manage RLS policies',
9
+ usage: 'motherbase rls policy create --table <t> --name <n> --command <SELECT|INSERT|UPDATE|DELETE|ALL> [--using <expr>] [--check <expr>] [--roles a,b] | motherbase rls policy delete <table> <name>',
10
+ subcommands: {
11
+ create: {
12
+ summary: 'Create an RLS policy',
13
+ usage: 'motherbase rls policy create --table <t> --name <n> --command <cmd> [--using <expr>] [--check <expr>] [--roles a,b]',
14
+ async run(ctx, args, flags) {
15
+ const body = {
16
+ table: flags.table,
17
+ name: flags.name,
18
+ command: (flags.command || 'ALL').toUpperCase(),
19
+ };
20
+ if (!body.table || !body.name) throw new CliError('--table and --name are required');
21
+ if (flags.using) body.using = flags.using;
22
+ if (flags.check) body.check = flags.check;
23
+ if (flags.roles) body.roles = String(flags.roles).split(',').map(s => s.trim()).filter(Boolean);
24
+ const data = await api.adminRequest(ctx, 'POST', '/rls/policies', { body });
25
+ output(ctx, data, (d, ui) => ui.success(`Created policy ${body.name} on ${body.table}`));
26
+ },
27
+ },
28
+ delete: {
29
+ summary: 'Delete an RLS policy',
30
+ usage: 'motherbase rls policy delete <table> <name>',
31
+ async run(ctx, args) {
32
+ const table = requireArg(args[0], 'table');
33
+ const name = requireArg(args[1], 'name');
34
+ const data = await api.adminRequest(ctx, 'DELETE', '/rls/policies', { body: { table, name } });
35
+ output(ctx, data, (d, ui) => ui.success(`Dropped policy ${name} on ${table}`));
36
+ },
37
+ },
38
+ },
39
+ };
40
+
41
+ const subcommands = {
42
+ list: {
43
+ summary: 'List tables with RLS status and policies',
44
+ usage: 'motherbase rls list',
45
+ async run(ctx) {
46
+ const data = await api.adminRequest(ctx, 'GET', '/rls/tables');
47
+ output(ctx, data, (rows, ui) =>
48
+ ui.table((Array.isArray(rows) ? rows : []).map(r => ({
49
+ table: r.table,
50
+ rls: r.rlsEnabled ? 'on' : 'off',
51
+ policies: Array.isArray(r.policies) ? r.policies.length : 0,
52
+ })), [
53
+ { key: 'table', label: 'TABLE' },
54
+ { key: 'rls', label: 'RLS' },
55
+ { key: 'policies', label: 'POLICIES' },
56
+ ]),
57
+ );
58
+ },
59
+ },
60
+ enable: {
61
+ summary: 'Enable RLS on a table',
62
+ usage: 'motherbase rls enable <table>',
63
+ async run(ctx, args) {
64
+ const table = requireArg(args[0], 'table');
65
+ const data = await api.adminRequest(ctx, 'POST', `/rls/tables/${table}/rls`, { body: { enabled: true } });
66
+ output(ctx, data, (d, ui) => ui.success(`RLS enabled on ${table}`));
67
+ },
68
+ },
69
+ disable: {
70
+ summary: 'Disable RLS on a table',
71
+ usage: 'motherbase rls disable <table>',
72
+ async run(ctx, args) {
73
+ const table = requireArg(args[0], 'table');
74
+ const data = await api.adminRequest(ctx, 'POST', `/rls/tables/${table}/rls`, { body: { enabled: false } });
75
+ output(ctx, data, (d, ui) => ui.success(`RLS disabled on ${table}`));
76
+ },
77
+ },
78
+ policy,
79
+ preset: {
80
+ summary: 'Apply a policy preset to a table',
81
+ usage:
82
+ 'motherbase rls preset <table> <starter|public_crud|authenticated_crud|admin_only|blocked|owner_only|org_scoped|public_read_owner_write|soft_delete> [--owner-column c] [--org-column c] [--org-claim c] [--deleted-column c] [--replace]',
83
+ async run(ctx, args, flags) {
84
+ const table = requireArg(args[0], 'table');
85
+ const preset = requireArg(args[1], 'preset');
86
+ const body = { preset };
87
+ if (flags['owner-column']) body.ownerColumn = flags['owner-column'];
88
+ if (flags['org-column']) body.orgColumn = flags['org-column'];
89
+ if (flags['org-claim']) body.orgClaim = flags['org-claim'];
90
+ if (flags['deleted-column']) body.deletedColumn = flags['deleted-column'];
91
+ if (flags.replace) body.replace = true;
92
+ const data = await api.adminRequest(ctx, 'POST', `/rls/tables/${table}/preset`, { body });
93
+ output(ctx, data, (d, ui) =>
94
+ ui.success(`Applied ${preset} to ${table}: ${(d && d.policies ? d.policies : []).join(', ')}`)
95
+ );
96
+ },
97
+ },
98
+ simulate: {
99
+ summary: 'Run a query as a given caller and show what RLS lets through',
100
+ usage:
101
+ 'motherbase rls simulate --sql "select * from t" [--as anon|authenticated|<userId>] [--claims \'{"org_id":"x"}\'] [--limit n]',
102
+ async run(ctx, args, flags) {
103
+ const sql = flags.sql || args.join(' ');
104
+ if (!sql) throw new CliError('--sql is required');
105
+
106
+ // `--as` takes a role or a user id. A value that is not one of the two
107
+ // roles is treated as a user id, which is what makes "as this person"
108
+ // spellable without looking their claims up by hand.
109
+ const as = flags.as || 'anon';
110
+ const body = { sql };
111
+ if (as === 'anon' || as === 'authenticated') body.role = as;
112
+ else body.userId = as;
113
+ if (flags.claims) {
114
+ try {
115
+ body.claims = JSON.parse(flags.claims);
116
+ } catch {
117
+ throw new CliError('--claims must be a JSON object');
118
+ }
119
+ }
120
+ if (flags.limit) body.limit = Number(flags.limit);
121
+
122
+ const data = await api.adminRequest(ctx, 'POST', '/rls/simulate', { body });
123
+ output(ctx, data, (d, ui) => {
124
+ if (d.denied) {
125
+ ui.warn(`Refused by Postgres: ${d.denied.message}`);
126
+ }
127
+ if (d.rows && d.rows.length) {
128
+ ui.table(d.rows, (d.columns || []).map(key => ({ key, label: key.toUpperCase() })));
129
+ } else {
130
+ ui.info('0 rows');
131
+ }
132
+ if (d.truncated) ui.info(`Truncated at ${d.rowCount} rows.`);
133
+ for (const t of d.tables || []) {
134
+ const reason =
135
+ t.deniedBy === 'grant'
136
+ ? 'no privilege for this role'
137
+ : t.deniedBy === 'policy'
138
+ ? 'RLS on, no policy grants this role read access'
139
+ : null;
140
+ ui.info(`${t.schema}.${t.table}: RLS ${t.rlsEnabled ? 'on' : 'off'}${reason ? ` (${reason})` : ''}`);
141
+ for (const p of t.policies || []) {
142
+ ui.info(` ${p.applies ? '*' : ' '} ${p.name} ${p.command} to ${p.roles.join(',')}: ${p.using || 'true'}`);
143
+ }
144
+ }
145
+ });
146
+ },
147
+ },
148
+ lint: {
149
+ summary: 'Run the security and performance advisor',
150
+ usage: 'motherbase rls lint [--level critical|warning|info]',
151
+ async run(ctx, args, flags) {
152
+ const data = await api.adminRequest(ctx, 'GET', '/advisor');
153
+ const findings = (data && data.findings ? data.findings : []).filter(
154
+ f => !flags.level || f.level === flags.level
155
+ );
156
+ output(ctx, { ...data, findings }, (d, ui) => {
157
+ if (!findings.length) return ui.success('No findings.');
158
+ ui.table(
159
+ findings.map(f => ({ level: f.level, id: f.id, target: f.target, title: f.title })),
160
+ [
161
+ { key: 'level', label: 'LEVEL' },
162
+ { key: 'id', label: 'RULE' },
163
+ { key: 'target', label: 'TARGET' },
164
+ { key: 'title', label: 'FINDING' },
165
+ ]
166
+ );
167
+ });
168
+ },
169
+ },
170
+ };
171
+
172
+ module.exports = {
173
+ summary: 'Row-level security (tables, policies, presets, simulator, lint)',
174
+ usage: 'motherbase rls <list|enable|disable|policy|preset|simulate|lint> ...',
175
+ subcommands,
176
+ };
@@ -1,45 +1,66 @@
1
- 'use strict';
2
-
3
- const api = require('../api');
4
- const { output, requireArg } = require('./_util');
5
-
6
- const subcommands = {
7
- list: {
8
- summary: 'List edge secrets (names only)',
9
- usage: 'motherbase secrets list',
10
- async run(ctx) {
11
- const data = await api.adminRequest(ctx, 'GET', '/edge-secrets');
12
- const secrets = (data && data.secrets) || data || [];
13
- output(ctx, data, (_d, ui) =>
14
- ui.table((Array.isArray(secrets) ? secrets : []).map(s => (typeof s === 'string' ? { name: s } : s)), [
15
- { key: 'name', label: 'NAME' },
16
- ]),
17
- );
18
- },
19
- },
20
- set: {
21
- summary: 'Set a secret',
22
- usage: 'motherbase secrets set <NAME> <value>',
23
- async run(ctx, args) {
24
- const name = requireArg(args[0], 'NAME');
25
- const value = requireArg(args[1], 'value');
26
- const data = await api.adminRequest(ctx, 'PUT', '/edge-secrets', { body: { name, value } });
27
- output(ctx, data, (d, ui) => ui.success(`Set secret ${name}`));
28
- },
29
- },
30
- unset: {
31
- summary: 'Remove a secret',
32
- usage: 'motherbase secrets unset <NAME>',
33
- async run(ctx, args) {
34
- const name = requireArg(args[0], 'NAME');
35
- const data = await api.adminRequest(ctx, 'DELETE', `/edge-secrets/${encodeURIComponent(name)}`);
36
- output(ctx, data, (d, ui) => ui.success(`Removed secret ${name}`));
37
- },
38
- },
39
- };
40
-
41
- module.exports = {
42
- summary: 'Edge function secrets',
43
- usage: 'motherbase secrets <list|set|unset> ...',
44
- subcommands,
45
- };
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const api = require('../api');
6
+ const { output, requireArg, parseKeyValues, parseDotenv } = require('./_util');
7
+ const { CliError } = require('../context');
8
+
9
+ // `NAME=VALUE ...` plus `--env-file`, as the Supabase CLI takes them. The older
10
+ // `set NAME value` form keeps working. Positional pairs win over the file.
11
+ // Node itself pre-scans argv for `--env-file` and exits (code 9) when the path
12
+ // does not exist relative to the shell's cwd, before this code runs; with
13
+ // `--workdir`, pass an absolute path.
14
+ function collectSecrets(cwd, args, flags) {
15
+ const fromFile = flags['env-file']
16
+ ? parseDotenv(fs.readFileSync(path.resolve(cwd, String(flags['env-file'])), 'utf8'))
17
+ : {};
18
+ const legacy = args.length === 2 && !args[0].includes('=');
19
+ const fromArgs = legacy ? { [args[0]]: args[1] } : parseKeyValues(args, { coerce: false });
20
+ return { ...fromFile, ...fromArgs };
21
+ }
22
+
23
+ const subcommands = {
24
+ list: {
25
+ summary: 'List edge secrets (names only)',
26
+ usage: 'motherbase secrets list',
27
+ async run(ctx) {
28
+ const data = await api.adminRequest(ctx, 'GET', '/edge-secrets');
29
+ const secrets = (data && data.secrets) || data || [];
30
+ output(ctx, data, (_d, ui) =>
31
+ ui.table((Array.isArray(secrets) ? secrets : []).map(s => (typeof s === 'string' ? { name: s } : s)), [
32
+ { key: 'name', label: 'NAME' },
33
+ ]),
34
+ );
35
+ },
36
+ },
37
+ set: {
38
+ summary: 'Set one or more secrets',
39
+ usage: 'motherbase secrets set NAME=VALUE [NAME=VALUE ...] [--env-file .env]',
40
+ async run(ctx, args, flags) {
41
+ const secrets = collectSecrets(ctx.cwd, args, flags);
42
+ const names = Object.keys(secrets);
43
+ if (!names.length) throw new CliError('Nothing to set. Pass NAME=VALUE pairs or --env-file <path>.');
44
+ // The server sets one secret per request.
45
+ for (const name of names) {
46
+ await api.adminRequest(ctx, 'PUT', '/edge-secrets', { body: { name, value: secrets[name] } });
47
+ }
48
+ output(ctx, { set: names }, (d, ui) => ui.success(`Set ${names.length === 1 ? 'secret' : 'secrets'} ${names.join(', ')}`));
49
+ },
50
+ },
51
+ unset: {
52
+ summary: 'Remove a secret',
53
+ usage: 'motherbase secrets unset <NAME>',
54
+ async run(ctx, args) {
55
+ const name = requireArg(args[0], 'NAME');
56
+ const data = await api.adminRequest(ctx, 'DELETE', `/edge-secrets/${encodeURIComponent(name)}`);
57
+ output(ctx, data, (d, ui) => ui.success(`Removed secret ${name}`));
58
+ },
59
+ },
60
+ };
61
+
62
+ module.exports = {
63
+ summary: 'Edge function secrets',
64
+ usage: 'motherbase secrets <list|set|unset> ...',
65
+ subcommands,
66
+ };
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ // Local key material, the halves of `gen` that need no server: signing keys
4
+ // (`gen signing-key`), tokens signed with them (`gen bearer-jwt`) and a fresh
5
+ // set of stack secrets (`gen keys`). Node's crypto does all of it; the CLI has
6
+ // no dependencies and this does not add one.
7
+
8
+ const crypto = require('crypto');
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const api = require('../api');
12
+ const { output } = require('./_util');
13
+ const { CliError } = require('../context');
14
+
15
+ const DEFAULT_KEY_FILE = path.join('motherbase', 'signing_keys.json');
16
+ const ALGORITHMS = {
17
+ ES256: () => crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }),
18
+ RS256: () => crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }),
19
+ EdDSA: () => crypto.generateKeyPairSync('ed25519'),
20
+ };
21
+
22
+ /** A private JWK with the kid and alg the server keeps when it is imported. */
23
+ function generateSigningKey(algorithm = 'ES256') {
24
+ const generate = ALGORITHMS[algorithm];
25
+ if (!generate) throw new CliError(`--algorithm must be one of: ${Object.keys(ALGORITHMS).join(', ')}`);
26
+ const { privateKey } = generate();
27
+ return { ...privateKey.export({ format: 'jwk' }), kid: crypto.randomUUID(), alg: algorithm, use: 'sig' };
28
+ }
29
+
30
+ function algorithmOf(jwk) {
31
+ if (jwk.kty === 'EC' && jwk.crv === 'P-256') return 'ES256';
32
+ if (jwk.kty === 'RSA') return 'RS256';
33
+ if (jwk.kty === 'OKP' && jwk.crv === 'Ed25519') return 'EdDSA';
34
+ throw new CliError('The signing key must be an ES256, RS256 or EdDSA private JWK.');
35
+ }
36
+
37
+ const b64url = value => Buffer.from(typeof value === 'string' ? value : JSON.stringify(value)).toString('base64url');
38
+
39
+ /** A compact JWS over `claims`, signed with a private JWK. */
40
+ function signJwt(jwk, claims) {
41
+ const alg = algorithmOf(jwk);
42
+ const input = `${b64url({ alg, kid: jwk.kid, typ: 'JWT' })}.${b64url(claims)}`;
43
+ const key = crypto.createPrivateKey({ key: jwk, format: 'jwk' });
44
+ const signature = alg === 'EdDSA'
45
+ ? crypto.sign(null, Buffer.from(input), key)
46
+ : crypto.sign('sha256', Buffer.from(input), alg === 'ES256' ? { key, dsaEncoding: 'ieee-p1363' } : key);
47
+ return `${input}.${signature.toString('base64url')}`;
48
+ }
49
+
50
+ /** `30s`, `15m`, `2h`, `7d`, or plain seconds. */
51
+ function parseDuration(value) {
52
+ const match = /^(\d+)([smhd]?)$/.exec(String(value).trim());
53
+ if (!match) throw new CliError(`invalid duration "${value}" (use e.g. 30m, 2h, 7d)`);
54
+ return Number(match[1]) * { '': 1, s: 1, m: 60, h: 3600, d: 86400 }[match[2]];
55
+ }
56
+
57
+ function readKeys(file) {
58
+ if (!fs.existsSync(file)) {
59
+ throw new CliError(`${file} does not exist. Run \`motherbase gen signing-key --append\` first.`);
60
+ }
61
+ const keys = JSON.parse(fs.readFileSync(file, 'utf8'));
62
+ if (!Array.isArray(keys) || !keys.length) throw new CliError(`${file} holds no keys.`);
63
+ return keys;
64
+ }
65
+
66
+ const signingKey = {
67
+ summary: 'Generate a JWT signing key (a private JWK) to import into a project',
68
+ usage: 'motherbase gen signing-key [--algorithm ES256|RS256|EdDSA] [--append] [--key-file motherbase/signing_keys.json]',
69
+ async run(ctx, _args, flags) {
70
+ const key = generateSigningKey(flags.algorithm || 'ES256');
71
+ if (!flags.append) {
72
+ output(ctx, key, (d, ui) => ui.print(JSON.stringify(d, null, 2)));
73
+ return;
74
+ }
75
+ const file = path.resolve(ctx.cwd, flags['key-file'] || DEFAULT_KEY_FILE);
76
+ const keys = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : [];
77
+ fs.mkdirSync(path.dirname(file), { recursive: true });
78
+ // Private keys: readable by the owner only.
79
+ fs.writeFileSync(file, JSON.stringify([...keys, key], null, 2) + '\n', { mode: 0o600 });
80
+ output(ctx, { kid: key.kid, alg: key.alg, file }, (d, ui) => {
81
+ ui.success(`Appended ${d.alg} key ${d.kid} to ${d.file}`);
82
+ ui.info('Import it in Studio (Project Settings > JWT Keys) to have the project accept its tokens.');
83
+ });
84
+ },
85
+ };
86
+
87
+ const bearerJwt = {
88
+ summary: 'Sign an access token with a local signing key, for calling the API as a user',
89
+ usage: 'motherbase gen bearer-jwt --sub <user id> [--role authenticated] [--valid-for 1h | --exp <unix>] [--payload <json>] [--issuer <url>] [--key-file <file>]',
90
+ async run(ctx, _args, flags) {
91
+ const [jwk] = readKeys(path.resolve(ctx.cwd, flags['key-file'] || DEFAULT_KEY_FILE));
92
+ if (!flags.sub) throw new CliError('--sub is required: the project resolves the token to that user.');
93
+ // The project verifies iss and aud against its own URL; ask it rather than guess.
94
+ const issuer = flags.issuer || (await api.adminRequest(ctx, 'GET', '/auth/signing-keys')).issuer;
95
+ const now = Math.floor(Date.now() / 1000);
96
+ const extra = flags.payload ? JSON.parse(String(flags.payload)) : {};
97
+ const claims = {
98
+ ...extra,
99
+ sub: String(flags.sub),
100
+ role: flags.role || extra.role || 'authenticated',
101
+ iss: issuer,
102
+ aud: flags.audience || issuer,
103
+ iat: now,
104
+ exp: flags.exp ? Number(flags.exp) : now + parseDuration(flags['valid-for'] || '1h'),
105
+ };
106
+ const token = signJwt(jwk, claims);
107
+ output(ctx, { token, kid: jwk.kid, claims }, d => process.stdout.write(`${d.token}\n`));
108
+ },
109
+ };
110
+
111
+ const hex = n => crypto.randomBytes(n).toString('hex');
112
+
113
+ const keys = {
114
+ summary: 'Generate a fresh set of secrets for a local stack (use -o env)',
115
+ usage: 'motherbase gen keys [-o env]',
116
+ async run(ctx) {
117
+ const data = {
118
+ JWT_SECRET: hex(32),
119
+ CONFIG_ENCRYPTION_KEY: crypto.randomBytes(32).toString('base64'),
120
+ PLATFORM_SERVICE_TOKEN: hex(32),
121
+ EDGE_RUNTIME_TOKEN: hex(32),
122
+ POSTGRES_PASSWORD: hex(16),
123
+ S3_ACCESS_KEY_ID: hex(8),
124
+ S3_SECRET_ACCESS_KEY: hex(20),
125
+ };
126
+ output(ctx, data, (d, ui) => ui.keyValue(d));
127
+ },
128
+ };
129
+
130
+ module.exports = { signingKey, bearerJwt, keys, generateSigningKey, signJwt, parseDuration };
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+
3
+ const api = require('../api');
4
+ const { output, requireArg } = require('./_util');
5
+
6
+ // SQL Editor snippets, kept by the project (see the Studio's SQL Editor). Like
7
+ // `supabase snippets`: list what you can see, download one as plain SQL.
8
+ const subcommands = {
9
+ list: {
10
+ summary: 'List SQL snippets (yours and the ones shared with the project)',
11
+ usage: 'motherbase snippets list',
12
+ async run(ctx) {
13
+ const data = await api.adminRequest(ctx, 'GET', '/content', { query: { type: 'sql' } });
14
+ const items = (data && data.items) || [];
15
+ output(ctx, data, (_d, ui) =>
16
+ ui.table(items.map(item => ({ ...item, visibility: item.visibility === 'project' ? 'shared' : 'private' })), [
17
+ { key: 'id', label: 'ID' },
18
+ { key: 'name', label: 'NAME' },
19
+ { key: 'visibility', label: 'VISIBILITY' },
20
+ { key: 'ownerId', label: 'OWNER' },
21
+ { key: 'updatedAt', label: 'UPDATED' },
22
+ ]),
23
+ );
24
+ },
25
+ },
26
+ download: {
27
+ summary: 'Print a snippet as plain SQL (redirect it to a file to save it)',
28
+ usage: 'motherbase snippets download <id>',
29
+ async run(ctx, args) {
30
+ const id = requireArg(args[0], 'id');
31
+ const data = await api.adminRequest(ctx, 'GET', `/content/${encodeURIComponent(id)}`);
32
+ const item = data && data.item;
33
+ // Structured output keeps the whole record; the default is the bare SQL,
34
+ // with no decoration, so `> query.sql` writes a runnable file.
35
+ output(ctx, item, () => process.stdout.write(`${item.content.sql}\n`));
36
+ },
37
+ },
38
+ };
39
+
40
+ module.exports = {
41
+ summary: 'SQL Editor snippets',
42
+ usage: 'motherbase snippets <list|download> ...',
43
+ subcommands,
44
+ };