@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
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const api = require('../api');
|
|
4
|
+
const { output, requireArg } = require('./_util');
|
|
5
|
+
const { CliError } = require('../context');
|
|
6
|
+
|
|
7
|
+
const enc = encodeURIComponent;
|
|
8
|
+
|
|
9
|
+
function int(flag, name) {
|
|
10
|
+
if (flag === undefined) return undefined;
|
|
11
|
+
const n = Number(flag);
|
|
12
|
+
if (!Number.isInteger(n) || n < 0) throw new CliError(`--${name} must be a non-negative integer`);
|
|
13
|
+
return n;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function parseMessage(raw) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(requireArg(raw, 'json'));
|
|
19
|
+
} catch (err) {
|
|
20
|
+
if (err instanceof CliError) throw err;
|
|
21
|
+
throw new CliError('the message must be valid JSON, e.g. \'{"task":"resize"}\'');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const subcommands = {
|
|
26
|
+
list: {
|
|
27
|
+
summary: 'List queues with their depth',
|
|
28
|
+
usage: 'motherbase queues list',
|
|
29
|
+
async run(ctx) {
|
|
30
|
+
const data = await api.adminRequest(ctx, 'GET', '/queues');
|
|
31
|
+
const queues = (data && data.queues) || [];
|
|
32
|
+
output(ctx, data, (_d, ui) =>
|
|
33
|
+
ui.table(queues, [
|
|
34
|
+
{ key: 'name', label: 'NAME' },
|
|
35
|
+
{ key: 'type', label: 'TYPE' },
|
|
36
|
+
{ key: 'length', label: 'MESSAGES' },
|
|
37
|
+
{ key: 'visibleLength', label: 'VISIBLE' },
|
|
38
|
+
{ key: 'totalMessages', label: 'TOTAL' },
|
|
39
|
+
]),
|
|
40
|
+
);
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
enable: {
|
|
44
|
+
summary: 'Install pgmq for this project',
|
|
45
|
+
usage: 'motherbase queues enable',
|
|
46
|
+
async run(ctx) {
|
|
47
|
+
const data = await api.adminRequest(ctx, 'POST', '/queues/enable');
|
|
48
|
+
output(ctx, data, (_d, ui) => ui.success('Queues enabled'));
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
expose: {
|
|
52
|
+
summary: 'Expose queues to the client SDK through pgmq_public (or hide them)',
|
|
53
|
+
usage: 'motherbase queues expose <true|false>',
|
|
54
|
+
async run(ctx, args) {
|
|
55
|
+
const value = requireArg(args[0], 'true|false');
|
|
56
|
+
if (value !== 'true' && value !== 'false') throw new CliError('expected true or false');
|
|
57
|
+
const data = await api.adminRequest(ctx, 'PUT', '/queues/expose', { body: { enabled: value === 'true' } });
|
|
58
|
+
output(ctx, data, (_d, ui) => ui.success(`Queues ${value === 'true' ? 'exposed' : 'hidden'}`));
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
create: {
|
|
62
|
+
summary: 'Create a queue',
|
|
63
|
+
usage: 'motherbase queues create <name> [--type basic|unlogged|partitioned] [--rls true|false]',
|
|
64
|
+
async run(ctx, args, flags) {
|
|
65
|
+
const name = requireArg(args[0], 'name');
|
|
66
|
+
const body = { name };
|
|
67
|
+
if (flags.type) body.type = flags.type;
|
|
68
|
+
if (flags.rls !== undefined) body.rls = flags.rls === true || flags.rls === 'true';
|
|
69
|
+
const data = await api.adminRequest(ctx, 'POST', '/queues', { body });
|
|
70
|
+
output(ctx, data, (_d, ui) => ui.success(`Created queue ${name}`));
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
drop: {
|
|
74
|
+
summary: 'Drop a queue and its messages',
|
|
75
|
+
usage: 'motherbase queues drop <name>',
|
|
76
|
+
async run(ctx, args) {
|
|
77
|
+
const name = requireArg(args[0], 'name');
|
|
78
|
+
const data = await api.adminRequest(ctx, 'DELETE', `/queues/${enc(name)}`);
|
|
79
|
+
output(ctx, data, (_d, ui) => ui.success(`Dropped queue ${name}`));
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
send: {
|
|
83
|
+
summary: 'Send a JSON message',
|
|
84
|
+
usage: "motherbase queues send <name> '<json>' [--delay seconds]",
|
|
85
|
+
async run(ctx, args, flags) {
|
|
86
|
+
const name = requireArg(args[0], 'name');
|
|
87
|
+
const body = { message: parseMessage(args[1]) };
|
|
88
|
+
const delay = int(flags.delay, 'delay');
|
|
89
|
+
if (delay !== undefined) body.delay = delay;
|
|
90
|
+
const data = await api.adminRequest(ctx, 'POST', `/queues/${enc(name)}/messages`, { body });
|
|
91
|
+
output(ctx, data, (d, ui) => ui.success(`Sent message ${((d && d.msgIds) || []).join(', ')}`));
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
read: {
|
|
95
|
+
summary: 'Read messages, hiding them from other readers for the visibility timeout',
|
|
96
|
+
usage: 'motherbase queues read <name> [--qty 1] [--vt 30]',
|
|
97
|
+
async run(ctx, args, flags) {
|
|
98
|
+
const name = requireArg(args[0], 'name');
|
|
99
|
+
const body = {};
|
|
100
|
+
const qty = int(flags.qty, 'qty');
|
|
101
|
+
const vt = int(flags.vt, 'vt');
|
|
102
|
+
if (qty !== undefined) body.qty = qty;
|
|
103
|
+
if (vt !== undefined) body.vt = vt;
|
|
104
|
+
const data = await api.adminRequest(ctx, 'POST', `/queues/${enc(name)}/read`, { body });
|
|
105
|
+
output(ctx, data, (d, ui) => ui.json((d && d.messages) || []));
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
archive: {
|
|
109
|
+
summary: 'Move a message to the archive',
|
|
110
|
+
usage: 'motherbase queues archive <name> <msg-id>',
|
|
111
|
+
async run(ctx, args) {
|
|
112
|
+
const name = requireArg(args[0], 'name');
|
|
113
|
+
const id = requireArg(args[1], 'msg-id');
|
|
114
|
+
const data = await api.adminRequest(ctx, 'POST', `/queues/${enc(name)}/messages/${enc(id)}/archive`);
|
|
115
|
+
output(ctx, data, (_d, ui) => ui.success(`Archived message ${id}`));
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
purge: {
|
|
119
|
+
summary: 'Delete every message in a queue',
|
|
120
|
+
usage: 'motherbase queues purge <name>',
|
|
121
|
+
async run(ctx, args) {
|
|
122
|
+
const name = requireArg(args[0], 'name');
|
|
123
|
+
const data = await api.adminRequest(ctx, 'POST', `/queues/${enc(name)}/purge`);
|
|
124
|
+
output(ctx, data, (d, ui) => ui.success(`Purged ${(d && d.purged) ?? 0} messages from ${name}`));
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
module.exports = {
|
|
130
|
+
summary: 'Message queues (pgmq)',
|
|
131
|
+
usage: 'motherbase queues <list|enable|expose|create|drop|send|read|archive|purge> ...',
|
|
132
|
+
subcommands,
|
|
133
|
+
};
|
package/src/commands/secrets.js
CHANGED
|
@@ -1,7 +1,24 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
3
5
|
const api = require('../api');
|
|
4
|
-
const { output, requireArg } = require('./_util');
|
|
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
|
+
}
|
|
5
22
|
|
|
6
23
|
const subcommands = {
|
|
7
24
|
list: {
|
|
@@ -18,13 +35,17 @@ const subcommands = {
|
|
|
18
35
|
},
|
|
19
36
|
},
|
|
20
37
|
set: {
|
|
21
|
-
summary: 'Set
|
|
22
|
-
usage: 'motherbase secrets set
|
|
23
|
-
async run(ctx, args) {
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
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(', ')}`));
|
|
28
49
|
},
|
|
29
50
|
},
|
|
30
51
|
unset: {
|
|
@@ -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
|
+
};
|
package/src/commands/storage.js
CHANGED
|
@@ -1,9 +1,140 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// Storage in the Supabase CLI's shape: objects are addressed `ss:///bucket/key`
|
|
4
|
+
// and `ls|cp|mv|rm` take `-r` for a whole prefix. The pre-Supabase forms still
|
|
5
|
+
// work: `cp ./a.png bucket:` uploads, `cp <fileId> ./a.png` downloads and
|
|
6
|
+
// `rm <fileId>` deletes by id.
|
|
7
|
+
|
|
3
8
|
const fs = require('fs');
|
|
4
9
|
const path = require('path');
|
|
5
10
|
const api = require('../api');
|
|
6
11
|
const { output, requireArg, splitRef } = require('./_util');
|
|
12
|
+
const { CliError } = require('../context');
|
|
13
|
+
const remote = require('../storage/remote');
|
|
14
|
+
|
|
15
|
+
const PAGE = 1000;
|
|
16
|
+
|
|
17
|
+
const objectPath = (bucket, key) => `/storage/buckets/${encodeURIComponent(bucket)}/objects/${remote.encodeKey(key)}`;
|
|
18
|
+
|
|
19
|
+
/** Every key under a prefix, paged through the server's 1000-row limit. */
|
|
20
|
+
async function listKeys(ctx, bucket, prefix) {
|
|
21
|
+
const keys = [];
|
|
22
|
+
for (let offset = 0; ; offset += PAGE) {
|
|
23
|
+
const data = await api.adminRequest(ctx, 'GET', `/storage/buckets/${encodeURIComponent(bucket)}/objects`, {
|
|
24
|
+
query: { prefix: prefix || undefined, limit: PAGE, offset },
|
|
25
|
+
});
|
|
26
|
+
const page = (data && data.objects) || [];
|
|
27
|
+
keys.push(...page.map(o => o.name));
|
|
28
|
+
if (page.length < PAGE) return keys;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Files under a local directory, as paths relative to it with `/` separators. */
|
|
33
|
+
function walk(dir, base = dir) {
|
|
34
|
+
return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
|
|
35
|
+
const full = path.join(dir, entry.name);
|
|
36
|
+
if (entry.isDirectory()) return walk(full, base);
|
|
37
|
+
return entry.isFile() ? [path.relative(base, full).split(path.sep).join('/')] : [];
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Run one request per object, in order. The first failure stops the batch and
|
|
43
|
+
* says how far it got, so a partial copy or delete is never reported as done.
|
|
44
|
+
*/
|
|
45
|
+
async function each(items, label, fn) {
|
|
46
|
+
const done = [];
|
|
47
|
+
for (const item of items) {
|
|
48
|
+
try {
|
|
49
|
+
await fn(item);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
throw new CliError(`${label} failed on ${item} after ${done.length} of ${items.length}: ${err.message}`);
|
|
52
|
+
}
|
|
53
|
+
done.push(item);
|
|
54
|
+
}
|
|
55
|
+
return done;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The keys an operation on `key` touches: the one object, or everything under it with -r. */
|
|
59
|
+
async function sourceKeys(ctx, { bucket, key }, recursive) {
|
|
60
|
+
if (!recursive) {
|
|
61
|
+
if (!key) throw new CliError('Name an object, or pass -r to act on a whole prefix.');
|
|
62
|
+
return [key];
|
|
63
|
+
}
|
|
64
|
+
const keys = await listKeys(ctx, bucket, remote.asPrefix(key));
|
|
65
|
+
if (!keys.length) throw new CliError(`No objects under ss:///${bucket}/${remote.asPrefix(key)}`);
|
|
66
|
+
return keys;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Remote-to-remote copy or move: same bucket, since both routes are bucket-scoped. */
|
|
70
|
+
async function transfer(ctx, action, srcArg, dstArg, recursive) {
|
|
71
|
+
const src = remote.requireBucket(remote.parseRemote(srcArg), srcArg);
|
|
72
|
+
const dst = remote.requireBucket(remote.parseRemote(dstArg), dstArg);
|
|
73
|
+
if (src.bucket !== dst.bucket) {
|
|
74
|
+
throw new CliError(`${action} works within one bucket: ${src.bucket} and ${dst.bucket} differ.`);
|
|
75
|
+
}
|
|
76
|
+
const keys = await sourceKeys(ctx, src, recursive);
|
|
77
|
+
const target = key => (recursive
|
|
78
|
+
? remote.rebase(key, remote.asPrefix(src.key), remote.asPrefix(dst.key))
|
|
79
|
+
: remote.destinationKey(dst.key, key));
|
|
80
|
+
// cp overwrites as it does locally; mv refuses to, as Supabase's does.
|
|
81
|
+
const upsert = action === 'copy';
|
|
82
|
+
return each(keys, action, key =>
|
|
83
|
+
api.adminRequest(ctx, 'POST', `/storage/buckets/${encodeURIComponent(src.bucket)}/${action}`, {
|
|
84
|
+
body: { from: key, to: target(key), upsert },
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function upload(ctx, srcArg, dstArg, recursive) {
|
|
90
|
+
const dst = remote.requireBucket(remote.parseRemote(dstArg), dstArg);
|
|
91
|
+
const local = path.resolve(ctx.cwd, srcArg);
|
|
92
|
+
const upsert = 'true';
|
|
93
|
+
if (fs.statSync(local).isDirectory()) {
|
|
94
|
+
if (!recursive) throw new CliError(`${srcArg} is a directory: pass -r to upload it.`);
|
|
95
|
+
const prefix = remote.asPrefix(dst.key);
|
|
96
|
+
return each(walk(local), 'upload', rel =>
|
|
97
|
+
api.uploadFile(ctx, '/storage/files', path.join(local, rel), { bucket: dst.bucket, path: prefix + rel, upsert }),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
const key = remote.destinationKey(dst.key, local);
|
|
101
|
+
return each([key], 'upload', () => api.uploadFile(ctx, '/storage/files', local, { bucket: dst.bucket, path: key, upsert }));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function download(ctx, srcArg, dstArg, recursive) {
|
|
105
|
+
const src = remote.requireBucket(remote.parseRemote(srcArg), srcArg);
|
|
106
|
+
const local = path.resolve(ctx.cwd, dstArg);
|
|
107
|
+
if (recursive) {
|
|
108
|
+
const prefix = remote.asPrefix(src.key);
|
|
109
|
+
const keys = await sourceKeys(ctx, src, true);
|
|
110
|
+
return each(keys, 'download', key => {
|
|
111
|
+
const target = path.join(local, ...key.slice(prefix.length).split('/'));
|
|
112
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
113
|
+
return api.downloadFile(ctx, objectPath(src.bucket, key), target);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const [key] = await sourceKeys(ctx, src, false);
|
|
117
|
+
const intoDir = dstArg.endsWith('/') || dstArg.endsWith(path.sep) || (fs.existsSync(local) && fs.statSync(local).isDirectory());
|
|
118
|
+
const target = intoDir ? path.join(local, path.basename(key)) : local;
|
|
119
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
120
|
+
return each([key], 'download', () => api.downloadFile(ctx, objectPath(src.bucket, key), target));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// The id-addressed forms that predate `ss://` paths.
|
|
124
|
+
async function legacyCopy(ctx, src, dst, flags) {
|
|
125
|
+
if (fs.existsSync(path.resolve(ctx.cwd, src))) {
|
|
126
|
+
const { bucket } = splitRef(dst);
|
|
127
|
+
const fields = { bucket, visibility: flags.visibility };
|
|
128
|
+
const data = await api.uploadFile(ctx, '/storage/files', path.resolve(ctx.cwd, src), fields);
|
|
129
|
+
return output(ctx, data, (d, ui) => ui.success(`Uploaded ${src}${d && d.id ? ` → ${d.id}` : ''}`));
|
|
130
|
+
}
|
|
131
|
+
const { rest } = splitRef(src);
|
|
132
|
+
const fileId = rest || src;
|
|
133
|
+
const res = await api.downloadFile(ctx, `/storage/files/${fileId}/raw`, path.resolve(ctx.cwd, dst));
|
|
134
|
+
return output(ctx, res, (d, ui) => ui.success(`Downloaded ${fileId} → ${dst}`));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const reportDone = (verb, count) => (_d, ui) => ui.success(`${verb} ${count} object${count === 1 ? '' : 's'}`);
|
|
7
138
|
|
|
8
139
|
const buckets = {
|
|
9
140
|
summary: 'Manage storage buckets',
|
|
@@ -26,11 +157,16 @@ const buckets = {
|
|
|
26
157
|
},
|
|
27
158
|
create: {
|
|
28
159
|
summary: 'Create a bucket',
|
|
29
|
-
usage:
|
|
160
|
+
usage:
|
|
161
|
+
'motherbase storage buckets create <slug> [--name n] [--visibility public|private]'
|
|
162
|
+
+ ' [--file-size-limit 20MB] [--allowed-mime-types image/*,application/pdf]',
|
|
30
163
|
async run(ctx, args, flags) {
|
|
31
164
|
const slug = requireArg(args[0], 'slug');
|
|
32
165
|
const body = { slug, name: flags.name || slug };
|
|
33
166
|
if (flags.visibility) body.visibility = flags.visibility;
|
|
167
|
+
// Passed through as typed: the server reads "20MB" as well as a byte count.
|
|
168
|
+
if (flags['file-size-limit']) body.fileSizeLimit = flags['file-size-limit'];
|
|
169
|
+
if (flags['allowed-mime-types']) body.allowedMimeTypes = String(flags['allowed-mime-types']).split(',');
|
|
34
170
|
const data = await api.adminRequest(ctx, 'POST', '/storage/buckets', { body });
|
|
35
171
|
output(ctx, data, (d, ui) => ui.success(`Created bucket ${slug}`));
|
|
36
172
|
},
|
|
@@ -51,63 +187,75 @@ const buckets = {
|
|
|
51
187
|
const subcommands = {
|
|
52
188
|
buckets,
|
|
53
189
|
ls: {
|
|
54
|
-
summary: 'List
|
|
55
|
-
usage: 'motherbase storage ls [bucket]',
|
|
56
|
-
async run(ctx, args) {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
);
|
|
190
|
+
summary: 'List buckets, or the objects under a path',
|
|
191
|
+
usage: 'motherbase storage ls [ss:///bucket/prefix] [-r]',
|
|
192
|
+
async run(ctx, args, flags) {
|
|
193
|
+
const arg = args[0] || remote.SCHEME;
|
|
194
|
+
// A bare bucket name is the pre-`ss://` form of the same request.
|
|
195
|
+
const { bucket, key } = remote.parseRemote(remote.isRemote(arg) ? arg : `${remote.SCHEME}${arg}`);
|
|
196
|
+
if (!bucket) {
|
|
197
|
+
const data = await api.adminRequest(ctx, 'GET', '/storage/buckets');
|
|
198
|
+
const slugs = (Array.isArray(data) ? data : []).map(b => `${b.slug}/`);
|
|
199
|
+
return output(ctx, { entries: slugs }, (d, ui) => d.entries.forEach(e => ui.print(e)));
|
|
200
|
+
}
|
|
201
|
+
const prefix = remote.asPrefix(key);
|
|
202
|
+
const keys = await listKeys(ctx, bucket, prefix);
|
|
203
|
+
const entries = flags.recursive ? keys : remote.childrenOf(keys, prefix);
|
|
204
|
+
output(ctx, { bucket, prefix, entries }, (d, ui) => d.entries.forEach(e => ui.print(e)));
|
|
69
205
|
},
|
|
70
206
|
},
|
|
71
207
|
cp: {
|
|
72
|
-
summary: '
|
|
73
|
-
usage: 'motherbase storage cp <src> <dst> e.g. cp ./a.png
|
|
208
|
+
summary: 'Copy between local paths and ss:///bucket/key (upload, download or in-bucket copy)',
|
|
209
|
+
usage: 'motherbase storage cp <src> <dst> [-r] e.g. cp ./a.png ss:///media/ | cp -r ss:///media/img ./img',
|
|
74
210
|
async run(ctx, args, flags) {
|
|
75
211
|
const src = requireArg(args[0], 'src');
|
|
76
212
|
const dst = requireArg(args[1], 'dst');
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
else require('../ui').success(`Downloaded ${fileId} → ${dst}`);
|
|
213
|
+
const recursive = Boolean(flags.recursive);
|
|
214
|
+
let done;
|
|
215
|
+
if (remote.isRemote(src) && remote.isRemote(dst)) done = await transfer(ctx, 'copy', src, dst, recursive);
|
|
216
|
+
else if (remote.isRemote(dst)) done = await upload(ctx, src, dst, recursive);
|
|
217
|
+
else if (remote.isRemote(src)) done = await download(ctx, src, dst, recursive);
|
|
218
|
+
else return legacyCopy(ctx, src, dst, flags);
|
|
219
|
+
output(ctx, { copied: done }, reportDone('Copied', done.length));
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
mv: {
|
|
223
|
+
summary: 'Move objects within a bucket',
|
|
224
|
+
usage: 'motherbase storage mv <ss:///bucket/src> <ss:///bucket/dst> [-r]',
|
|
225
|
+
async run(ctx, args, flags) {
|
|
226
|
+
const src = requireArg(args[0], 'src');
|
|
227
|
+
const dst = requireArg(args[1], 'dst');
|
|
228
|
+
if (!remote.isRemote(src) || !remote.isRemote(dst)) {
|
|
229
|
+
throw new CliError('mv moves objects: both paths must be ss:///bucket/key.');
|
|
95
230
|
}
|
|
231
|
+
const done = await transfer(ctx, 'move', src, dst, Boolean(flags.recursive));
|
|
232
|
+
output(ctx, { moved: done }, reportDone('Moved', done.length));
|
|
96
233
|
},
|
|
97
234
|
},
|
|
98
235
|
rm: {
|
|
99
|
-
summary: 'Delete a file by id',
|
|
100
|
-
usage: 'motherbase storage rm <
|
|
101
|
-
async run(ctx, args) {
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
236
|
+
summary: 'Delete objects by path (or a file by id)',
|
|
237
|
+
usage: 'motherbase storage rm <ss:///bucket/key | fileId>... [-r]',
|
|
238
|
+
async run(ctx, args, flags) {
|
|
239
|
+
requireArg(args[0], 'path');
|
|
240
|
+
const removed = [];
|
|
241
|
+
for (const arg of args) {
|
|
242
|
+
if (!remote.isRemote(arg)) {
|
|
243
|
+
await api.adminRequest(ctx, 'DELETE', `/storage/files/${encodeURIComponent(arg)}`);
|
|
244
|
+
removed.push(arg);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const target = remote.requireBucket(remote.parseRemote(arg), arg);
|
|
248
|
+
const keys = await sourceKeys(ctx, target, Boolean(flags.recursive));
|
|
249
|
+
const done = await each(keys, 'rm', key => api.adminRequest(ctx, 'DELETE', objectPath(target.bucket, key)));
|
|
250
|
+
removed.push(...done.map(key => `${target.bucket}/${key}`));
|
|
251
|
+
}
|
|
252
|
+
output(ctx, { removed }, reportDone('Deleted', removed.length));
|
|
105
253
|
},
|
|
106
254
|
},
|
|
107
255
|
};
|
|
108
256
|
|
|
109
257
|
module.exports = {
|
|
110
|
-
summary: 'Storage buckets and
|
|
111
|
-
usage: 'motherbase storage <buckets|ls|cp|rm> ...',
|
|
258
|
+
summary: 'Storage buckets and objects (ss:///bucket/key paths)',
|
|
259
|
+
usage: 'motherbase storage <buckets|ls|cp|mv|rm> ...',
|
|
112
260
|
subcommands,
|
|
113
261
|
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Supabase's `test db`: pgTAP files from motherbase/tests, run on the linked project.
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
summary: 'Run database tests (pgTAP)',
|
|
7
|
+
usage: 'motherbase test db [file.sql ...]',
|
|
8
|
+
subcommands: require('../migration/commands').testSubcommands,
|
|
9
|
+
};
|