@motherbase/cli 0.2.2 → 0.2.4
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/README.md +24 -0
- package/package.json +4 -5
- package/src/api.js +11 -3
- package/src/commands/functions.js +274 -112
- package/src/commands/gen.js +126 -0
- package/src/commands/index.js +10 -1
- package/src/commands/keys.js +72 -0
- package/src/commands/local.js +273 -64
- package/src/commands/mcp.js +43 -0
- package/src/commands/rls.js +93 -2
- package/src/main.js +5 -0
- package/src/mcp/protocol.js +149 -0
- package/src/mcp/tools.js +166 -0
- package/src/prompt.js +61 -14
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Project API keys. `secrets` next door is a different thing: those are values
|
|
4
|
+
// an edge function reads, these are what a client presents to reach the project.
|
|
5
|
+
|
|
6
|
+
const api = require('../api');
|
|
7
|
+
const { output, requireArg } = require('./_util');
|
|
8
|
+
|
|
9
|
+
const TYPES = ['publishable', 'secret'];
|
|
10
|
+
|
|
11
|
+
const subcommands = {
|
|
12
|
+
list: {
|
|
13
|
+
summary: 'List project API keys',
|
|
14
|
+
usage: 'motherbase keys list',
|
|
15
|
+
async run(ctx) {
|
|
16
|
+
const data = await api.adminRequest(ctx, 'GET', '/api-keys');
|
|
17
|
+
const keys = (data && data.keys) || [];
|
|
18
|
+
output(ctx, data, (_d, ui) =>
|
|
19
|
+
ui.table(
|
|
20
|
+
keys.map(k => ({
|
|
21
|
+
name: k.name,
|
|
22
|
+
type: k.type,
|
|
23
|
+
// A secret key has no stored clear value; the prefix is all there is.
|
|
24
|
+
key: k.token || `${k.prefix}…`,
|
|
25
|
+
status: k.revokedAt ? 'revoked' : 'active',
|
|
26
|
+
})),
|
|
27
|
+
[
|
|
28
|
+
{ key: 'name', label: 'NAME' },
|
|
29
|
+
{ key: 'type', label: 'TYPE' },
|
|
30
|
+
{ key: 'key', label: 'KEY' },
|
|
31
|
+
{ key: 'status', label: 'STATUS' },
|
|
32
|
+
],
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
create: {
|
|
38
|
+
summary: 'Create a key. A secret key is printed once and never again',
|
|
39
|
+
usage: 'motherbase keys create <NAME> [--type publishable|secret]',
|
|
40
|
+
async run(ctx, args, flags) {
|
|
41
|
+
const name = requireArg(args[0], 'NAME');
|
|
42
|
+
const type = flags.type || 'secret';
|
|
43
|
+
if (!TYPES.includes(type)) {
|
|
44
|
+
throw new Error(`--type must be one of: ${TYPES.join(', ')}`);
|
|
45
|
+
}
|
|
46
|
+
const data = await api.adminRequest(ctx, 'POST', '/api-keys', { body: { name, type } });
|
|
47
|
+
output(ctx, data, (d, ui) => {
|
|
48
|
+
ui.success(`Created ${type} key "${name}"`);
|
|
49
|
+
ui.print(d.clearToken);
|
|
50
|
+
if (type === 'secret') {
|
|
51
|
+
ui.warn('Copy it now. It is stored hashed and cannot be shown again.');
|
|
52
|
+
ui.warn('It bypasses row level security: keep it server side.');
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
revoke: {
|
|
58
|
+
summary: 'Revoke a key by id',
|
|
59
|
+
usage: 'motherbase keys revoke <ID>',
|
|
60
|
+
async run(ctx, args) {
|
|
61
|
+
const id = requireArg(args[0], 'ID');
|
|
62
|
+
const data = await api.adminRequest(ctx, 'DELETE', `/api-keys/${encodeURIComponent(id)}`);
|
|
63
|
+
output(ctx, data, (d, ui) => ui.success(`Revoked key ${d.name || id}`));
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
module.exports = {
|
|
69
|
+
summary: 'Project API keys (publishable and secret)',
|
|
70
|
+
usage: 'motherbase keys <list|create|revoke> ...',
|
|
71
|
+
subcommands,
|
|
72
|
+
};
|
package/src/commands/local.js
CHANGED
|
@@ -1,64 +1,273 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Local self-host workflow: thin wrappers over `docker compose`, plus `init`
|
|
4
|
-
// scaffolding.
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Local self-host workflow: thin wrappers over `docker compose`, plus `init`
|
|
4
|
+
// scaffolding.
|
|
5
|
+
//
|
|
6
|
+
// `init` used to write a `.env` and nothing else, which left a directory that
|
|
7
|
+
// `motherbase start` could not start: `docker compose up` in a folder with no
|
|
8
|
+
// compose file is a raw Docker error, and that was the first thing a new user
|
|
9
|
+
// saw. It now writes a self-contained stack that runs.
|
|
10
|
+
|
|
11
|
+
const crypto = require('crypto');
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { spawn } = require('child_process');
|
|
15
|
+
const ui = require('../ui');
|
|
16
|
+
const { CliError } = require('../context');
|
|
17
|
+
|
|
18
|
+
const COMPOSE_FILE = 'docker-compose.yml';
|
|
19
|
+
const ENV_FILE = '.env';
|
|
20
|
+
|
|
21
|
+
// Where the images are built from when the caller has no checkout. Docker
|
|
22
|
+
// Compose accepts a git URL as a build context, so a scaffolded directory needs
|
|
23
|
+
// nothing but Docker: no clone, no registry, no published image. The first `up`
|
|
24
|
+
// pays for the build; every one after that is cached.
|
|
25
|
+
const DEFAULT_REPO = 'https://github.com/JulienGuinot/BoilerPlate-Backend.git';
|
|
26
|
+
const DEFAULT_REF = 'master';
|
|
27
|
+
|
|
28
|
+
function dockerCompose(composeArgs) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const child = spawn('docker', ['compose', ...composeArgs], { stdio: 'inherit', windowsHide: true });
|
|
31
|
+
child.on('error', err => reject(new CliError(`failed to run docker: ${err.message}`)));
|
|
32
|
+
child.on('close', code => (code === 0 ? resolve() : reject(new CliError(`docker compose exited with code ${code}`))));
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Refuse to run compose from a directory that has no stack, and say what to do.
|
|
38
|
+
* Without this the user gets Docker's own "no configuration file provided",
|
|
39
|
+
* which does not mention `motherbase init`.
|
|
40
|
+
*/
|
|
41
|
+
function assertStack(ctx) {
|
|
42
|
+
const file = path.join(ctx.cwd, COMPOSE_FILE);
|
|
43
|
+
if (!fs.existsSync(file)) {
|
|
44
|
+
throw new CliError(
|
|
45
|
+
`no ${COMPOSE_FILE} here. Run \`motherbase init\` first, or cd into a directory that has one.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const hex = n => crypto.randomBytes(n).toString('hex');
|
|
51
|
+
const b64 = n => crypto.randomBytes(n).toString('base64');
|
|
52
|
+
|
|
53
|
+
function renderEnv(context) {
|
|
54
|
+
return [
|
|
55
|
+
'# Generated by `motherbase init`. Secrets below are unique to this install.',
|
|
56
|
+
'# Keep this file out of version control.',
|
|
57
|
+
'',
|
|
58
|
+
`PORT=${context.port}`,
|
|
59
|
+
`BASE_HOST=localhost:${context.port}`,
|
|
60
|
+
'NODE_ENV=production',
|
|
61
|
+
'',
|
|
62
|
+
'# Secrets. Regenerating any of these invalidates what it protects:',
|
|
63
|
+
'# JWT_SECRET signs sessions, CONFIG_ENCRYPTION_KEY encrypts Studio-managed',
|
|
64
|
+
'# secrets (SMTP, OAuth credentials) at rest.',
|
|
65
|
+
`JWT_SECRET=${hex(32)}`,
|
|
66
|
+
`CONFIG_ENCRYPTION_KEY=${b64(32)}`,
|
|
67
|
+
`POSTGRES_PASSWORD=${hex(16)}`,
|
|
68
|
+
`S3_ACCESS_KEY_ID=${hex(8)}`,
|
|
69
|
+
`S3_SECRET_ACCESS_KEY=${hex(20)}`,
|
|
70
|
+
'',
|
|
71
|
+
'S3_BUCKET=uploads',
|
|
72
|
+
'S3_PUBLIC_BUCKET=public',
|
|
73
|
+
'S3_REGION=us-east-1',
|
|
74
|
+
'',
|
|
75
|
+
'# Host ports. Change these if something already listens on them.',
|
|
76
|
+
`POSTGRES_PORT=${context.postgresPort}`,
|
|
77
|
+
'MINIO_PORT=59000',
|
|
78
|
+
'MINIO_CONSOLE_PORT=59001',
|
|
79
|
+
'',
|
|
80
|
+
'# First operator account, created on first boot.',
|
|
81
|
+
`SEED_ADMIN_EMAIL=${context.adminEmail}`,
|
|
82
|
+
`SEED_ADMIN_PASSWORD=${context.adminPassword}`,
|
|
83
|
+
'',
|
|
84
|
+
].join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function renderCompose(context) {
|
|
88
|
+
const buildContext = `${context.repo}#${context.ref}`;
|
|
89
|
+
return `# Generated by \`motherbase init\`.
|
|
90
|
+
#
|
|
91
|
+
# Images are built from the public repository, so this directory needs Docker and
|
|
92
|
+
# nothing else: no checkout, no registry login. The first \`up\` builds them, which
|
|
93
|
+
# takes a few minutes; after that the layers are cached.
|
|
94
|
+
#
|
|
95
|
+
# Working from a checkout instead? Replace both \`context:\` values with the path
|
|
96
|
+
# to it and the build becomes local and incremental.
|
|
97
|
+
services:
|
|
98
|
+
db:
|
|
99
|
+
build:
|
|
100
|
+
context: ${buildContext}
|
|
101
|
+
dockerfile: deploy/postgres.Dockerfile
|
|
102
|
+
command: ['postgres', '-c', 'shared_preload_libraries=pg_cron,pg_stat_statements', '-c', 'cron.database_name=app']
|
|
103
|
+
restart: unless-stopped
|
|
104
|
+
environment:
|
|
105
|
+
POSTGRES_USER: postgres
|
|
106
|
+
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD}
|
|
107
|
+
POSTGRES_DB: app
|
|
108
|
+
ports:
|
|
109
|
+
# Loopback only: reachable by psql, Prisma, Drizzle or a BI client on this
|
|
110
|
+
# machine, and by nothing on the network.
|
|
111
|
+
- '127.0.0.1:\${POSTGRES_PORT:-55432}:5432'
|
|
112
|
+
volumes:
|
|
113
|
+
- db_data:/var/lib/postgresql/data
|
|
114
|
+
healthcheck:
|
|
115
|
+
test: ['CMD-SHELL', 'pg_isready -U postgres']
|
|
116
|
+
interval: 5s
|
|
117
|
+
timeout: 5s
|
|
118
|
+
retries: 10
|
|
119
|
+
|
|
120
|
+
minio:
|
|
121
|
+
image: alpine/minio:RELEASE.2025-10-15T17-29-55Z
|
|
122
|
+
restart: unless-stopped
|
|
123
|
+
user: "0:0"
|
|
124
|
+
command: server /data --console-address ":9001"
|
|
125
|
+
environment:
|
|
126
|
+
MINIO_ROOT_USER: \${S3_ACCESS_KEY_ID}
|
|
127
|
+
MINIO_ROOT_PASSWORD: \${S3_SECRET_ACCESS_KEY}
|
|
128
|
+
ports:
|
|
129
|
+
- '127.0.0.1:\${MINIO_PORT:-59000}:9000'
|
|
130
|
+
- '127.0.0.1:\${MINIO_CONSOLE_PORT:-59001}:9001'
|
|
131
|
+
volumes:
|
|
132
|
+
- minio_data:/data
|
|
133
|
+
healthcheck:
|
|
134
|
+
test: ['CMD-SHELL', 'wget -qO- http://localhost:9000/minio/health/live >/dev/null']
|
|
135
|
+
interval: 5s
|
|
136
|
+
timeout: 5s
|
|
137
|
+
retries: 10
|
|
138
|
+
|
|
139
|
+
# One-shot: a private bucket and a separate anonymously-downloadable one.
|
|
140
|
+
createbuckets:
|
|
141
|
+
image: minio/mc:RELEASE.2025-08-13T08-35-41Z
|
|
142
|
+
depends_on:
|
|
143
|
+
minio:
|
|
144
|
+
condition: service_healthy
|
|
145
|
+
entrypoint: >
|
|
146
|
+
/bin/sh -c "
|
|
147
|
+
mc alias set local http://minio:9000 \${S3_ACCESS_KEY_ID} \${S3_SECRET_ACCESS_KEY};
|
|
148
|
+
mc mb --ignore-existing local/\${S3_BUCKET:-uploads};
|
|
149
|
+
mc anonymous set none local/\${S3_BUCKET:-uploads};
|
|
150
|
+
mc mb --ignore-existing local/\${S3_PUBLIC_BUCKET:-public};
|
|
151
|
+
mc anonymous set download local/\${S3_PUBLIC_BUCKET:-public};
|
|
152
|
+
echo 'buckets ready';
|
|
153
|
+
"
|
|
154
|
+
|
|
155
|
+
api:
|
|
156
|
+
build:
|
|
157
|
+
context: ${buildContext}
|
|
158
|
+
restart: unless-stopped
|
|
159
|
+
depends_on:
|
|
160
|
+
db:
|
|
161
|
+
condition: service_healthy
|
|
162
|
+
minio:
|
|
163
|
+
condition: service_healthy
|
|
164
|
+
createbuckets:
|
|
165
|
+
condition: service_completed_successfully
|
|
166
|
+
env_file:
|
|
167
|
+
- ${ENV_FILE}
|
|
168
|
+
# Container-internal wiring, overriding the localhost values in .env so the
|
|
169
|
+
# same file works for both host tooling and the containers.
|
|
170
|
+
environment:
|
|
171
|
+
NODE_ENV: production
|
|
172
|
+
PORT: 5001
|
|
173
|
+
DATABASE_URL: postgresql://postgres:\${POSTGRES_PASSWORD}@db:5432/app?schema=public
|
|
174
|
+
STORAGE_DRIVER: s3
|
|
175
|
+
S3_ENDPOINT: http://minio:9000
|
|
176
|
+
DB_PORT: \${POSTGRES_PORT:-55432}
|
|
177
|
+
ports:
|
|
178
|
+
- '\${PORT:-5001}:5001'
|
|
179
|
+
|
|
180
|
+
volumes:
|
|
181
|
+
db_data:
|
|
182
|
+
minio_data:
|
|
183
|
+
`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const init = {
|
|
187
|
+
summary: 'Scaffold a runnable local stack (compose file, .env, generated secrets)',
|
|
188
|
+
usage: 'motherbase init [dir] [--port 5001] [--repo URL] [--ref BRANCH] [--force]',
|
|
189
|
+
async run(ctx, args, flags = {}) {
|
|
190
|
+
const dir = args[0] || '.';
|
|
191
|
+
const target = path.resolve(ctx.cwd, dir);
|
|
192
|
+
fs.mkdirSync(target, { recursive: true });
|
|
193
|
+
|
|
194
|
+
const context = {
|
|
195
|
+
port: Number(flags.port) || 5001,
|
|
196
|
+
postgresPort: Number(flags['postgres-port']) || 55432,
|
|
197
|
+
repo: flags.repo || DEFAULT_REPO,
|
|
198
|
+
ref: flags.ref || DEFAULT_REF,
|
|
199
|
+
adminEmail: flags['admin-email'] || 'admin@example.com',
|
|
200
|
+
// Generated, never a placeholder: a scaffold that ships `change-me` is a
|
|
201
|
+
// scaffold that reaches production with `change-me`.
|
|
202
|
+
adminPassword: flags['admin-password'] || crypto.randomBytes(12).toString('base64url'),
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const written = [];
|
|
206
|
+
const skipped = [];
|
|
207
|
+
for (const [name, render] of [[ENV_FILE, renderEnv], [COMPOSE_FILE, renderCompose]]) {
|
|
208
|
+
const file = path.join(target, name);
|
|
209
|
+
// Never silently overwrite: .env holds the only copy of secrets that
|
|
210
|
+
// existing data was encrypted with.
|
|
211
|
+
if (fs.existsSync(file) && !flags.force) {
|
|
212
|
+
skipped.push(name);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
fs.writeFileSync(file, render(context), name === ENV_FILE ? { mode: 0o600 } : undefined);
|
|
216
|
+
written.push(name);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
ui.success(`Initialized Motherbase project at ${target}`);
|
|
220
|
+
if (written.length) ui.info(`wrote ${written.join(', ')}`);
|
|
221
|
+
if (skipped.length) {
|
|
222
|
+
ui.warn(`kept existing ${skipped.join(', ')} (use --force to overwrite, which discards their secrets)`);
|
|
223
|
+
}
|
|
224
|
+
if (written.includes(ENV_FILE)) {
|
|
225
|
+
ui.print('');
|
|
226
|
+
ui.print(` Studio: http://localhost:${context.port}`);
|
|
227
|
+
ui.print(` Sign in: ${context.adminEmail} / ${context.adminPassword}`);
|
|
228
|
+
ui.print('');
|
|
229
|
+
ui.print(' Next:');
|
|
230
|
+
if (dir !== '.') ui.print(` cd ${dir}`);
|
|
231
|
+
ui.print(' motherbase start # first run builds the images, give it a few minutes');
|
|
232
|
+
ui.print('');
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const start = {
|
|
238
|
+
summary: 'Start the local docker compose stack',
|
|
239
|
+
usage: 'motherbase start',
|
|
240
|
+
async run(ctx) {
|
|
241
|
+
assertStack(ctx);
|
|
242
|
+
await dockerCompose(['up', '-d']);
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const stop = {
|
|
247
|
+
summary: 'Stop the local docker compose stack',
|
|
248
|
+
usage: 'motherbase stop',
|
|
249
|
+
async run(ctx) {
|
|
250
|
+
assertStack(ctx);
|
|
251
|
+
await dockerCompose(['down']);
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const deploy = {
|
|
256
|
+
summary: 'Rebuild and start the local docker compose stack',
|
|
257
|
+
usage: 'motherbase deploy',
|
|
258
|
+
async run(ctx) {
|
|
259
|
+
assertStack(ctx);
|
|
260
|
+
await dockerCompose(['up', '-d', '--build']);
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const logs = {
|
|
265
|
+
summary: 'Follow logs from the local docker compose stack',
|
|
266
|
+
usage: 'motherbase logs [service...]',
|
|
267
|
+
async run(ctx, args) {
|
|
268
|
+
assertStack(ctx);
|
|
269
|
+
await dockerCompose(['logs', '-f', ...args]);
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
module.exports = { init, start, stop, deploy, logs, renderEnv, renderCompose };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// `motherbase mcp` — expose the linked project to an AI agent over the Model
|
|
4
|
+
// Context Protocol.
|
|
5
|
+
//
|
|
6
|
+
// The transport is stdio, which is how an MCP client launches a server: it spawns
|
|
7
|
+
// this process and talks JSON-RPC over its stdin/stdout. Nothing is printed to
|
|
8
|
+
// stdout except protocol messages, so diagnostics go to stderr.
|
|
9
|
+
|
|
10
|
+
const { serve, toolServer } = require('../mcp/protocol');
|
|
11
|
+
const { buildTools } = require('../mcp/tools');
|
|
12
|
+
|
|
13
|
+
const pkg = require('../../package.json');
|
|
14
|
+
|
|
15
|
+
const mcp = {
|
|
16
|
+
summary: 'Serve the linked project to an AI agent over MCP (stdio)',
|
|
17
|
+
usage: 'motherbase mcp [--write]',
|
|
18
|
+
async run(ctx, _args, flags = {}) {
|
|
19
|
+
// Resolve the project up front. Failing here is a clear CLI error; failing
|
|
20
|
+
// on the first tool call is an opaque one inside someone's agent.
|
|
21
|
+
ctx.requireProject();
|
|
22
|
+
|
|
23
|
+
const write = Boolean(flags.write);
|
|
24
|
+
const tools = buildTools(ctx, { write });
|
|
25
|
+
|
|
26
|
+
process.stderr.write(
|
|
27
|
+
`motherbase mcp: ${tools.length} tools, ${write ? 'read and write' : 'read only'}. `
|
|
28
|
+
+ `Project: ${ctx.projectName || ctx.projectId || 'direct'}\n`,
|
|
29
|
+
);
|
|
30
|
+
if (!write) {
|
|
31
|
+
process.stderr.write(' Pass --write to expose apply_migration and raw SQL writes.\n');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await serve({
|
|
35
|
+
input: process.stdin,
|
|
36
|
+
output: process.stdout,
|
|
37
|
+
handlers: toolServer({ name: 'motherbase', version: pkg.version, tools }),
|
|
38
|
+
onError: err => process.stderr.write(`motherbase mcp: ${err && err.message ? err.message : err}\n`),
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
module.exports = mcp;
|
package/src/commands/rls.js
CHANGED
|
@@ -76,10 +76,101 @@ const subcommands = {
|
|
|
76
76
|
},
|
|
77
77
|
},
|
|
78
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
|
+
},
|
|
79
170
|
};
|
|
80
171
|
|
|
81
172
|
module.exports = {
|
|
82
|
-
summary: 'Row-level security (tables
|
|
83
|
-
usage: 'motherbase rls <list|enable|disable|policy> ...',
|
|
173
|
+
summary: 'Row-level security (tables, policies, presets, simulator, lint)',
|
|
174
|
+
usage: 'motherbase rls <list|enable|disable|policy|preset|simulate|lint> ...',
|
|
84
175
|
subcommands,
|
|
85
176
|
};
|
package/src/main.js
CHANGED
|
@@ -8,6 +8,11 @@ const ui = require('./ui');
|
|
|
8
8
|
// Flags that never consume a following value.
|
|
9
9
|
const PURE_BOOLEAN = new Set([
|
|
10
10
|
'json', 'yes', 'help', 'version', 'force', 'preview', 'app', 'no-data', 'no-auth', 'f',
|
|
11
|
+
// Codegen: `--check` and `--local` never take a value, so a trailing
|
|
12
|
+
// `--check database.types.ts` must not silently swallow the path.
|
|
13
|
+
'check', 'local',
|
|
14
|
+
// `rls preset <table> <preset> --replace` must not swallow a following word.
|
|
15
|
+
'replace',
|
|
11
16
|
]);
|
|
12
17
|
// Short flag aliases → canonical (with whether they take a value).
|
|
13
18
|
const SHORT = {
|