@motherbase/cli 0.2.5 → 0.2.6

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +247 -247
  3. package/bin/motherbase.js +10 -10
  4. package/package.json +35 -26
  5. package/src/api.js +233 -241
  6. package/src/commands/_util.js +106 -93
  7. package/src/commands/backups.js +16 -16
  8. package/src/commands/branches.js +113 -113
  9. package/src/commands/completion.js +90 -90
  10. package/src/commands/config.js +47 -47
  11. package/src/commands/cron.js +65 -65
  12. package/src/commands/db-catalog.js +326 -326
  13. package/src/commands/db.js +103 -103
  14. package/src/commands/domains.js +124 -124
  15. package/src/commands/functions.js +304 -297
  16. package/src/commands/gen.js +172 -172
  17. package/src/commands/import.js +55 -55
  18. package/src/commands/index.js +85 -85
  19. package/src/commands/inspect.js +178 -178
  20. package/src/commands/keys.js +72 -72
  21. package/src/commands/link.js +35 -35
  22. package/src/commands/local.js +294 -273
  23. package/src/commands/login.js +39 -39
  24. package/src/commands/logout.js +16 -16
  25. package/src/commands/mcp.js +61 -61
  26. package/src/commands/migrate.js +207 -179
  27. package/src/commands/migration.js +108 -108
  28. package/src/commands/network.js +83 -83
  29. package/src/commands/orgs.js +40 -40
  30. package/src/commands/postgres-config.js +113 -113
  31. package/src/commands/projects.js +130 -130
  32. package/src/commands/queues.js +133 -133
  33. package/src/commands/realtime.js +35 -35
  34. package/src/commands/rls.js +176 -176
  35. package/src/commands/secrets.js +66 -66
  36. package/src/commands/signing.js +130 -130
  37. package/src/commands/snippets.js +44 -44
  38. package/src/commands/status.js +42 -42
  39. package/src/commands/storage.js +261 -261
  40. package/src/commands/test.js +9 -9
  41. package/src/commands/unlink.js +15 -15
  42. package/src/commands/users.js +142 -142
  43. package/src/commands/vault.js +82 -82
  44. package/src/commands/webhooks.js +62 -62
  45. package/src/commands/whoami.js +20 -20
  46. package/src/commands/wrappers.js +150 -150
  47. package/src/config.js +98 -98
  48. package/src/context.js +106 -102
  49. package/src/docs.js +68 -0
  50. package/src/edge-templates.js +194 -194
  51. package/src/format.js +92 -92
  52. package/src/inspect/csv.js +22 -22
  53. package/src/inspect/queries.js +562 -562
  54. package/src/main.js +224 -194
  55. package/src/mcp/platform-tools.js +233 -233
  56. package/src/mcp/protocol.js +152 -149
  57. package/src/mcp/storage-tools.js +93 -93
  58. package/src/mcp/tools.js +478 -478
  59. package/src/migration/commands.js +375 -375
  60. package/src/migration/files.js +245 -245
  61. package/src/prompt.js +91 -91
  62. package/src/storage/remote.js +62 -62
  63. package/src/ui.js +111 -111
