@motherbase/cli 0.2.1

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 ADDED
@@ -0,0 +1,110 @@
1
+ # Motherbase CLI
2
+
3
+ A Supabase-CLI-like operator CLI for **Motherbase**. It drives both the
4
+ **Platform Console** (account + multi-project lifecycle) and a **linked project's
5
+ admin API** through the Console proxy — or a single project API directly.
6
+
7
+ Zero runtime dependencies. Node ≥ 18 (uses built-in `fetch`, `readline`, `fs`,
8
+ `child_process` only).
9
+
10
+ ```bash
11
+ node bin/motherbase.js --help # auto-generated help
12
+ npm link # or: npm i -g @motherbase/cli
13
+ motherbase login --url https://console.example.com
14
+ motherbase link # pick a project → writes ./.motherbase/config.json
15
+ motherbase db tables
16
+ motherbase functions deploy --name hello --route hello --file ./hello.js
17
+ ```
18
+
19
+ ## Connection model
20
+
21
+ 1. **Console / account** — `motherbase login [--url <consoleUrl>]` performs a real
22
+ Console login (`POST /api/platform/login`) and stores the session token.
23
+ 2. **Linked project** — `motherbase link [project]` records which project this
24
+ directory targets. All resource commands then route through the Console proxy
25
+ (`/api/projects/:id/admin/*`).
26
+ 3. **Direct mode (CI / single stack)** — set `MOTHERBASE_URL` +
27
+ `MOTHERBASE_ADMIN_TOKEN` to talk straight to one project's `/api/admin/*`.
28
+
29
+ ### Configuration files
30
+
31
+ **Global credentials** — `~/.motherbase/config.json` (contains tokens, written `0o600`):
32
+
33
+ ```json
34
+ {
35
+ "consoles": {
36
+ "https://console.example.com": { "token": "…", "email": "you@example.com" }
37
+ },
38
+ "current": "https://console.example.com"
39
+ }
40
+ ```
41
+
42
+ **Project link** — `./.motherbase/config.json` (versionable, non-secret):
43
+
44
+ ```json
45
+ { "consoleUrl": "https://console.example.com", "projectId": "my-app", "projectName": "my-app" }
46
+ ```
47
+
48
+ ### Resolution precedence
49
+
50
+ `flags > env > config`, per value:
51
+
52
+ | Value | Flag | Env | Config |
53
+ | ----------- | ----------------- | ------------------------- | ------ |
54
+ | console URL | `--url/-u` | `MOTHERBASE_CONSOLE_URL` | link / global `current` |
55
+ | token | `--token` | `MOTHERBASE_TOKEN` | global console entry |
56
+ | project | `--project/-p` | `MOTHERBASE_PROJECT` | link |
57
+ | direct API | — | `MOTHERBASE_URL` + `MOTHERBASE_ADMIN_TOKEN` | — |
58
+
59
+ ### Auth headers
60
+
61
+ The console session token is sent as `Authorization: Bearer <token>` on every
62
+ request. Console requests hit `/api/platform/*`; admin requests hit the proxy at
63
+ `/api/projects/:id/admin/*` (which validates the session, then injects its own
64
+ `X-Platform-Token` to the project). In direct mode the admin token is the Bearer.
65
+
66
+ ## Commands
67
+
68
+ Global flags: `--json`, `--yes/-y`, `--project/-p <id>`, `--url/-u <consoleUrl>`,
69
+ `--token <t>`, `-h/--help`, `-v/--version`.
70
+
71
+ - **Connection**: `login`, `logout`, `whoami`, `link`, `unlink`, `status`
72
+ - **Projects (Console)**: `projects list|create|start|stop|restart|update|rebuild|delete|logs|info|limits`
73
+ - **Local self-host** (docker compose): `init`, `start`, `stop`, `deploy`, `logs`
74
+ - **Database**: `db tables|sql|dump|restore|backups`
75
+ - **Migrations**: `migrations list|diff|get|create|apply|rollback`, `migration new`
76
+ - **Import**: `import --from-supabase <conn> [--preview] [--no-data] [--no-auth]`
77
+ - **RLS**: `rls list|enable|disable`, `rls policy create|delete`
78
+ - **Users**: `users list|get|create|update|delete|reset-password|promote|demote|revoke-sessions`
79
+ - **Storage**: `storage buckets list|create|delete`, `storage ls|cp|rm`
80
+ - **Functions**: `functions list|get|new|deploy|delete|invoke|logs`
81
+ - **Secrets**: `secrets list|set|unset`
82
+ - **Realtime**: `realtime config|channels`
83
+ - **Webhooks**: `webhooks list|create|delete|test`
84
+ - **Cron**: `cron list|create|delete|toggle`
85
+ - **Config**: `config list|get|set`
86
+ - **Inspect**: `inspect logs|stats|performance|requests|advisor|shield`
87
+
88
+ Run `motherbase <group> -h` for per-group usage.
89
+
90
+ ## Supabase migration
91
+
92
+ Preview a Supabase import without writing to Motherbase:
93
+
94
+ ```bash
95
+ motherbase import --from-supabase "$SUPABASE_DB_URL" --preview --json
96
+ ```
97
+
98
+ Run the import:
99
+
100
+ ```bash
101
+ motherbase import --from-supabase "$SUPABASE_DB_URL" --yes
102
+ ```
103
+
104
+ The importer moves the portable parts of a Supabase project: `public` schema, table data, RLS policies, and email/password users from `auth.users`. Storage objects are not copied. Use `--no-data` or `--no-auth` for partial imports.
105
+
106
+ ## Tests
107
+
108
+ ```bash
109
+ npm test # node --test, mocked fetch, no network/DB
110
+ ```
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { main } = require('../src/main');
5
+
6
+ main(process.argv.slice(2))
7
+ .then(code => { process.exitCode = code || 0; })
8
+ .catch(err => {
9
+ process.stderr.write((err && err.message ? err.message : String(err)) + '\n');
10
+ process.exitCode = 1;
11
+ });
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@motherbase/cli",
3
+ "version": "0.2.1",
4
+ "description": "Motherbase operator CLI — drives the Platform Console and linked projects.",
5
+ "type": "commonjs",
6
+ "bin": {
7
+ "motherbase": "./bin/motherbase.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "scripts": {
13
+ "test": "node --test"
14
+ },
15
+ "files": [
16
+ "bin",
17
+ "src",
18
+ "README.md"
19
+ ],
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "license": "MIT"
24
+ }
package/src/api.js ADDED
@@ -0,0 +1,200 @@
1
+ 'use strict';
2
+
3
+ // The one and only HTTP layer. Every command routes through here so auth headers,
4
+ // URL construction and error mapping live in a single place.
5
+ //
6
+ // URL shapes:
7
+ // console request -> ${consoleUrl}/api/platform${path}
8
+ // admin (console mode) -> ${consoleUrl}/api/projects/${projectId}/admin${path}
9
+ // admin (direct mode) -> ${directUrl}/api/admin${path}
10
+ // edge invoke (console)-> ${consoleUrl}/api/projects/${projectId}/edge/${route}
11
+ // edge invoke (direct) -> ${directUrl}/api/edge/${route}
12
+ //
13
+ // Auth: the console session token is sent as `Authorization: Bearer <token>`.
14
+ // Success envelopes differ across surfaces; both wrap payload under `data`, so we
15
+ // unwrap `body.data` when present. Non-2xx becomes a thrown CliError with the
16
+ // status and a short message/body snippet.
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const { Readable } = require('stream');
21
+ const { CliError } = require('./context');
22
+
23
+ function buildQuery(query) {
24
+ if (!query) return '';
25
+ const params = new URLSearchParams();
26
+ for (const [k, v] of Object.entries(query)) {
27
+ if (v === undefined || v === null) continue;
28
+ params.append(k, String(v));
29
+ }
30
+ const s = params.toString();
31
+ return s ? `?${s}` : '';
32
+ }
33
+
34
+ function unwrap(body) {
35
+ if (body && typeof body === 'object' && 'data' in body) return body.data;
36
+ return body;
37
+ }
38
+
39
+ function errorMessage(status, body, text) {
40
+ const snippet = (body && (body.message || body.error)) || (text ? text.slice(0, 300) : '');
41
+ if (status === 401) {
42
+ return `Unauthorized (401)${snippet ? `: ${snippet}` : ''}. Run \`motherbase login\` (or check --token).`;
43
+ }
44
+ if (status === 403) {
45
+ return `Forbidden (403)${snippet ? `: ${snippet}` : ''}.`;
46
+ }
47
+ if (status === 404) {
48
+ return `Not found (404)${snippet ? `: ${snippet}` : ''}.`;
49
+ }
50
+ return `Request failed (${status})${snippet ? `: ${snippet}` : ''}`;
51
+ }
52
+
53
+ async function parseResponse(res) {
54
+ const text = await res.text();
55
+ let body;
56
+ if (text) {
57
+ try { body = JSON.parse(text); } catch { body = undefined; }
58
+ }
59
+ if (!res.ok) {
60
+ throw new CliError(errorMessage(res.status, body, text));
61
+ }
62
+ return body === undefined ? text : unwrap(body);
63
+ }
64
+
65
+ async function doFetch(url, { method = 'GET', token, body, headers = {} } = {}) {
66
+ const finalHeaders = { ...headers };
67
+ if (token) finalHeaders['authorization'] = `Bearer ${token}`;
68
+ let payload;
69
+ if (body !== undefined && body !== null) {
70
+ finalHeaders['content-type'] = 'application/json';
71
+ payload = JSON.stringify(body);
72
+ }
73
+ let res;
74
+ try {
75
+ res = await fetch(url, { method, headers: finalHeaders, body: payload });
76
+ } catch (err) {
77
+ throw new CliError(`Cannot reach ${url}: ${err.message}`);
78
+ }
79
+ return parseResponse(res);
80
+ }
81
+
82
+ // ── Console (account / multi-project) ────────────────────────────────────────
83
+
84
+ function consoleRequest(ctx, method, apiPath, { body, query } = {}) {
85
+ const { consoleUrl, token } = ctx.requireConsole();
86
+ const url = `${consoleUrl}/api/platform${apiPath}${buildQuery(query)}`;
87
+ return doFetch(url, { method, token, body });
88
+ }
89
+
90
+ // Login is special: no token yet. Uses Better Auth's native sign-in endpoint;
91
+ // the session token comes back in the body and/or the `set-auth-token` header
92
+ // (bearer plugin). Returns `{ token, user }` so the caller can persist it.
93
+ async function loginRequest(consoleUrl, email, password) {
94
+ const url = `${consoleUrl}/api/auth/sign-in/email`;
95
+ let res;
96
+ try {
97
+ res = await fetch(url, {
98
+ method: 'POST',
99
+ headers: { 'content-type': 'application/json' },
100
+ body: JSON.stringify({ email, password }),
101
+ });
102
+ } catch (err) {
103
+ throw new CliError(`Cannot reach ${url}: ${err.message}`);
104
+ }
105
+ const text = await res.text();
106
+ let body;
107
+ if (text) { try { body = JSON.parse(text); } catch { body = undefined; } }
108
+ if (!res.ok) {
109
+ throw new CliError(errorMessage(res.status, body, text));
110
+ }
111
+ const token = (body && body.token) || res.headers.get('set-auth-token') || undefined;
112
+ return { token, user: body && body.user };
113
+ }
114
+
115
+ // ── Admin / resource (via proxy or direct) ───────────────────────────────────
116
+
117
+ function adminBase(target) {
118
+ if (target.mode === 'direct') {
119
+ return { base: `${target.baseUrl}/api/admin`, token: target.token };
120
+ }
121
+ return { base: `${target.consoleUrl}/api/projects/${target.projectId}/admin`, token: target.token };
122
+ }
123
+
124
+ function adminRequest(ctx, method, apiPath, { body, query } = {}) {
125
+ const target = ctx.requireProject();
126
+ const { base, token } = adminBase(target);
127
+ const url = `${base}${apiPath}${buildQuery(query)}`;
128
+ return doFetch(url, { method, token, body });
129
+ }
130
+
131
+ // Edge invoke goes through the un-authenticated edge route, not /admin.
132
+ function edgeInvokeRequest(ctx, route, body) {
133
+ const target = ctx.requireProject();
134
+ const url = target.mode === 'direct'
135
+ ? `${target.baseUrl}/api/edge/${encodeURIComponent(route)}`
136
+ : `${target.consoleUrl}/api/projects/${target.projectId}/edge/${encodeURIComponent(route)}`;
137
+ return doFetch(url, { method: 'POST', token: target.token, body: body || {} });
138
+ }
139
+
140
+ // ── Multipart upload (native FormData/Blob; zero-dep on Node >=18) ────────────
141
+
142
+ async function uploadFile(ctx, apiPath, filePath, fields = {}) {
143
+ const target = ctx.requireProject();
144
+ const { base, token } = adminBase(target);
145
+ const url = `${base}${apiPath}`;
146
+ const buf = fs.readFileSync(filePath);
147
+ const form = new FormData();
148
+ form.append('file', new Blob([buf]), path.basename(filePath));
149
+ for (const [k, v] of Object.entries(fields)) {
150
+ if (v !== undefined && v !== null) form.append(k, String(v));
151
+ }
152
+ let res;
153
+ try {
154
+ res = await fetch(url, {
155
+ method: 'POST',
156
+ headers: token ? { authorization: `Bearer ${token}` } : {},
157
+ body: form,
158
+ });
159
+ } catch (err) {
160
+ throw new CliError(`Cannot reach ${url}: ${err.message}`);
161
+ }
162
+ return parseResponse(res);
163
+ }
164
+
165
+ // ── Streamed download to a local file ────────────────────────────────────────
166
+
167
+ async function downloadFile(ctx, apiPath, destPath, { query } = {}) {
168
+ const target = ctx.requireProject();
169
+ const { base, token } = adminBase(target);
170
+ const url = `${base}${apiPath}${buildQuery(query)}`;
171
+ let res;
172
+ try {
173
+ res = await fetch(url, { headers: token ? { authorization: `Bearer ${token}` } : {} });
174
+ } catch (err) {
175
+ throw new CliError(`Cannot reach ${url}: ${err.message}`);
176
+ }
177
+ if (!res.ok) {
178
+ const text = await res.text().catch(() => '');
179
+ let body; try { body = JSON.parse(text); } catch { /* noop */ }
180
+ throw new CliError(errorMessage(res.status, body, text));
181
+ }
182
+ await new Promise((resolve, reject) => {
183
+ const out = fs.createWriteStream(destPath);
184
+ out.on('error', reject);
185
+ out.on('finish', resolve);
186
+ Readable.fromWeb(res.body).pipe(out);
187
+ });
188
+ return { path: destPath, contentType: res.headers.get('content-type') || undefined };
189
+ }
190
+
191
+ module.exports = {
192
+ consoleRequest,
193
+ loginRequest,
194
+ adminRequest,
195
+ edgeInvokeRequest,
196
+ uploadFile,
197
+ downloadFile,
198
+ buildQuery,
199
+ unwrap,
200
+ };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ // Small shared helpers for command modules.
4
+
5
+ const { CliError } = require('../context');
6
+
7
+ // Parse `key=value` tokens into an object. Values that look like numbers/bools are
8
+ // coerced unless `raw` is set. Supports dotted keys via `--json`-style callers.
9
+ function parseKeyValues(tokens, { coerce = true } = {}) {
10
+ const out = {};
11
+ for (const tok of tokens) {
12
+ const idx = tok.indexOf('=');
13
+ if (idx <= 0) throw new CliError(`expected key=value, got "${tok}"`);
14
+ const key = tok.slice(0, idx).trim();
15
+ let value = tok.slice(idx + 1);
16
+ if (coerce) {
17
+ if (value === 'true') value = true;
18
+ else if (value === 'false') value = false;
19
+ else if (value !== '' && !Number.isNaN(Number(value)) && /^-?\d+(\.\d+)?$/.test(value)) value = Number(value);
20
+ }
21
+ out[key] = value;
22
+ }
23
+ return out;
24
+ }
25
+
26
+ // Emit either raw JSON (when --json) or run the human renderer.
27
+ function output(ctx, data, humanFn) {
28
+ const ui = require('../ui');
29
+ if (ctx.flags.json) {
30
+ ui.json(data);
31
+ return;
32
+ }
33
+ humanFn(data, ui);
34
+ }
35
+
36
+ function requireArg(value, name) {
37
+ if (value === undefined || value === null || value === '') {
38
+ throw new CliError(`missing required argument: <${name}>`);
39
+ }
40
+ return value;
41
+ }
42
+
43
+ // Split "bucket:path" into { bucket, rest }.
44
+ function splitRef(ref) {
45
+ const idx = ref.indexOf(':');
46
+ if (idx < 0) return { bucket: undefined, rest: ref };
47
+ return { bucket: ref.slice(0, idx), rest: ref.slice(idx + 1) };
48
+ }
49
+
50
+ module.exports = { parseKeyValues, output, requireArg, splitRef };
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ const api = require('../api');
4
+ const { output, requireArg, parseKeyValues } = require('./_util');
5
+
6
+ const subcommands = {
7
+ list: {
8
+ summary: 'List setting groups',
9
+ usage: 'motherbase config list',
10
+ async run(ctx) {
11
+ const data = await api.adminRequest(ctx, 'GET', '/settings');
12
+ const groups = (data && data.groups) || [];
13
+ output(ctx, data, (_d, ui) =>
14
+ ui.table(groups, [
15
+ { key: 'key', label: 'GROUP' },
16
+ { key: 'label', label: 'LABEL' },
17
+ { key: 'source', label: 'SOURCE' },
18
+ ]),
19
+ );
20
+ },
21
+ },
22
+ get: {
23
+ summary: 'Get a settings group',
24
+ usage: 'motherbase config get <group>',
25
+ async run(ctx, args) {
26
+ const group = requireArg(args[0], 'group');
27
+ const data = await api.adminRequest(ctx, 'GET', `/settings/${group}`);
28
+ output(ctx, data, (d, ui) => ui.keyValue(d || {}));
29
+ },
30
+ },
31
+ set: {
32
+ summary: 'Set values in a settings group',
33
+ usage: 'motherbase config set <group> key=value [key2=value2 ...]',
34
+ async run(ctx, args) {
35
+ const group = requireArg(args[0], 'group');
36
+ const body = parseKeyValues(args.slice(1));
37
+ const data = await api.adminRequest(ctx, 'PUT', `/settings/${group}`, { body });
38
+ output(ctx, data, (d, ui) => ui.success(`Saved settings for ${group}`));
39
+ },
40
+ },
41
+ };
42
+
43
+ module.exports = {
44
+ summary: 'Project settings groups',
45
+ usage: 'motherbase config <list|get|set> ...',
46
+ subcommands,
47
+ };
@@ -0,0 +1,65 @@
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 pg_cron jobs (and app jobs with --app)',
10
+ usage: 'motherbase cron list [--app]',
11
+ async run(ctx, args, flags) {
12
+ if (flags.app) {
13
+ const data = await api.adminRequest(ctx, 'GET', '/cron/app/jobs');
14
+ const jobs = (data && data.jobs) || [];
15
+ output(ctx, data, (_d, ui) => ui.table(jobs, [{ key: 'name', label: 'NAME' }, { key: 'schedule', label: 'SCHEDULE' }]));
16
+ return;
17
+ }
18
+ const data = await api.adminRequest(ctx, 'GET', '/cron/pg/jobs');
19
+ const jobs = Array.isArray(data) ? data : (data && data.jobs) || [];
20
+ output(ctx, data, (_d, ui) =>
21
+ ui.table(jobs, [
22
+ { key: 'jobid', label: 'ID' },
23
+ { key: 'jobname', label: 'NAME' },
24
+ { key: 'schedule', label: 'SCHEDULE' },
25
+ { key: 'active', label: 'ACTIVE' },
26
+ ]),
27
+ );
28
+ },
29
+ },
30
+ create: {
31
+ summary: 'Create a pg_cron job',
32
+ usage: 'motherbase cron create --name <n> --schedule "<cron>" --command "<sql>"',
33
+ async run(ctx, args, flags) {
34
+ if (!flags.name || !flags.schedule || !flags.command) throw new CliError('--name, --schedule and --command are required');
35
+ const body = { name: flags.name, schedule: flags.schedule, command: flags.command };
36
+ const data = await api.adminRequest(ctx, 'POST', '/cron/pg/jobs', { body });
37
+ output(ctx, data, (d, ui) => ui.success(`Created cron job ${flags.name}`));
38
+ },
39
+ },
40
+ delete: {
41
+ summary: 'Delete a pg_cron job by numeric id',
42
+ usage: 'motherbase cron delete <id>',
43
+ async run(ctx, args) {
44
+ const id = requireArg(args[0], 'id');
45
+ const data = await api.adminRequest(ctx, 'DELETE', `/cron/pg/jobs/${id}`);
46
+ output(ctx, data, (d, ui) => ui.success(`Deleted cron job ${id}`));
47
+ },
48
+ },
49
+ toggle: {
50
+ summary: 'Enable/disable a pg_cron job',
51
+ usage: 'motherbase cron toggle <id> --active <true|false>',
52
+ async run(ctx, args, flags) {
53
+ const id = requireArg(args[0], 'id');
54
+ const active = flags.active === undefined ? true : flags.active === 'true' || flags.active === true;
55
+ const data = await api.adminRequest(ctx, 'PATCH', `/cron/pg/jobs/${id}`, { body: { active } });
56
+ output(ctx, data, (d, ui) => ui.success(`Cron job ${id} active=${active}`));
57
+ },
58
+ },
59
+ };
60
+
61
+ module.exports = {
62
+ summary: 'Scheduled jobs (pg_cron)',
63
+ usage: 'motherbase cron <list|create|delete|toggle> ...',
64
+ subcommands,
65
+ };
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const api = require('../api');
6
+ const prompt = require('../prompt');
7
+ const { output, parseKeyValues } = require('./_util');
8
+ const { CliError } = require('../context');
9
+
10
+ const subcommands = {
11
+ tables: {
12
+ summary: 'List database tables',
13
+ usage: 'motherbase db tables',
14
+ async run(ctx) {
15
+ const data = await api.adminRequest(ctx, 'GET', '/schema/tables');
16
+ const tables = (data && data.tables) || data || [];
17
+ output(ctx, data, (_d, ui) =>
18
+ ui.table(Array.isArray(tables) ? tables : [], [
19
+ { key: 'name', label: 'TABLE' },
20
+ { key: 'schema', label: 'SCHEMA' },
21
+ ]),
22
+ );
23
+ },
24
+ },
25
+ sql: {
26
+ summary: 'Run SQL from -e "..." or a file',
27
+ usage: 'motherbase db sql -e "select 1" | motherbase db sql <file.sql>',
28
+ async run(ctx, args, flags) {
29
+ let sql = flags.e || flags.execute;
30
+ if (!sql && args[0]) sql = fs.readFileSync(path.resolve(ctx.cwd, args[0]), 'utf8');
31
+ if (!sql) throw new CliError('Provide SQL via -e "..." or a file path.');
32
+ const data = await api.adminRequest(ctx, 'POST', '/sql', { body: { sql } });
33
+ output(ctx, data, (d, ui) => {
34
+ if (d && Array.isArray(d.rows)) ui.table(d.rows);
35
+ else if (Array.isArray(d)) ui.table(d);
36
+ else ui.json(d);
37
+ });
38
+ },
39
+ },
40
+ dump: {
41
+ summary: 'Create a database backup on the server',
42
+ usage: 'motherbase db dump [file]',
43
+ async run(ctx, args) {
44
+ const body = args[0] ? { file: args[0] } : {};
45
+ const data = await api.adminRequest(ctx, 'POST', '/backups', { body });
46
+ output(ctx, data, (d, ui) => ui.success(`Backup created${d && d.file ? `: ${d.file}` : ''}`));
47
+ },
48
+ },
49
+ restore: {
50
+ summary: 'Restore the database from a server dump or offsite key',
51
+ usage: 'motherbase db restore <file> | --key <remoteKey> --confirm',
52
+ async run(ctx, args, flags) {
53
+ const file = args[0];
54
+ const key = flags.key;
55
+ if (!file && !key) throw new CliError('Provide a <file> or --key <remoteKey>.');
56
+ if (!flags.confirm && !flags.yes) {
57
+ const ok = await prompt.confirm('This will overwrite the database. Continue?');
58
+ if (!ok) throw new CliError('Aborted.');
59
+ }
60
+ const body = key ? { key, confirm: 'RESTORE' } : { file, confirm: 'RESTORE' };
61
+ const data = await api.adminRequest(ctx, 'POST', '/backups/restore', { body });
62
+ output(ctx, data, (d, ui) => ui.success('Restore complete'));
63
+ },
64
+ },
65
+ backups: {
66
+ summary: 'List backups, or manage backup config (`db backups config`)',
67
+ usage: 'motherbase db backups [list] | motherbase db backups config [--set key=value ...]',
68
+ async run(ctx, args, flags) {
69
+ const sub = args[0];
70
+ if (sub === 'config') {
71
+ const sets = [].concat(flags.set || []).filter(Boolean);
72
+ if (sets.length) {
73
+ const body = parseKeyValues(sets);
74
+ const data = await api.adminRequest(ctx, 'PUT', '/backups/config', { body });
75
+ output(ctx, data, (d, ui) => ui.success('Backup config saved'));
76
+ } else {
77
+ const data = await api.adminRequest(ctx, 'GET', '/backups/config');
78
+ output(ctx, data, (d, ui) => ui.keyValue(d || {}));
79
+ }
80
+ return;
81
+ }
82
+ const data = await api.adminRequest(ctx, 'GET', '/backups/list');
83
+ output(ctx, data, (d, ui) => ui.json(d));
84
+ },
85
+ },
86
+ };
87
+
88
+ module.exports = {
89
+ summary: 'Database: tables, SQL, dumps and restores',
90
+ usage: 'motherbase db <tables|sql|dump|restore|backups> ...',
91
+ subcommands,
92
+ };