@motherbase/cli 0.2.3 → 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 +4 -3
- 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 +32 -6
- package/src/commands/inspect.js +142 -5
- package/src/commands/keys.js +72 -0
- package/src/commands/local.js +226 -17
- package/src/commands/mcp.js +61 -0
- 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 -0
- package/src/mcp/storage-tools.js +93 -0
- package/src/mcp/tools.js +478 -0
- 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,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
|
+
};
|
package/src/commands/users.js
CHANGED
|
@@ -104,10 +104,39 @@ const subcommands = {
|
|
|
104
104
|
output(ctx, data, (d, ui) => ui.success(`Sessions revoked for ${id}`));
|
|
105
105
|
},
|
|
106
106
|
},
|
|
107
|
+
audit: {
|
|
108
|
+
summary: 'Read the auth audit log, newest first',
|
|
109
|
+
usage: 'motherbase users audit [--action <a>] [--actor <email|id>] [--type <log type>] [--from <iso>] [--to <iso>] [--limit <n>]',
|
|
110
|
+
async run(ctx, args, flags) {
|
|
111
|
+
const query = {
|
|
112
|
+
action: flags.action,
|
|
113
|
+
actor: flags.actor,
|
|
114
|
+
log_type: flags.type,
|
|
115
|
+
from: flags.from,
|
|
116
|
+
to: flags.to,
|
|
117
|
+
per_page: flags.limit || 50,
|
|
118
|
+
};
|
|
119
|
+
const data = await api.adminRequest(ctx, 'GET', '/auth/audit-logs', { query });
|
|
120
|
+
const entries = ((data && data.entries) || []).map(e => ({
|
|
121
|
+
at: e.created_at,
|
|
122
|
+
action: e.payload.action,
|
|
123
|
+
actor: e.payload.actor_username || e.payload.actor_id,
|
|
124
|
+
ip: e.ip_address,
|
|
125
|
+
}));
|
|
126
|
+
output(ctx, data, (_d, ui) =>
|
|
127
|
+
ui.table(entries, [
|
|
128
|
+
{ key: 'at', label: 'AT' },
|
|
129
|
+
{ key: 'action', label: 'ACTION' },
|
|
130
|
+
{ key: 'actor', label: 'ACTOR' },
|
|
131
|
+
{ key: 'ip', label: 'IP' },
|
|
132
|
+
]),
|
|
133
|
+
);
|
|
134
|
+
},
|
|
135
|
+
},
|
|
107
136
|
};
|
|
108
137
|
|
|
109
138
|
module.exports = {
|
|
110
139
|
summary: 'Project auth users',
|
|
111
|
-
usage: 'motherbase users <list|get|create|update|delete|reset-password|promote|demote|revoke-sessions> ...',
|
|
140
|
+
usage: 'motherbase users <list|get|create|update|delete|reset-password|promote|demote|revoke-sessions|audit> ...',
|
|
112
141
|
subcommands,
|
|
113
142
|
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const api = require('../api');
|
|
4
|
+
const { output, requireArg } = require('./_util');
|
|
5
|
+
const { CliError } = require('../context');
|
|
6
|
+
|
|
7
|
+
const subcommands = {
|
|
8
|
+
list: {
|
|
9
|
+
summary: 'List Vault secrets (names and metadata, never values)',
|
|
10
|
+
usage: 'motherbase vault list',
|
|
11
|
+
async run(ctx) {
|
|
12
|
+
const data = await api.adminRequest(ctx, 'GET', '/vault');
|
|
13
|
+
const secrets = (data && data.secrets) || [];
|
|
14
|
+
output(ctx, data, (_d, ui) =>
|
|
15
|
+
ui.table(secrets, [
|
|
16
|
+
{ key: 'id', label: 'ID' },
|
|
17
|
+
{ key: 'name', label: 'NAME' },
|
|
18
|
+
{ key: 'description', label: 'DESCRIPTION' },
|
|
19
|
+
{ key: 'updatedAt', label: 'UPDATED' },
|
|
20
|
+
]),
|
|
21
|
+
);
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
enable: {
|
|
25
|
+
summary: 'Install the Vault for this project',
|
|
26
|
+
usage: 'motherbase vault enable',
|
|
27
|
+
async run(ctx) {
|
|
28
|
+
const data = await api.adminRequest(ctx, 'POST', '/vault/enable');
|
|
29
|
+
output(ctx, data, (_d, ui) => ui.success('Vault enabled'));
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
create: {
|
|
33
|
+
summary: 'Store an encrypted secret',
|
|
34
|
+
usage: 'motherbase vault create <name> --secret <value> [--description <text>]',
|
|
35
|
+
async run(ctx, args, flags) {
|
|
36
|
+
const name = requireArg(args[0], 'name');
|
|
37
|
+
if (typeof flags.secret !== 'string') throw new CliError('--secret is required');
|
|
38
|
+
const body = { name, secret: flags.secret };
|
|
39
|
+
if (typeof flags.description === 'string') body.description = flags.description;
|
|
40
|
+
const data = await api.adminRequest(ctx, 'POST', '/vault/secrets', { body });
|
|
41
|
+
output(ctx, data, (_d, ui) => ui.success(`Created secret ${name}`));
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
update: {
|
|
45
|
+
summary: 'Rename a secret, change its value or its description',
|
|
46
|
+
usage: 'motherbase vault update <id> [--name <n>] [--secret <value>] [--description <text>]',
|
|
47
|
+
async run(ctx, args, flags) {
|
|
48
|
+
const id = requireArg(args[0], 'id');
|
|
49
|
+
const body = {};
|
|
50
|
+
for (const key of ['name', 'secret', 'description']) {
|
|
51
|
+
if (typeof flags[key] === 'string') body[key] = flags[key];
|
|
52
|
+
}
|
|
53
|
+
if (!Object.keys(body).length) throw new CliError('nothing to update: pass --name, --secret or --description');
|
|
54
|
+
const data = await api.adminRequest(ctx, 'PATCH', `/vault/secrets/${encodeURIComponent(id)}`, { body });
|
|
55
|
+
output(ctx, data, (_d, ui) => ui.success(`Updated secret ${id}`));
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
reveal: {
|
|
59
|
+
summary: 'Print the decrypted value of a secret',
|
|
60
|
+
usage: 'motherbase vault reveal <id>',
|
|
61
|
+
async run(ctx, args) {
|
|
62
|
+
const id = requireArg(args[0], 'id');
|
|
63
|
+
const data = await api.adminRequest(ctx, 'GET', `/vault/secrets/${encodeURIComponent(id)}/reveal`);
|
|
64
|
+
output(ctx, data, (d, ui) => ui.print(String((d && d.secret) ?? '')));
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
delete: {
|
|
68
|
+
summary: 'Delete a secret',
|
|
69
|
+
usage: 'motherbase vault delete <id>',
|
|
70
|
+
async run(ctx, args) {
|
|
71
|
+
const id = requireArg(args[0], 'id');
|
|
72
|
+
const data = await api.adminRequest(ctx, 'DELETE', `/vault/secrets/${encodeURIComponent(id)}`);
|
|
73
|
+
output(ctx, data, (_d, ui) => ui.success(`Deleted secret ${id}`));
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
summary: 'Encrypted secrets (Vault)',
|
|
80
|
+
usage: 'motherbase vault <list|enable|create|update|reveal|delete> ...',
|
|
81
|
+
subcommands,
|
|
82
|
+
};
|