@@ -1,375 +1,375 @@
1
- 'use strict';
2
-
3
- // The Supabase CLI's local-files migration workflow, against the linked project:
4
- //
5
- // migration list | up | down | repair | squash | fetch
6
- // db push | pull | diff | reset | dump | seed
7
- // test db
8
- //
9
- // Registered from commands/migration.js, commands/db.js and commands/test.js.
10
- // Files are read and written here (see ./files.js for the layout); the server
11
- // side is /api/admin/db (apps/server controllers/admin-db.controller.ts).
12
-
13
- const fs = require('fs');
14
- const path = require('path');
15
- const api = require('../api');
16
- const prompt = require('../prompt');
17
- const ui = require('../ui');
18
- const { output, requireArg } = require('../commands/_util');
19
- const { CliError } = require('../context');
20
- const files = require('./files');
21
-
22
- // ── Shared steps ─────────────────────────────────────────────────────────────
23
-
24
- async function remoteHistory(ctx) {
25
- const data = await api.adminRequest(ctx, 'GET', '/db/migrations');
26
- return (data && data.migrations) || [];
27
- }
28
-
29
- function readLocal(ctx) {
30
- const { migrations, skipped } = files.readLocalMigrations(ctx.cwd);
31
- for (const name of skipped) {
32
- ui.warn(`Skipping ${name}: migration files must be named <timestamp>_name.sql`);
33
- }
34
- return migrations;
35
- }
36
-
37
- const asPayload = file => ({ version: file.version, name: file.name, sql: file.sql, downSql: file.downSql });
38
- const label = file => `${file.version}_${file.name}`;
39
-
40
- async function reconciled(ctx) {
41
- const [local, remote] = [readLocal(ctx), await remoteHistory(ctx)];
42
- return { local, remote, rows: files.reconcile(local, remote) };
43
- }
44
-
45
- /** Apply every pending local file in version order, stopping at the first failure. */
46
- async function pushPending(ctx, _args, flags) {
47
- const { rows } = await reconciled(ctx);
48
- const plan = files.planPush(rows, { includeAll: Boolean(flags['include-all']) });
49
- if (plan.error) throw new CliError(plan.error);
50
-
51
- const seeds = flags['include-seed'] ? files.readSeeds(ctx.cwd) : [];
52
- const result = { dryRun: Boolean(flags['dry-run']), pending: plan.pending.map(label), applied: [], seeded: [] };
53
-
54
- if (result.dryRun) {
55
- output(ctx, result, () => {
56
- if (!plan.pending.length) ui.info('Remote database is up to date.');
57
- for (const file of plan.pending) ui.print(`Would push migration ${label(file)}`);
58
- for (const seed of seeds) ui.print(`Would seed data ${seed.path}`);
59
- });
60
- return;
61
- }
62
-
63
- for (const file of plan.pending) {
64
- if (!ctx.flags.json) ui.info(`Applying migration ${label(file)}...`);
65
- await api.adminRequest(ctx, 'POST', '/db/migrations', { body: asPayload(file) });
66
- result.applied.push(label(file));
67
- }
68
- if (seeds.length) {
69
- await api.adminRequest(ctx, 'POST', '/db/seed', { body: { files: seeds } });
70
- result.seeded = seeds.map(seed => seed.path);
71
- }
72
- output(ctx, result, () => {
73
- if (!result.applied.length) ui.info('Remote database is up to date.');
74
- else ui.success(`Applied ${result.applied.length} migration(s).`);
75
- if (result.seeded.length) ui.success(`Seeded ${result.seeded.join(', ')}`);
76
- });
77
- }
78
-
79
- /** Write a new local migration file under a fresh version and return it. */
80
- function writeMigration(ctx, name, sql) {
81
- const version = files.versionAt();
82
- const file = path.join(ctx.cwd, files.MIGRATIONS_DIR, files.fileNameFor(version, name));
83
- fs.mkdirSync(path.dirname(file), { recursive: true });
84
- fs.writeFileSync(file, files.renderFile(sql));
85
- return { version, name, file: path.relative(ctx.cwd, file) };
86
- }
87
-
88
- function refuseWithoutConfirm(flags, what) {
89
- if (!flags.confirm) throw new CliError(`${what} Rerun with --confirm to proceed.`);
90
- }
91
-
92
- const sqlFlag = (flags, args) => flags.file || (flags.f ? args[0] : undefined);
93
-
94
- // ── migration ────────────────────────────────────────────────────────────────
95
-
96
- const migrationSubcommands = {
97
- list: {
98
- summary: 'List local and remote migrations side by side',
99
- usage: 'motherbase migration list',
100
- async run(ctx) {
101
- const { rows } = await reconciled(ctx);
102
- const data = {
103
- migrations: rows.map(row => ({
104
- version: row.version,
105
- name: row.name,
106
- local: Boolean(row.local),
107
- remote: row.remote ? (row.applied ? 'applied' : 'reverted') : null,
108
- origin: row.remote ? row.remote.origin : 'local',
109
- appliedAt: row.applied ? row.remote.appliedAt : null,
110
- })),
111
- };
112
- output(ctx, data, () =>
113
- ui.table(
114
- data.migrations.map(m => ({
115
- ...m,
116
- local: m.local ? m.version : '',
117
- remote: m.remote === 'applied' ? m.version : m.remote === 'reverted' ? `(${m.version} reverted)` : '',
118
- appliedAt: m.appliedAt ? new Date(m.appliedAt).toISOString().replace('T', ' ').slice(0, 19) : '',
119
- })),
120
- [
121
- { key: 'local', label: 'LOCAL' },
122
- { key: 'remote', label: 'REMOTE' },
123
- { key: 'appliedAt', label: 'TIME (UTC)' },
124
- { key: 'origin', label: 'ORIGIN' },
125
- { key: 'name', label: 'NAME' },
126
- ],
127
- ),
128
- );
129
- },
130
- },
131
-
132
- up: {
133
- summary: 'Apply pending local migrations to the linked project',
134
- usage: 'motherbase migration up [--include-all] [--dry-run]',
135
- run: (ctx, args, flags) => pushPending(ctx, args, { ...flags, 'include-seed': false }),
136
- },
137
-
138
- down: {
139
- summary: 'Roll back the last n applied migrations with their down SQL',
140
- usage: 'motherbase migration down [--last <n>] [--yes]',
141
- async run(ctx, _args, flags) {
142
- const last = flags.last === undefined ? 1 : Number(flags.last);
143
- if (!Number.isInteger(last) || last < 1) throw new CliError('--last must be a positive integer');
144
- const applied = (await remoteHistory(ctx)).filter(entry => entry.status === 'applied').reverse().slice(0, last);
145
- if (!applied.length) throw new CliError('No applied migration to roll back.');
146
-
147
- if (!flags.yes) {
148
- const ok = await prompt.confirm(`Roll back ${applied.map(label).join(', ')} on the linked project?`);
149
- if (!ok) throw new CliError('Aborted.');
150
- }
151
- const reverted = [];
152
- for (const entry of applied) {
153
- if (!entry.downSql) {
154
- throw new CliError(`${label(entry)} has no down SQL (a "-- @down" section), so it cannot be rolled back.${reverted.length ? ` Rolled back: ${reverted.join(', ')}.` : ''}`);
155
- }
156
- await api.adminRequest(ctx, 'POST', `/migrations/${encodeURIComponent(entry.id)}/rollback`);
157
- reverted.push(label(entry));
158
- }
159
- output(ctx, { reverted }, () => ui.success(`Rolled back ${reverted.join(', ')}`));
160
- },
161
- },
162
-
163
- repair: {
164
- summary: 'Mark migrations applied or reverted in the remote history, without running them',
165
- usage: 'motherbase migration repair <version...> --status applied|reverted',
166
- async run(ctx, args, flags) {
167
- const status = flags.status;
168
- if (status !== 'applied' && status !== 'reverted') throw new CliError('--status must be applied or reverted');
169
- requireArg(args[0], 'version');
170
- const local = new Map(readLocal(ctx).map(file => [file.version, file]));
171
- const repaired = [];
172
- for (const version of args) {
173
- const file = local.get(version);
174
- // Recording a file as applied records what it contains, so a later
175
- // `migration fetch` writes back the same SQL.
176
- const body = status === 'applied' && file ? { status, ...asPayload(file) } : { status };
177
- delete body.version;
178
- await api.adminRequest(ctx, 'POST', `/db/migrations/${encodeURIComponent(version)}/repair`, { body });
179
- repaired.push(version);
180
- }
181
- output(ctx, { status, repaired }, () => ui.success(`Repaired migration history: ${repaired.join(', ')} => ${status}`));
182
- },
183
- },
184
-
185
- squash: {
186
- summary: 'Squash local migrations into one file',
187
- usage: 'motherbase migration squash [--version <v>] [--linked]',
188
- async run(ctx, _args, flags) {
189
- const all = readLocal(ctx);
190
- const upTo = flags.version ? String(flags.version) : null;
191
- const chosen = upTo ? all.filter(file => file.version <= upTo) : all;
192
- const result = files.squash(chosen);
193
-
194
- const kept = path.join(ctx.cwd, files.MIGRATIONS_DIR, files.fileNameFor(result.version, result.name));
195
- for (const file of chosen) fs.rmSync(file.file);
196
- fs.writeFileSync(kept, files.renderFile(result.sql, result.downSql));
197
-
198
- // The remote history still lists every squashed version. With --linked it
199
- // is rewritten to match: the old versions reverted, the kept one recorded
200
- // with the squashed SQL. Nothing runs against the schema.
201
- if (flags.linked) {
202
- const applied = new Set((await remoteHistory(ctx)).filter(e => e.status === 'applied').map(e => e.version));
203
- for (const file of chosen) {
204
- if (file.version !== result.version && applied.has(file.version)) {
205
- await api.adminRequest(ctx, 'POST', `/db/migrations/${file.version}/repair`, { body: { status: 'reverted' } });
206
- }
207
- }
208
- if (applied.has(result.version)) {
209
- await api.adminRequest(ctx, 'POST', `/db/migrations/${result.version}/repair`, {
210
- body: { status: 'applied', name: result.name, sql: result.sql, downSql: result.downSql },
211
- });
212
- }
213
- }
214
- output(ctx, { file: path.relative(ctx.cwd, kept), replaced: result.replaced, reversible: result.reversible }, d => {
215
- ui.success(`Squashed ${d.replaced.length} migrations into ${d.file}`);
216
- if (!d.reversible) ui.warn('Not every squashed file had a down section, so the result has none.');
217
- if (!flags.linked) ui.info('The remote history is unchanged. Rerun with --linked, or repair it, to match.');
218
- });
219
- },
220
- },
221
-
222
- fetch: {
223
- summary: 'Write the remote migration history into local files',
224
- usage: 'motherbase migration fetch [--force]',
225
- async run(ctx, _args, flags) {
226
- const local = new Map(readLocal(ctx).map(file => [file.version, file]));
227
- const dir = path.join(ctx.cwd, files.MIGRATIONS_DIR);
228
- fs.mkdirSync(dir, { recursive: true });
229
- const written = [];
230
- const kept = [];
231
- for (const entry of (await remoteHistory(ctx)).filter(e => e.status === 'applied')) {
232
- const existing = local.get(entry.version);
233
- if (existing && !flags.force) { kept.push(path.basename(existing.file)); continue; }
234
- if (existing) fs.rmSync(existing.file);
235
- const name = files.fileNameFor(entry.version, entry.name);
236
- fs.writeFileSync(path.join(dir, name), files.renderFile(entry.sql, entry.downSql));
237
- written.push(name);
238
- }
239
- output(ctx, { written, kept }, d => {
240
- if (!d.written.length) ui.info('Local migrations already cover the remote history.');
241
- for (const name of d.written) ui.success(`Wrote ${path.join(files.MIGRATIONS_DIR, name)}`);
242
- if (d.kept.length) ui.info(`Kept ${d.kept.length} existing file(s); --force overwrites them.`);
243
- });
244
- },
245
- },
246
- };
247
-
248
- // ── db ───────────────────────────────────────────────────────────────────────
249
-
250
- const dbSubcommands = {
251
- push: {
252
- summary: 'Apply pending local migrations to the linked project',
253
- usage: 'motherbase db push [--dry-run] [--include-seed] [--include-all]',
254
- run: pushPending,
255
- },
256
-
257
- pull: {
258
- summary: 'Create a migration from the remote schema changes local files lack',
259
- usage: 'motherbase db pull [name]',
260
- async run(ctx, args) {
261
- const { local, rows } = await reconciled(ctx);
262
- const plan = files.planPush(rows, { includeAll: true });
263
- if (plan.missingLocal.length) throw new CliError(plan.error);
264
- if (plan.pending.length) {
265
- throw new CliError(`Local migrations not yet on the remote: ${plan.pending.map(label).join(', ')}. Run \`motherbase db push\` first.`);
266
- }
267
-
268
- const diff = await api.adminRequest(ctx, 'POST', '/db/diff', { body: { migrations: local.map(asPayload) } });
269
- if (diff.empty) {
270
- output(ctx, { created: null }, () => ui.info('No schema changes found.'));
271
- return;
272
- }
273
- const created = writeMigration(ctx, args[0] || 'remote_schema', diff.sql);
274
- // The remote already has this schema: record the new file as applied so
275
- // the next push does not run it again.
276
- await api.adminRequest(ctx, 'POST', `/db/migrations/${created.version}/repair`, {
277
- body: { status: 'applied', name: created.name, sql: diff.sql },
278
- });
279
- output(ctx, { created: created.file, source: diff.source }, d => {
280
- ui.success(`Schema written to ${d.created}, and recorded as applied on the remote.`);
281
- if (d.source === 'diff') ui.warn('Generated from a schema diff: review it (foreign keys, policies and functions are not diffed).');
282
- });
283
- },
284
- },
285
-
286
- diff: {
287
- summary: 'Diff the remote schema against local migrations',
288
- usage: 'motherbase db diff [-f <name>]',
289
- async run(ctx, args, flags) {
290
- const local = readLocal(ctx);
291
- const diff = await api.adminRequest(ctx, 'POST', '/db/diff', { body: { migrations: local.map(asPayload) } });
292
- const name = sqlFlag(flags, args);
293
- if (name && !diff.empty) {
294
- const created = writeMigration(ctx, name, diff.sql);
295
- output(ctx, { ...diff, created: created.file }, d => ui.success(`Wrote ${d.created}`));
296
- return;
297
- }
298
- output(ctx, diff, d => (d.empty ? ui.info('No schema changes found.') : process.stdout.write(d.sql)));
299
- },
300
- },
301
-
302
- reset: {
303
- summary: 'Reset the linked database to local migrations and seeds',
304
- usage: 'motherbase db reset --confirm [--no-seed]',
305
- async run(ctx, _args, flags) {
306
- refuseWithoutConfirm(flags, 'db reset drops every table, view and function in your app schemas on the LINKED project and replays motherbase/migrations.');
307
- const migrations = readLocal(ctx).map(asPayload);
308
- const seeds = flags['no-seed'] ? [] : files.readSeeds(ctx.cwd);
309
- const data = await api.adminRequest(ctx, 'POST', '/db/reset', { body: { confirm: 'RESET', migrations, seeds } });
310
- output(ctx, data, d => {
311
- ui.info(`Backup taken first: ${d.backup}`);
312
- ui.success(`Reset complete: ${d.applied.length} migration(s) applied${d.seeded.length ? `, seeded ${d.seeded.join(', ')}` : ''}.`);
313
- });
314
- },
315
- },
316
-
317
- dump: {
318
- summary: 'Dump the linked database (app schemas) to a local file or stdout',
319
- usage: 'motherbase db dump [-f <file>] [--schema-only | --data-only] [--schema a,b] [--use-copy]',
320
- async run(ctx, args, flags) {
321
- const schemas = flags.schema ? String(flags.schema).split(',').map(s => s.trim()).filter(Boolean) : undefined;
322
- const body = {
323
- schemaOnly: Boolean(flags['schema-only']),
324
- dataOnly: Boolean(flags['data-only']),
325
- useCopy: Boolean(flags['use-copy']),
326
- schemas,
327
- };
328
- const { sql } = await api.adminRequest(ctx, 'POST', '/db/dump', { body });
329
- const file = sqlFlag(flags, args);
330
- if (!file) { process.stdout.write(sql); return; }
331
- fs.writeFileSync(path.resolve(ctx.cwd, file), sql);
332
- output(ctx, { file, bytes: Buffer.byteLength(sql) }, d => ui.success(`Dumped to ${d.file}`));
333
- },
334
- },
335
-
336
- seed: {
337
- summary: 'Run the seed files against the linked database',
338
- usage: 'motherbase db seed',
339
- async run(ctx) {
340
- const seeds = files.readSeeds(ctx.cwd);
341
- if (!seeds.length) throw new CliError('No seed files found (motherbase/seed.sql, or db.seed.sql_paths in motherbase/config.json).');
342
- const data = await api.adminRequest(ctx, 'POST', '/db/seed', { body: { files: seeds } });
343
- output(ctx, data, d => ui.success(`Seeded ${d.files.join(', ')}`));
344
- },
345
- },
346
- };
347
-
348
- // ── test ─────────────────────────────────────────────────────────────────────
349
-
350
- const testSubcommands = {
351
- db: {
352
- summary: 'Run pgTAP tests from motherbase/tests against the linked database',
353
- usage: 'motherbase test db [file.sql ...]',
354
- async run(ctx, args) {
355
- const tests = files.readTests(ctx.cwd, args);
356
- if (!tests.length) throw new CliError(`No test files in ${files.TESTS_DIR}.`);
357
- const results = [];
358
- for (const test of tests) {
359
- results.push(await api.adminRequest(ctx, 'POST', '/db/test', { body: { name: test.path, sql: test.sql } }));
360
- }
361
- const failed = results.filter(r => !r.ok);
362
- output(ctx, { ok: !failed.length, results }, () => {
363
- for (const result of results) {
364
- ui.print(ui.color.bold(`# ${result.name}`));
365
- for (const line of result.output) ui.print(line);
366
- }
367
- const summary = `${results.length - failed.length}/${results.length} test file(s) passed`;
368
- if (failed.length) ui.printErr(ui.color.red(summary)); else ui.success(summary);
369
- });
370
- if (failed.length) throw new CliError(`Failed: ${failed.map(r => r.name).join(', ')}`);
371
- },
372
- },
373
- };
374
-
375
- module.exports = { migrationSubcommands, dbSubcommands, testSubcommands };
1
+ 'use strict';
2
+
3
+ // The Supabase CLI's local-files migration workflow, against the linked project:
4
+ //
5
+ // migration list | up | down | repair | squash | fetch
6
+ // db push | pull | diff | reset | dump | seed
7
+ // test db
8
+ //
9
+ // Registered from commands/migration.js, commands/db.js and commands/test.js.
10
+ // Files are read and written here (see ./files.js for the layout); the server
11
+ // side is /api/admin/db (apps/server controllers/admin-db.controller.ts).
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const api = require('../api');
16
+ const prompt = require('../prompt');
17
+ const ui = require('../ui');
18
+ const { output, requireArg } = require('../commands/_util');
19
+ const { CliError } = require('../context');
20
+ const files = require('./files');
21
+
22
+ // ── Shared steps ─────────────────────────────────────────────────────────────
23
+
24
+ async function remoteHistory(ctx) {
25
+ const data = await api.adminRequest(ctx, 'GET', '/db/migrations');
26
+ return (data && data.migrations) || [];
27
+ }
28
+
29
+ function readLocal(ctx) {
30
+ const { migrations, skipped } = files.readLocalMigrations(ctx.cwd);
31
+ for (const name of skipped) {
32
+ ui.warn(`Skipping ${name}: migration files must be named <timestamp>_name.sql`);
33
+ }
34
+ return migrations;
35
+ }
36
+
37
+ const asPayload = file => ({ version: file.version, name: file.name, sql: file.sql, downSql: file.downSql });
38
+ const label = file => `${file.version}_${file.name}`;
39
+
40
+ async function reconciled(ctx) {
41
+ const [local, remote] = [readLocal(ctx), await remoteHistory(ctx)];
42
+ return { local, remote, rows: files.reconcile(local, remote) };
43
+ }
44
+
45
+ /** Apply every pending local file in version order, stopping at the first failure. */
46
+ async function pushPending(ctx, _args, flags) {
47
+ const { rows } = await reconciled(ctx);
48
+ const plan = files.planPush(rows, { includeAll: Boolean(flags['include-all']) });
49
+ if (plan.error) throw new CliError(plan.error);
50
+
51
+ const seeds = flags['include-seed'] ? files.readSeeds(ctx.cwd) : [];
52
+ const result = { dryRun: Boolean(flags['dry-run']), pending: plan.pending.map(label), applied: [], seeded: [] };
53
+
54
+ if (result.dryRun) {
55
+ output(ctx, result, () => {
56
+ if (!plan.pending.length) ui.info('Remote database is up to date.');
57
+ for (const file of plan.pending) ui.print(`Would push migration ${label(file)}`);
58
+ for (const seed of seeds) ui.print(`Would seed data ${seed.path}`);
59
+ });
60
+ return;
61
+ }
62
+
63
+ for (const file of plan.pending) {
64
+ if (!ctx.flags.json) ui.info(`Applying migration ${label(file)}...`);
65
+ await api.adminRequest(ctx, 'POST', '/db/migrations', { body: asPayload(file) });
66
+ result.applied.push(label(file));
67
+ }
68
+ if (seeds.length) {
69
+ await api.adminRequest(ctx, 'POST', '/db/seed', { body: { files: seeds } });
70
+ result.seeded = seeds.map(seed => seed.path);
71
+ }
72
+ output(ctx, result, () => {
73
+ if (!result.applied.length) ui.info('Remote database is up to date.');
74
+ else ui.success(`Applied ${result.applied.length} migration(s).`);
75
+ if (result.seeded.length) ui.success(`Seeded ${result.seeded.join(', ')}`);
76
+ });
77
+ }
78
+
79
+ /** Write a new local migration file under a fresh version and return it. */
80
+ function writeMigration(ctx, name, sql) {
81
+ const version = files.versionAt();
82
+ const file = path.join(ctx.cwd, files.MIGRATIONS_DIR, files.fileNameFor(version, name));
83
+ fs.mkdirSync(path.dirname(file), { recursive: true });
84
+ fs.writeFileSync(file, files.renderFile(sql));
85
+ return { version, name, file: path.relative(ctx.cwd, file) };
86
+ }
87
+
88
+ function refuseWithoutConfirm(flags, what) {
89
+ if (!flags.confirm) throw new CliError(`${what} Rerun with --confirm to proceed.`);
90
+ }
91
+
92
+ const sqlFlag = (flags, args) => flags.file || (flags.f ? args[0] : undefined);
93
+
94
+ // ── migration ────────────────────────────────────────────────────────────────
95
+
96
+ const migrationSubcommands = {
97
+ list: {
98
+ summary: 'List local and remote migrations side by side',
99
+ usage: 'motherbase migration list',
100
+ async run(ctx) {
101
+ const { rows } = await reconciled(ctx);
102
+ const data = {
103
+ migrations: rows.map(row => ({
104
+ version: row.version,
105
+ name: row.name,
106
+ local: Boolean(row.local),
107
+ remote: row.remote ? (row.applied ? 'applied' : 'reverted') : null,
108
+ origin: row.remote ? row.remote.origin : 'local',
109
+ appliedAt: row.applied ? row.remote.appliedAt : null,
110
+ })),
111
+ };
112
+ output(ctx, data, () =>
113
+ ui.table(
114
+ data.migrations.map(m => ({
115
+ ...m,
116
+ local: m.local ? m.version : '',
117
+ remote: m.remote === 'applied' ? m.version : m.remote === 'reverted' ? `(${m.version} reverted)` : '',
118
+ appliedAt: m.appliedAt ? new Date(m.appliedAt).toISOString().replace('T', ' ').slice(0, 19) : '',
119
+ })),
120
+ [
121
+ { key: 'local', label: 'LOCAL' },
122
+ { key: 'remote', label: 'REMOTE' },
123
+ { key: 'appliedAt', label: 'TIME (UTC)' },
124
+ { key: 'origin', label: 'ORIGIN' },
125
+ { key: 'name', label: 'NAME' },
126
+ ],
127
+ ),
128
+ );
129
+ },
130
+ },
131
+
132
+ up: {
133
+ summary: 'Apply pending local migrations to the linked project',
134
+ usage: 'motherbase migration up [--include-all] [--dry-run]',
135
+ run: (ctx, args, flags) => pushPending(ctx, args, { ...flags, 'include-seed': false }),
136
+ },
137
+
138
+ down: {
139
+ summary: 'Roll back the last n applied migrations with their down SQL',
140
+ usage: 'motherbase migration down [--last <n>] [--yes]',
141
+ async run(ctx, _args, flags) {
142
+ const last = flags.last === undefined ? 1 : Number(flags.last);
143
+ if (!Number.isInteger(last) || last < 1) throw new CliError('--last must be a positive integer');
144
+ const applied = (await remoteHistory(ctx)).filter(entry => entry.status === 'applied').reverse().slice(0, last);
145
+ if (!applied.length) throw new CliError('No applied migration to roll back.');
146
+
147
+ if (!flags.yes) {
148
+ const ok = await prompt.confirm(`Roll back ${applied.map(label).join(', ')} on the linked project?`);
149
+ if (!ok) throw new CliError('Aborted.');
150
+ }
151
+ const reverted = [];
152
+ for (const entry of applied) {
153
+ if (!entry.downSql) {
154
+ throw new CliError(`${label(entry)} has no down SQL (a "-- @down" section), so it cannot be rolled back.${reverted.length ? ` Rolled back: ${reverted.join(', ')}.` : ''}`);
155
+ }
156
+ await api.adminRequest(ctx, 'POST', `/migrations/${encodeURIComponent(entry.id)}/rollback`);
157
+ reverted.push(label(entry));
158
+ }
159
+ output(ctx, { reverted }, () => ui.success(`Rolled back ${reverted.join(', ')}`));
160
+ },
161
+ },
162
+
163
+ repair: {
164
+ summary: 'Mark migrations applied or reverted in the remote history, without running them',
165
+ usage: 'motherbase migration repair <version...> --status applied|reverted',
166
+ async run(ctx, args, flags) {
167
+ const status = flags.status;
168
+ if (status !== 'applied' && status !== 'reverted') throw new CliError('--status must be applied or reverted');
169
+ requireArg(args[0], 'version');
170
+ const local = new Map(readLocal(ctx).map(file => [file.version, file]));
171
+ const repaired = [];
172
+ for (const version of args) {
173
+ const file = local.get(version);
174
+ // Recording a file as applied records what it contains, so a later
175
+ // `migration fetch` writes back the same SQL.
176
+ const body = status === 'applied' && file ? { status, ...asPayload(file) } : { status };
177
+ delete body.version;
178
+ await api.adminRequest(ctx, 'POST', `/db/migrations/${encodeURIComponent(version)}/repair`, { body });
179
+ repaired.push(version);
180
+ }
181
+ output(ctx, { status, repaired }, () => ui.success(`Repaired migration history: ${repaired.join(', ')} => ${status}`));
182
+ },
183
+ },
184
+
185
+ squash: {
186
+ summary: 'Squash local migrations into one file',
187
+ usage: 'motherbase migration squash [--version <v>] [--linked]',
188
+ async run(ctx, _args, flags) {
189
+ const all = readLocal(ctx);
190
+ const upTo = flags.version ? String(flags.version) : null;
191
+ const chosen = upTo ? all.filter(file => file.version <= upTo) : all;
192
+ const result = files.squash(chosen);
193
+
194
+ const kept = path.join(ctx.cwd, files.MIGRATIONS_DIR, files.fileNameFor(result.version, result.name));
195
+ for (const file of chosen) fs.rmSync(file.file);
196
+ fs.writeFileSync(kept, files.renderFile(result.sql, result.downSql));
197
+
198
+ // The remote history still lists every squashed version. With --linked it
199
+ // is rewritten to match: the old versions reverted, the kept one recorded
200
+ // with the squashed SQL. Nothing runs against the schema.
201
+ if (flags.linked) {
202
+ const applied = new Set((await remoteHistory(ctx)).filter(e => e.status === 'applied').map(e => e.version));
203
+ for (const file of chosen) {
204
+ if (file.version !== result.version && applied.has(file.version)) {
205
+ await api.adminRequest(ctx, 'POST', `/db/migrations/${file.version}/repair`, { body: { status: 'reverted' } });
206
+ }
207
+ }
208
+ if (applied.has(result.version)) {
209
+ await api.adminRequest(ctx, 'POST', `/db/migrations/${result.version}/repair`, {
210
+ body: { status: 'applied', name: result.name, sql: result.sql, downSql: result.downSql },
211
+ });
212
+ }
213
+ }
214
+ output(ctx, { file: path.relative(ctx.cwd, kept), replaced: result.replaced, reversible: result.reversible }, d => {
215
+ ui.success(`Squashed ${d.replaced.length} migrations into ${d.file}`);
216
+ if (!d.reversible) ui.warn('Not every squashed file had a down section, so the result has none.');
217
+ if (!flags.linked) ui.info('The remote history is unchanged. Rerun with --linked, or repair it, to match.');
218
+ });
219
+ },
220
+ },
221
+
222
+ fetch: {
223
+ summary: 'Write the remote migration history into local files',
224
+ usage: 'motherbase migration fetch [--force]',
225
+ async run(ctx, _args, flags) {
226
+ const local = new Map(readLocal(ctx).map(file => [file.version, file]));
227
+ const dir = path.join(ctx.cwd, files.MIGRATIONS_DIR);
228
+ fs.mkdirSync(dir, { recursive: true });
229
+ const written = [];
230
+ const kept = [];
231
+ for (const entry of (await remoteHistory(ctx)).filter(e => e.status === 'applied')) {
232
+ const existing = local.get(entry.version);
233
+ if (existing && !flags.force) { kept.push(path.basename(existing.file)); continue; }
234
+ if (existing) fs.rmSync(existing.file);
235
+ const name = files.fileNameFor(entry.version, entry.name);
236
+ fs.writeFileSync(path.join(dir, name), files.renderFile(entry.sql, entry.downSql));
237
+ written.push(name);
238
+ }
239
+ output(ctx, { written, kept }, d => {
240
+ if (!d.written.length) ui.info('Local migrations already cover the remote history.');
241
+ for (const name of d.written) ui.success(`Wrote ${path.join(files.MIGRATIONS_DIR, name)}`);
242
+ if (d.kept.length) ui.info(`Kept ${d.kept.length} existing file(s); --force overwrites them.`);
243
+ });
244
+ },
245
+ },
246
+ };
247
+
248
+ // ── db ───────────────────────────────────────────────────────────────────────
249
+
250
+ const dbSubcommands = {
251
+ push: {
252
+ summary: 'Apply pending local migrations to the linked project',
253
+ usage: 'motherbase db push [--dry-run] [--include-seed] [--include-all]',
254
+ run: pushPending,
255
+ },
256
+
257
+ pull: {
258
+ summary: 'Create a migration from the remote schema changes local files lack',
259
+ usage: 'motherbase db pull [name]',
260
+ async run(ctx, args) {
261
+ const { local, rows } = await reconciled(ctx);
262
+ const plan = files.planPush(rows, { includeAll: true });
263
+ if (plan.missingLocal.length) throw new CliError(plan.error);
264
+ if (plan.pending.length) {
265
+ throw new CliError(`Local migrations not yet on the remote: ${plan.pending.map(label).join(', ')}. Run \`motherbase db push\` first.`);
266
+ }
267
+
268
+ const diff = await api.adminRequest(ctx, 'POST', '/db/diff', { body: { migrations: local.map(asPayload) } });
269
+ if (diff.empty) {
270
+ output(ctx, { created: null }, () => ui.info('No schema changes found.'));
271
+ return;
272
+ }
273
+ const created = writeMigration(ctx, args[0] || 'remote_schema', diff.sql);
274
+ // The remote already has this schema: record the new file as applied so
275
+ // the next push does not run it again.
276
+ await api.adminRequest(ctx, 'POST', `/db/migrations/${created.version}/repair`, {
277
+ body: { status: 'applied', name: created.name, sql: diff.sql },
278
+ });
279
+ output(ctx, { created: created.file, source: diff.source }, d => {
280
+ ui.success(`Schema written to ${d.created}, and recorded as applied on the remote.`);
281
+ if (d.source === 'diff') ui.warn('Generated from a schema diff: review it (foreign keys, policies and functions are not diffed).');
282
+ });
283
+ },
284
+ },
285
+
286
+ diff: {
287
+ summary: 'Diff the remote schema against local migrations',
288
+ usage: 'motherbase db diff [-f <name>]',
289
+ async run(ctx, args, flags) {
290
+ const local = readLocal(ctx);
291
+ const diff = await api.adminRequest(ctx, 'POST', '/db/diff', { body: { migrations: local.map(asPayload) } });
292
+ const name = sqlFlag(flags, args);
293
+ if (name && !diff.empty) {
294
+ const created = writeMigration(ctx, name, diff.sql);
295
+ output(ctx, { ...diff, created: created.file }, d => ui.success(`Wrote ${d.created}`));
296
+ return;
297
+ }
298
+ output(ctx, diff, d => (d.empty ? ui.info('No schema changes found.') : process.stdout.write(d.sql)));
299
+ },
300
+ },
301
+
302
+ reset: {
303
+ summary: 'Reset the linked database to local migrations and seeds',
304
+ usage: 'motherbase db reset --confirm [--no-seed]',
305
+ async run(ctx, _args, flags) {
306
+ refuseWithoutConfirm(flags, 'db reset drops every table, view and function in your app schemas on the LINKED project and replays motherbase/migrations.');
307
+ const migrations = readLocal(ctx).map(asPayload);
308
+ const seeds = flags['no-seed'] ? [] : files.readSeeds(ctx.cwd);
309
+ const data = await api.adminRequest(ctx, 'POST', '/db/reset', { body: { confirm: 'RESET', migrations, seeds } });
310
+ output(ctx, data, d => {
311
+ ui.info(`Backup taken first: ${d.backup}`);
312
+ ui.success(`Reset complete: ${d.applied.length} migration(s) applied${d.seeded.length ? `, seeded ${d.seeded.join(', ')}` : ''}.`);
313
+ });
314
+ },
315
+ },
316
+
317
+ dump: {
318
+ summary: 'Dump the linked database (app schemas) to a local file or stdout',
319
+ usage: 'motherbase db dump [-f <file>] [--schema-only | --data-only] [--schema a,b] [--use-copy]',
320
+ async run(ctx, args, flags) {
321
+ const schemas = flags.schema ? String(flags.schema).split(',').map(s => s.trim()).filter(Boolean) : undefined;
322
+ const body = {
323
+ schemaOnly: Boolean(flags['schema-only']),
324
+ dataOnly: Boolean(flags['data-only']),
325
+ useCopy: Boolean(flags['use-copy']),
326
+ schemas,
327
+ };
328
+ const { sql } = await api.adminRequest(ctx, 'POST', '/db/dump', { body });
329
+ const file = sqlFlag(flags, args);
330
+ if (!file) { process.stdout.write(sql); return; }
331
+ fs.writeFileSync(path.resolve(ctx.cwd, file), sql);
332
+ output(ctx, { file, bytes: Buffer.byteLength(sql) }, d => ui.success(`Dumped to ${d.file}`));
333
+ },
334
+ },
335
+
336
+ seed: {
337
+ summary: 'Run the seed files against the linked database',
338
+ usage: 'motherbase db seed',
339
+ async run(ctx) {
340
+ const seeds = files.readSeeds(ctx.cwd);
341
+ if (!seeds.length) throw new CliError('No seed files found (motherbase/seed.sql, or db.seed.sql_paths in motherbase/config.json).');
342
+ const data = await api.adminRequest(ctx, 'POST', '/db/seed', { body: { files: seeds } });
343
+ output(ctx, data, d => ui.success(`Seeded ${d.files.join(', ')}`));
344
+ },
345
+ },
346
+ };
347
+
348
+ // ── test ─────────────────────────────────────────────────────────────────────
349
+
350
+ const testSubcommands = {
351
+ db: {
352
+ summary: 'Run pgTAP tests from motherbase/tests against the linked database',
353
+ usage: 'motherbase test db [file.sql ...]',
354
+ async run(ctx, args) {
355
+ const tests = files.readTests(ctx.cwd, args);
356
+ if (!tests.length) throw new CliError(`No test files in ${files.TESTS_DIR}.`);
357
+ const results = [];
358
+ for (const test of tests) {
359
+ results.push(await api.adminRequest(ctx, 'POST', '/db/test', { body: { name: test.path, sql: test.sql } }));
360
+ }
361
+ const failed = results.filter(r => !r.ok);
362
+ output(ctx, { ok: !failed.length, results }, () => {
363
+ for (const result of results) {
364
+ ui.print(ui.color.bold(`# ${result.name}`));
365
+ for (const line of result.output) ui.print(line);
366
+ }
367
+ const summary = `${results.length - failed.length}/${results.length} test file(s) passed`;
368
+ if (failed.length) ui.printErr(ui.color.red(summary)); else ui.success(summary);
369
+ });
370
+ if (failed.length) throw new CliError(`Failed: ${failed.map(r => r.name).join(', ')}`);
371
+ },
372
+ },
373
+ };
374
+
375
+ module.exports = { migrationSubcommands, dbSubcommands, testSubcommands };