@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.
- package/README.md +247 -134
- package/package.json +2 -2
- package/src/api.js +14 -2
- package/src/commands/_util.js +49 -6
- package/src/commands/backups.js +16 -0
- package/src/commands/branches.js +113 -0
- package/src/commands/completion.js +90 -0
- package/src/commands/db-catalog.js +326 -0
- package/src/commands/db.js +16 -5
- package/src/commands/domains.js +124 -0
- package/src/commands/functions.js +297 -274
- package/src/commands/gen.js +172 -126
- package/src/commands/import.js +23 -3
- package/src/commands/index.js +27 -6
- package/src/commands/inspect.js +142 -5
- package/src/commands/keys.js +72 -72
- package/src/commands/local.js +273 -273
- package/src/commands/mcp.js +61 -43
- package/src/commands/migrate.js +179 -0
- package/src/commands/migration.js +81 -6
- package/src/commands/network.js +83 -0
- package/src/commands/orgs.js +40 -0
- package/src/commands/postgres-config.js +113 -0
- package/src/commands/projects.js +8 -3
- package/src/commands/queues.js +133 -0
- package/src/commands/secrets.js +29 -8
- package/src/commands/signing.js +130 -0
- package/src/commands/snippets.js +44 -0
- package/src/commands/storage.js +192 -44
- package/src/commands/test.js +9 -0
- package/src/commands/users.js +30 -1
- package/src/commands/vault.js +82 -0
- package/src/commands/wrappers.js +150 -0
- package/src/edge-templates.js +194 -0
- package/src/format.js +92 -0
- package/src/inspect/csv.js +22 -0
- package/src/inspect/queries.js +562 -0
- package/src/main.js +41 -7
- package/src/mcp/platform-tools.js +233 -0
- package/src/mcp/protocol.js +149 -149
- package/src/mcp/storage-tools.js +93 -0
- package/src/mcp/tools.js +478 -166
- package/src/migration/commands.js +375 -0
- package/src/migration/files.js +245 -0
- package/src/prompt.js +1 -1
- package/src/storage/remote.js +62 -0
- package/src/commands/migrations.js +0 -85
package/src/commands/gen.js
CHANGED
|
@@ -1,126 +1,172 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Code generation from the live schema
|
|
4
|
-
//
|
|
5
|
-
// The server owns
|
|
6
|
-
// This command is a transport: it asks `GET /admin/schema/types` for the
|
|
7
|
-
// rendered file and writes it. Re-implementing the emitter here would guarantee
|
|
8
|
-
// drift between the CLI, the Studio download and the MCP tool within a release.
|
|
9
|
-
|
|
10
|
-
const fs = require('fs');
|
|
11
|
-
const path = require('path');
|
|
12
|
-
const api = require('../api');
|
|
13
|
-
const config = require('../config');
|
|
14
|
-
const { output } = require('./_util');
|
|
15
|
-
const {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
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
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Code generation from the live schema: TypeScript types and zod schemas.
|
|
4
|
+
//
|
|
5
|
+
// The server owns both emitters (apps/server/services/admin/typegen/).
|
|
6
|
+
// This command is a transport: it asks `GET /admin/schema/types` for the
|
|
7
|
+
// rendered file and writes it. Re-implementing the emitter here would guarantee
|
|
8
|
+
// drift between the CLI, the Studio download and the MCP tool within a release.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const api = require('../api');
|
|
13
|
+
const config = require('../config');
|
|
14
|
+
const { output } = require('./_util');
|
|
15
|
+
const { outputFormat } = require('../format');
|
|
16
|
+
const { CliError } = require('../context');
|
|
17
|
+
|
|
18
|
+
const DEFAULT_OUTPUT = 'database.types.ts';
|
|
19
|
+
const DEFAULT_ZOD_OUTPUT = 'database.schemas.ts';
|
|
20
|
+
const DEFAULT_LOCAL_URL = 'http://localhost:5001';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `--local` bypasses the Console proxy and talks straight to a project API on
|
|
24
|
+
* this machine, which is how someone iterating on a local stack works: there is
|
|
25
|
+
* no console account, no link file, just a server on a port. Everything else
|
|
26
|
+
* about the context (flags, cwd, json mode) is preserved.
|
|
27
|
+
*/
|
|
28
|
+
function resolveTarget(ctx, flags) {
|
|
29
|
+
if (!flags.local) return ctx;
|
|
30
|
+
const baseUrl = config.normalizeUrl(flags.url || ctx.env.MOTHERBASE_URL || DEFAULT_LOCAL_URL);
|
|
31
|
+
const token = flags.token || ctx.env.MOTHERBASE_ADMIN_TOKEN || ctx.token;
|
|
32
|
+
return Object.assign({}, ctx, {
|
|
33
|
+
mode: 'direct',
|
|
34
|
+
requireProject: () => ({ mode: 'direct', baseUrl, token }),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Git checkouts on Windows may rewrite line endings. The generator always emits
|
|
39
|
+
// LF, so compare on normalised text: a CRLF working copy is not schema drift.
|
|
40
|
+
function normalize(text) {
|
|
41
|
+
return String(text).replace(/\r\n/g, '\n');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** First line where the two files diverge, 1-based, for a useful error. */
|
|
45
|
+
function firstDifference(a, b) {
|
|
46
|
+
const left = normalize(a).split('\n');
|
|
47
|
+
const right = normalize(b).split('\n');
|
|
48
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
49
|
+
if (left[i] !== right[i]) {
|
|
50
|
+
return { line: i + 1, expected: right[i], actual: left[i] };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Both targets are the same transport: ask the server for the rendered file,
|
|
58
|
+
* then write it, diff it or print it. `field` names which rendering to take
|
|
59
|
+
* from the one response, so a new target is a few lines rather than a copy of
|
|
60
|
+
* this whole flow.
|
|
61
|
+
*/
|
|
62
|
+
function emitter({ field, defaultOutput, label, command }) {
|
|
63
|
+
return async function run(ctx, args, flags) {
|
|
64
|
+
const schema = flags.schema || 'public';
|
|
65
|
+
const data = await api.adminRequest(resolveTarget(ctx, flags), 'GET', '/schema/types', {
|
|
66
|
+
query: { schema },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const types = data && typeof data[field] === 'string' ? data[field] : null;
|
|
70
|
+
if (!types) {
|
|
71
|
+
throw new CliError(
|
|
72
|
+
`The server returned no generated ${label}. Check that the project serves ` +
|
|
73
|
+
`GET /admin/schema/types with a build new enough for \`${command}\`.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const outputPath = flags.output && flags.output !== true ? String(flags.output) : null;
|
|
78
|
+
const DEFAULT_OUTPUT = defaultOutput;
|
|
79
|
+
|
|
80
|
+
// --check is the CI drift gate: compare the committed file with a fresh
|
|
81
|
+
// generation and fail loudly, without ever writing.
|
|
82
|
+
if (flags.check) {
|
|
83
|
+
const target = path.resolve(ctx.cwd, outputPath || DEFAULT_OUTPUT);
|
|
84
|
+
if (!fs.existsSync(target)) {
|
|
85
|
+
throw new CliError(`${target} does not exist. Run \`${command} --output ${outputPath || DEFAULT_OUTPUT}\`.`);
|
|
86
|
+
}
|
|
87
|
+
const onDisk = fs.readFileSync(target, 'utf8');
|
|
88
|
+
const diff = firstDifference(onDisk, types);
|
|
89
|
+
if (diff) {
|
|
90
|
+
throw new CliError(
|
|
91
|
+
`${target} is out of date with the ${schema} schema (first difference at line ${diff.line}).\n` +
|
|
92
|
+
` on disk: ${diff.actual === undefined ? '(end of file)' : diff.actual}\n` +
|
|
93
|
+
` generated: ${diff.expected === undefined ? '(end of file)' : diff.expected}\n` +
|
|
94
|
+
'Re-run without --check to update it.',
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
output(ctx, { schema, output: target, upToDate: true }, (d, ui) =>
|
|
98
|
+
ui.success(`${target} is up to date`),
|
|
99
|
+
);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// No --output: write the file to stdout so `... > database.types.ts` works.
|
|
104
|
+
// Nothing else may go to stdout on this path or it lands in the file.
|
|
105
|
+
if (!outputPath) {
|
|
106
|
+
if (outputFormat(ctx.flags) !== 'pretty') {
|
|
107
|
+
output(ctx, { schema, types }, () => {});
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
process.stdout.write(types);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const target = path.resolve(ctx.cwd, outputPath);
|
|
115
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
116
|
+
fs.writeFileSync(target, types, 'utf8');
|
|
117
|
+
output(ctx, { schema, output: target, bytes: Buffer.byteLength(types) }, (d, ui) =>
|
|
118
|
+
ui.success(`Wrote ${target} (${schema} schema)`),
|
|
119
|
+
);
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const typescript = {
|
|
124
|
+
summary: 'Generate TypeScript types for the database schema',
|
|
125
|
+
usage:
|
|
126
|
+
'motherbase gen types typescript [--schema public] [--local] [--output database.types.ts] [--check]',
|
|
127
|
+
// Here `--output` names the file to write, not the `-o` output format.
|
|
128
|
+
outputIsPath: true,
|
|
129
|
+
run: emitter({
|
|
130
|
+
field: 'types',
|
|
131
|
+
defaultOutput: DEFAULT_OUTPUT,
|
|
132
|
+
label: 'types',
|
|
133
|
+
command: 'motherbase gen types typescript',
|
|
134
|
+
}),
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const zod = {
|
|
138
|
+
summary: 'Generate zod schemas for the database schema',
|
|
139
|
+
usage:
|
|
140
|
+
'motherbase gen types zod [--schema public] [--local] [--output database.schemas.ts] [--check]',
|
|
141
|
+
outputIsPath: true,
|
|
142
|
+
run: emitter({
|
|
143
|
+
field: 'zod',
|
|
144
|
+
defaultOutput: DEFAULT_ZOD_OUTPUT,
|
|
145
|
+
label: 'schemas',
|
|
146
|
+
command: 'motherbase gen types zod',
|
|
147
|
+
}),
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const LANGS = { typescript, zod };
|
|
151
|
+
|
|
152
|
+
// `gen types --lang typescript` is the Supabase spelling; `gen types typescript`
|
|
153
|
+
// reaches the same emitter through the subcommand.
|
|
154
|
+
const types = {
|
|
155
|
+
summary: 'Generate types from the database schema',
|
|
156
|
+
usage: 'motherbase gen types [--lang typescript|zod] [flags] | motherbase gen types <typescript|zod> [flags]',
|
|
157
|
+
subcommands: LANGS,
|
|
158
|
+
outputIsPath: true,
|
|
159
|
+
async run(ctx, args, flags) {
|
|
160
|
+
const lang = flags.lang || 'typescript';
|
|
161
|
+
if (!LANGS[lang]) throw new CliError(`--lang must be one of: ${Object.keys(LANGS).join(', ')}`);
|
|
162
|
+
return LANGS[lang].run(ctx, args, flags);
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const signing = require('./signing');
|
|
167
|
+
|
|
168
|
+
module.exports = {
|
|
169
|
+
summary: 'Generate code from the live schema, and signing keys, tokens and secrets',
|
|
170
|
+
usage: 'motherbase gen <types|signing-key|bearer-jwt|keys> ...',
|
|
171
|
+
subcommands: { types, 'signing-key': signing.signingKey, 'bearer-jwt': signing.bearerJwt, keys: signing.keys },
|
|
172
|
+
};
|
package/src/commands/import.js
CHANGED
|
@@ -5,17 +5,37 @@ const prompt = require('../prompt');
|
|
|
5
5
|
const { output } = require('./_util');
|
|
6
6
|
const { CliError } = require('../context');
|
|
7
7
|
|
|
8
|
+
// Sections beyond the database need their own credential, because that is where
|
|
9
|
+
// Supabase keeps them: object bytes behind the Storage API (service_role key),
|
|
10
|
+
// edge functions and project secrets behind the Management API (a personal
|
|
11
|
+
// access token). Both are optional; without one, that section reports what it
|
|
12
|
+
// could not read instead of failing the import.
|
|
8
13
|
module.exports = {
|
|
9
|
-
summary: 'Import a Supabase project (schema
|
|
10
|
-
usage:
|
|
14
|
+
summary: 'Import a Supabase project (schema, data, auth, storage, edge functions, secrets)',
|
|
15
|
+
usage:
|
|
16
|
+
'motherbase import --from-supabase <connectionString> [--service-role-key <key>] [--management-token <sbp_…>] ' +
|
|
17
|
+
'[--preview] [--no-data] [--no-auth] [--no-storage] [--no-edge-functions] [--no-secrets]',
|
|
11
18
|
async run(ctx, args, flags) {
|
|
12
19
|
const connectionString = flags['from-supabase'] || flags.fromSupabase;
|
|
13
20
|
if (!connectionString) throw new CliError('--from-supabase <connectionString> is required');
|
|
14
21
|
|
|
15
|
-
const include = {
|
|
22
|
+
const include = {
|
|
23
|
+
schema: true,
|
|
24
|
+
data: !flags['no-data'],
|
|
25
|
+
auth: !flags['no-auth'],
|
|
26
|
+
storage: !flags['no-storage'],
|
|
27
|
+
edgeFunctions: !flags['no-edge-functions'],
|
|
28
|
+
secrets: !flags['no-secrets'],
|
|
29
|
+
};
|
|
16
30
|
const preview = Boolean(flags.preview);
|
|
17
31
|
|
|
18
32
|
const body = { connectionString, include };
|
|
33
|
+
const serviceRoleKey = flags['service-role-key'] || flags.serviceRoleKey;
|
|
34
|
+
const managementToken = flags['management-token'] || flags.managementToken;
|
|
35
|
+
if (serviceRoleKey) body.serviceRoleKey = serviceRoleKey;
|
|
36
|
+
if (managementToken) body.managementToken = managementToken;
|
|
37
|
+
if (flags['project-ref'] || flags.projectRef) body.projectRef = flags['project-ref'] || flags.projectRef;
|
|
38
|
+
|
|
19
39
|
if (preview) {
|
|
20
40
|
body.preview = true;
|
|
21
41
|
} else {
|
package/src/commands/index.js
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// `main.js` builds all help/dispatch from this single map.
|
|
5
5
|
|
|
6
6
|
const local = require('./local');
|
|
7
|
+
const { alias } = require('./_util');
|
|
8
|
+
const migration = require('./migration');
|
|
7
9
|
|
|
8
10
|
const registry = {
|
|
9
11
|
// ── Connection ─────────────────────────────────────────────────────────────
|
|
@@ -14,8 +16,12 @@ const registry = {
|
|
|
14
16
|
unlink: require('./unlink'),
|
|
15
17
|
status: require('./status'),
|
|
16
18
|
|
|
17
|
-
// ──
|
|
19
|
+
// ── Orgs & projects (Platform Console) ─────────────────────────────────────
|
|
20
|
+
orgs: require('./orgs'),
|
|
18
21
|
projects: require('./projects'),
|
|
22
|
+
branches: require('./branches'),
|
|
23
|
+
domains: require('./domains').domains,
|
|
24
|
+
'vanity-subdomains': require('./domains').vanitySubdomains,
|
|
19
25
|
|
|
20
26
|
// ── Local self-host (docker compose) ───────────────────────────────────────
|
|
21
27
|
init: local.init,
|
|
@@ -26,9 +32,14 @@ const registry = {
|
|
|
26
32
|
|
|
27
33
|
// ── Database / migrations / import ─────────────────────────────────────────
|
|
28
34
|
db: require('./db'),
|
|
29
|
-
|
|
30
|
-
migration
|
|
35
|
+
backups: require('./backups'),
|
|
36
|
+
migration,
|
|
37
|
+
// The name Motherbase used before it matched Supabase's `migration`.
|
|
38
|
+
migrations: alias(migration, 'motherbase migrations <new|list|up|down|repair|squash|fetch|diff|get|create|apply|rollback> ... (alias of `migration`)'),
|
|
31
39
|
import: require('./import'),
|
|
40
|
+
migrate: require('./migrate'),
|
|
41
|
+
test: require('./test'),
|
|
42
|
+
'postgres-config': require('./postgres-config'),
|
|
32
43
|
|
|
33
44
|
// ── Resource domains ───────────────────────────────────────────────────────
|
|
34
45
|
rls: require('./rls'),
|
|
@@ -40,25 +51,35 @@ const registry = {
|
|
|
40
51
|
realtime: require('./realtime'),
|
|
41
52
|
webhooks: require('./webhooks'),
|
|
42
53
|
cron: require('./cron'),
|
|
54
|
+
queues: require('./queues'),
|
|
55
|
+
vault: require('./vault'),
|
|
56
|
+
wrappers: require('./wrappers'),
|
|
43
57
|
config: require('./config'),
|
|
44
58
|
inspect: require('./inspect'),
|
|
59
|
+
'network-restrictions': require('./network').networkRestrictions,
|
|
60
|
+
'ssl-enforcement': require('./network').sslEnforcement,
|
|
61
|
+
snippets: require('./snippets'),
|
|
45
62
|
|
|
46
63
|
// ── Codegen ────────────────────────────────────────────────────────────────
|
|
47
64
|
gen: require('./gen'),
|
|
48
65
|
|
|
49
66
|
// ── AI agents ──────────────────────────────────────────────────────────────
|
|
50
67
|
mcp: require('./mcp'),
|
|
68
|
+
|
|
69
|
+
// ── Shell ──────────────────────────────────────────────────────────────────
|
|
70
|
+
completion: require('./completion'),
|
|
51
71
|
};
|
|
52
72
|
|
|
53
73
|
// Grouping used only to render a tidy top-level help listing.
|
|
54
74
|
const groups = [
|
|
55
75
|
{ title: 'Connection', names: ['login', 'logout', 'whoami', 'link', 'unlink', 'status'] },
|
|
56
|
-
{ title: '
|
|
76
|
+
{ title: 'Orgs & projects (Console)', names: ['orgs', 'projects', 'branches', 'domains', 'vanity-subdomains'] },
|
|
57
77
|
{ title: 'Local self-host', names: ['init', 'start', 'stop', 'deploy', 'logs'] },
|
|
58
|
-
{ title: 'Database', names: ['db', 'migrations', 'migration', 'import'] },
|
|
59
|
-
{ title: 'Resources', names: ['rls', 'users', 'storage', 'functions', 'secrets', 'keys', 'realtime', 'webhooks', 'cron', 'config', 'inspect'] },
|
|
78
|
+
{ title: 'Database', names: ['db', 'backups', 'migrations', 'migration', 'import', 'test', 'postgres-config'] },
|
|
79
|
+
{ title: 'Resources', names: ['rls', 'users', 'storage', 'functions', 'secrets', 'keys', 'realtime', 'webhooks', 'cron', 'queues', 'vault', 'wrappers', 'config', 'inspect', 'snippets', 'network-restrictions', 'ssl-enforcement'] },
|
|
60
80
|
{ title: 'Codegen', names: ['gen'] },
|
|
61
81
|
{ title: 'AI agents', names: ['mcp'] },
|
|
82
|
+
{ title: 'Shell', names: ['completion'] },
|
|
62
83
|
];
|
|
63
84
|
|
|
64
85
|
module.exports = { registry, groups };
|
package/src/commands/inspect.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
3
5
|
const api = require('../api');
|
|
4
6
|
const { output } = require('./_util');
|
|
7
|
+
const { CliError } = require('../context');
|
|
8
|
+
const { QUERIES, DEPRECATED, REPORT, resolveQuery } = require('../inspect/queries');
|
|
9
|
+
const { toCsv } = require('../inspect/csv');
|
|
5
10
|
|
|
6
11
|
function simple(apiPath, summary, render) {
|
|
7
12
|
return {
|
|
8
13
|
summary,
|
|
9
|
-
usage:
|
|
14
|
+
// usage is filled in from the subcommand key below: deriving it from the
|
|
15
|
+
// summary printed `inspect security` for `shield` and `inspect request` for
|
|
16
|
+
// `requests`, neither of which is a command.
|
|
17
|
+
usage: '',
|
|
10
18
|
async run(ctx) {
|
|
11
19
|
const data = await api.adminRequest(ctx, 'GET', apiPath);
|
|
12
20
|
output(ctx, data, render || ((d, ui) => ui.json(d)));
|
|
@@ -14,12 +22,115 @@ function simple(apiPath, summary, render) {
|
|
|
14
22
|
};
|
|
15
23
|
}
|
|
16
24
|
|
|
25
|
+
// Postgres' two ways of saying pg_stat_statements is not usable here: the
|
|
26
|
+
// extension was never created in this database, or the library is not preloaded.
|
|
27
|
+
const STAT_STATEMENTS_MISSING = /pg_stat_statements.*(does not exist|must be loaded)/i;
|
|
28
|
+
|
|
29
|
+
class StatStatementsMissing extends CliError {
|
|
30
|
+
constructor() {
|
|
31
|
+
super(
|
|
32
|
+
'pg_stat_statements is not enabled in this database. Enable it from Studio (Database > Extensions) '
|
|
33
|
+
+ 'or run: motherbase db sql -e "create extension pg_stat_statements"',
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Rows of one inspect query, run on the read-only SQL endpoint. */
|
|
39
|
+
async function runQuery(ctx, query) {
|
|
40
|
+
try {
|
|
41
|
+
const data = await api.adminRequest(ctx, 'POST', '/sql/readonly', { body: { sql: query.sql } });
|
|
42
|
+
return (data && data.rows) || [];
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (query.needsStatStatements && STAT_STATEMENTS_MISSING.test(err.message)) {
|
|
45
|
+
throw new StatStatementsMissing();
|
|
46
|
+
}
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Statements span lines and `to_char` pads numbers; a table cell wants neither.
|
|
52
|
+
// A statement is also cut short, or one query pushes every other column off
|
|
53
|
+
// screen; -o json keeps it whole.
|
|
54
|
+
const CELL_WIDTH = 60;
|
|
55
|
+
function cell(value) {
|
|
56
|
+
if (typeof value !== 'string') return value;
|
|
57
|
+
const text = value.replace(/\s+/g, ' ').trim();
|
|
58
|
+
return text.length > CELL_WIDTH ? `${text.slice(0, CELL_WIDTH - 1)}…` : text;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function dbCommand(name) {
|
|
62
|
+
const { name: canonical, query } = resolveQuery(name);
|
|
63
|
+
const replaced = canonical !== name;
|
|
64
|
+
return {
|
|
65
|
+
summary: replaced ? `Deprecated: use "${canonical}" instead` : query.summary,
|
|
66
|
+
usage: `motherbase inspect db ${name}`,
|
|
67
|
+
async run(ctx) {
|
|
68
|
+
const rows = await runQuery(ctx, query);
|
|
69
|
+
output(ctx, { query: canonical, rows }, (_d, ui) => {
|
|
70
|
+
if (replaced) ui.warn(`"inspect db ${name}" is deprecated: showing "${canonical}".`);
|
|
71
|
+
const display = rows.map(row => Object.fromEntries(Object.entries(row).map(([k, v]) => [k, cell(v)])));
|
|
72
|
+
ui.table(display, query.columns);
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const db = {
|
|
79
|
+
summary: 'Inspect the database: bloat, locks, slow queries, index and table stats',
|
|
80
|
+
usage: `motherbase inspect db <${Object.keys(QUERIES).join('|')}>`,
|
|
81
|
+
subcommands: Object.fromEntries(
|
|
82
|
+
[...Object.keys(QUERIES), ...Object.keys(DEPRECATED)].map(name => [name, dbCommand(name)]),
|
|
83
|
+
),
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Local-time YYYY-MM-DD: the report's folder, one per day as upstream does.
|
|
87
|
+
function dateFolder(now = new Date()) {
|
|
88
|
+
const pad = n => String(n).padStart(2, '0');
|
|
89
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const report = {
|
|
93
|
+
summary: 'Write every inspect query to a CSV in a dated folder',
|
|
94
|
+
usage: 'motherbase inspect report [--output-dir <dir>]',
|
|
95
|
+
async run(ctx, _args, flags) {
|
|
96
|
+
const dir = path.resolve(ctx.cwd, String(flags['output-dir'] || '.'), dateFolder());
|
|
97
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
98
|
+
|
|
99
|
+
const files = [];
|
|
100
|
+
const skipped = [];
|
|
101
|
+
// One at a time: fourteen catalog scans at once is a load spike on the
|
|
102
|
+
// database being diagnosed.
|
|
103
|
+
for (const { file, name, query } of REPORT) {
|
|
104
|
+
let rows;
|
|
105
|
+
try {
|
|
106
|
+
rows = await runQuery(ctx, query);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
// A missing pg_stat_statements costs two files, not the whole report.
|
|
109
|
+
if (!(err instanceof StatStatementsMissing)) throw err;
|
|
110
|
+
skipped.push(name);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const target = path.join(dir, `${file}.csv`);
|
|
114
|
+
fs.writeFileSync(target, toCsv(query.columns, rows));
|
|
115
|
+
files.push(target);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
output(ctx, { outputDir: dir, files, skipped }, (d, ui) => {
|
|
119
|
+
if (d.skipped.length) ui.warn(`Skipped ${d.skipped.join(', ')}: ${new StatStatementsMissing().message}`);
|
|
120
|
+
ui.success(`Saved ${d.files.length} CSV files to ${path.relative(ctx.cwd, d.outputDir) || '.'}`);
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
17
125
|
const subcommands = {
|
|
126
|
+
db,
|
|
127
|
+
report,
|
|
18
128
|
logs: {
|
|
19
129
|
summary: 'Recent server logs',
|
|
20
|
-
usage: 'motherbase inspect logs [--limit n]',
|
|
130
|
+
usage: 'motherbase inspect logs [--limit n] [--source api|rest|postgres|pooler|auth|storage|realtime|edge-function|cron|platform]',
|
|
21
131
|
async run(ctx, args, flags) {
|
|
22
|
-
|
|
132
|
+
// A Logs Explorer "source" is the server's `service` filter.
|
|
133
|
+
const query = flags.limit || flags.source ? { limit: flags.limit, service: flags.source } : undefined;
|
|
23
134
|
const data = await api.adminRequest(ctx, 'GET', '/logs', { query });
|
|
24
135
|
const logs = (data && data.logs) || [];
|
|
25
136
|
output(ctx, data, (_d, ui) =>
|
|
@@ -27,6 +138,26 @@ const subcommands = {
|
|
|
27
138
|
);
|
|
28
139
|
},
|
|
29
140
|
},
|
|
141
|
+
'index-advisor': {
|
|
142
|
+
summary: 'Suggest indexes for a statement (hypopg + index_advisor)',
|
|
143
|
+
usage: 'motherbase inspect index-advisor --sql "<select ...>" | motherbase inspect index-advisor --enable',
|
|
144
|
+
async run(ctx, args, flags) {
|
|
145
|
+
if (flags.enable) {
|
|
146
|
+
const status = await api.adminRequest(ctx, 'POST', '/performance/index-advisor/enable');
|
|
147
|
+
output(ctx, status, (_d, ui) => ui.success('Index advisor enabled'));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const query = flags.sql || flags.e;
|
|
151
|
+
if (typeof query !== 'string') throw new CliError('Provide the statement with --sql "..." (or --enable to install the advisor).');
|
|
152
|
+
const data = await api.adminRequest(ctx, 'POST', '/performance/index-advisor', { body: { query } });
|
|
153
|
+
output(ctx, data, (d, ui) => {
|
|
154
|
+
const statements = (d && d.indexStatements) || [];
|
|
155
|
+
if (!statements.length) ui.print('No index would help this statement.');
|
|
156
|
+
statements.forEach(s => ui.print(s));
|
|
157
|
+
if (d && d.improvementPct != null) ui.print(`Estimated cost drop: ${d.improvementPct}%`);
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
},
|
|
30
161
|
stats: simple('/stats', 'Stats overview'),
|
|
31
162
|
performance: simple('/performance', 'Performance metrics'),
|
|
32
163
|
requests: simple('/metrics/requests', 'Request metrics'),
|
|
@@ -34,8 +165,14 @@ const subcommands = {
|
|
|
34
165
|
shield: simple('/shield', 'Security shield report'),
|
|
35
166
|
};
|
|
36
167
|
|
|
168
|
+
for (const [name, def] of Object.entries(subcommands)) {
|
|
169
|
+
if (!def.usage) def.usage = `motherbase inspect ${name}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
37
172
|
module.exports = {
|
|
38
|
-
summary: 'Diagnostics: logs, stats,
|
|
39
|
-
usage: 'motherbase inspect <logs|stats|performance|requests|advisor|shield>',
|
|
173
|
+
summary: 'Diagnostics: database inspection, report, logs, stats, index-advisor, advisor, shield',
|
|
174
|
+
usage: 'motherbase inspect <db|report|logs|stats|performance|index-advisor|requests|advisor|shield>',
|
|
40
175
|
subcommands,
|
|
176
|
+
dateFolder,
|
|
177
|
+
cell,
|
|
41
178
|
};
|