@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,245 +1,245 @@
1
- 'use strict';
2
-
3
- // The local half of the migration workflow: the `motherbase/` directory, laid
4
- // out the way a Supabase project lays out `supabase/`:
5
- //
6
- // motherbase/
7
- // migrations/<version>_<name>.sql one migration per file, applied in version order
8
- // seed.sql data for `db reset` and `db push --include-seed`
9
- // tests/*.sql pgTAP tests for `motherbase test db`
10
- // config.json optional: { "db": { "seed": { "sql_paths": [...] } } }
11
- //
12
- // A file holds its up SQL, then optionally a `-- @down` line and the SQL that
13
- // undoes it: the convention the Git sync reads (apps/server migration-files.ts).
14
- //
15
- // Everything here except the small fs readers is pure, so the parts that decide
16
- // what gets applied (naming, ordering, reconciliation, squashing) are tested
17
- // without a server.
18
-
19
- const fs = require('fs');
20
- const path = require('path');
21
- const { CliError } = require('../context');
22
-
23
- const PROJECT_DIR = 'motherbase';
24
- const MIGRATIONS_DIR = path.join(PROJECT_DIR, 'migrations');
25
- const TESTS_DIR = path.join(PROJECT_DIR, 'tests');
26
- const CONFIG_FILE = path.join(PROJECT_DIR, 'config.json');
27
- const DEFAULT_SEED_PATHS = ['./seed.sql'];
28
-
29
- const FILE_NAME = /^(\d+)_(.+)\.sql$/;
30
- /** `-- @down`, `--- @down`, `-- migrate:down`. Own line, any case, any indent. */
31
- const DOWN_MARKER = /^[ \t]*--+[ \t]*(?:migrate:)?@?down[ \t]*$/im;
32
-
33
- // ── Names ────────────────────────────────────────────────────────────────────
34
-
35
- /** `20260101120000_add_orders.sql` → { version, name }; null outside the convention. */
36
- function parseFileName(fileName) {
37
- const match = FILE_NAME.exec(fileName);
38
- return match ? { version: match[1], name: match[2] } : null;
39
- }
40
-
41
- /** The version `migration new` stamps: UTC, to the second. */
42
- function versionAt(date = new Date()) {
43
- return date.toISOString().replace(/[-:T]/g, '').slice(0, 14);
44
- }
45
-
46
- /** A file name for a history entry. Studio names are free text: keep them file-safe. */
47
- function fileNameFor(version, name) {
48
- const safe = String(name).trim().replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') || 'migration';
49
- return `${version}_${safe}.sql`;
50
- }
51
-
52
- function byVersion(a, b) {
53
- return a.version < b.version ? -1 : a.version > b.version ? 1 : 0;
54
- }
55
-
56
- // ── File contents ────────────────────────────────────────────────────────────
57
-
58
- /** Split a file into up and down SQL at the `-- @down` marker. */
59
- function splitDown(contents) {
60
- const marker = DOWN_MARKER.exec(contents);
61
- if (!marker) return { sql: contents.trim(), downSql: null };
62
- const down = contents.slice(marker.index + marker[0].length).trim();
63
- // Comments only (what `migration new` scaffolds) is no down SQL at all.
64
- const hasSql = down.replace(/\/\*[\s\S]*?\*\//g, '').replace(/--[^\n]*/g, '').trim();
65
- return { sql: contents.slice(0, marker.index).trim(), downSql: hasSql ? down : null };
66
- }
67
-
68
- function renderFile(sql, downSql) {
69
- const up = `${String(sql).trim()}\n`;
70
- return downSql && downSql.trim() ? `${up}\n-- @down\n${downSql.trim()}\n` : up;
71
- }
72
-
73
- // ── Reading the directory ────────────────────────────────────────────────────
74
-
75
- /**
76
- * Every migration file, in version order, with its SQL. Files that do not
77
- * match `<version>_<name>.sql` are reported, not applied, exactly as Supabase
78
- * skips them.
79
- */
80
- function readLocalMigrations(cwd) {
81
- const dir = path.join(cwd, MIGRATIONS_DIR);
82
- if (!fs.existsSync(dir)) return { migrations: [], skipped: [] };
83
- const migrations = [];
84
- const skipped = [];
85
- for (const fileName of fs.readdirSync(dir).sort()) {
86
- if (!fileName.toLowerCase().endsWith('.sql')) continue;
87
- const parsed = parseFileName(fileName);
88
- if (!parsed) { skipped.push(fileName); continue; }
89
- const file = path.join(dir, fileName);
90
- migrations.push({ ...parsed, file, ...splitDown(fs.readFileSync(file, 'utf8')) });
91
- }
92
- return { migrations: migrations.sort(byVersion), skipped };
93
- }
94
-
95
- function readConfig(cwd) {
96
- const file = path.join(cwd, CONFIG_FILE);
97
- if (!fs.existsSync(file)) return {};
98
- try {
99
- return JSON.parse(fs.readFileSync(file, 'utf8'));
100
- } catch (err) {
101
- throw new CliError(`${CONFIG_FILE} is not valid JSON: ${err.message}`);
102
- }
103
- }
104
-
105
- /** A glob with `*` / `?` in the last segment, like Supabase's `sql_paths`. */
106
- function expandPath(baseDir, pattern) {
107
- const full = path.resolve(baseDir, pattern);
108
- const base = path.basename(full);
109
- if (!/[*?]/.test(base)) return fs.existsSync(full) ? [full] : [];
110
- const dir = path.dirname(full);
111
- if (!fs.existsSync(dir)) return [];
112
- const re = new RegExp(`^${base.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
113
- return fs.readdirSync(dir).filter(name => re.test(name)).sort().map(name => path.join(dir, name));
114
- }
115
-
116
- /**
117
- * The seed files, in order: `db.seed.sql_paths` from motherbase/config.json
118
- * (relative to motherbase/), or `seed.sql`. `db.seed.enabled: false` turns
119
- * seeding off. A path that matches nothing is skipped; the default seed.sql is
120
- * optional, as in Supabase.
121
- */
122
- function readSeeds(cwd) {
123
- const seed = (readConfig(cwd).db || {}).seed || {};
124
- if (seed.enabled === false) return [];
125
- const patterns = Array.isArray(seed.sql_paths) && seed.sql_paths.length ? seed.sql_paths : DEFAULT_SEED_PATHS;
126
- const baseDir = path.join(cwd, PROJECT_DIR);
127
- const seen = new Set();
128
- const files = [];
129
- for (const pattern of patterns) {
130
- for (const file of expandPath(baseDir, pattern)) {
131
- if (seen.has(file)) continue;
132
- seen.add(file);
133
- files.push({ path: path.relative(cwd, file), sql: fs.readFileSync(file, 'utf8') });
134
- }
135
- }
136
- return files;
137
- }
138
-
139
- /** pgTAP files under motherbase/tests, recursively, in path order. */
140
- function readTests(cwd, only = []) {
141
- if (only.length) return only.map(file => ({ path: file, sql: fs.readFileSync(path.resolve(cwd, file), 'utf8') }));
142
- const root = path.join(cwd, TESTS_DIR);
143
- const found = [];
144
- const walk = dir => {
145
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
146
- const full = path.join(dir, entry.name);
147
- if (entry.isDirectory()) walk(full);
148
- else if (entry.name.toLowerCase().endsWith('.sql')) found.push(full);
149
- }
150
- };
151
- if (fs.existsSync(root)) walk(root);
152
- return found.sort().map(file => ({ path: path.relative(cwd, file), sql: fs.readFileSync(file, 'utf8') }));
153
- }
154
-
155
- // ── Local vs remote ──────────────────────────────────────────────────────────
156
-
157
- /**
158
- * One row per version, local and remote side by side: what `migration list`
159
- * prints. `remote` is the history entry or null; only an applied entry counts
160
- * as "on the remote", a rolled-back one is history, not schema.
161
- */
162
- function reconcile(local, remote) {
163
- const rows = new Map();
164
- for (const file of local) rows.set(file.version, { version: file.version, name: file.name, local: file, remote: null });
165
- for (const entry of remote) {
166
- const row = rows.get(entry.version) || { version: entry.version, name: entry.name, local: null, remote: null };
167
- row.remote = entry;
168
- rows.set(entry.version, row);
169
- }
170
- return [...rows.values()].sort(byVersion).map(row => ({
171
- ...row,
172
- applied: Boolean(row.remote && row.remote.status === 'applied'),
173
- }));
174
- }
175
-
176
- /**
177
- * What `db push` would do, and why it must refuse, Supabase's rules:
178
- * * a migration applied on the remote with no local file means local is
179
- * behind: fetch or repair first, never push over it;
180
- * * a pending file older than the last applied remote version was written
181
- * against a schema the remote has moved past: only with --include-all.
182
- */
183
- function planPush(rows, { includeAll = false } = {}) {
184
- const missingLocal = rows.filter(row => row.applied && !row.local).map(row => row.version);
185
- const pending = rows.filter(row => row.local && !row.applied).map(row => row.local);
186
- const lastApplied = rows.filter(row => row.applied).map(row => row.version).pop() || null;
187
- const outOfOrder = lastApplied ? pending.filter(file => file.version < lastApplied).map(file => file.version) : [];
188
-
189
- let error = null;
190
- if (missingLocal.length) {
191
- error =
192
- `Remote migration versions not found in ${MIGRATIONS_DIR}: ${missingLocal.join(', ')}.\n` +
193
- 'Pull them with `motherbase migration fetch`, or mark them reverted with ' +
194
- `\`motherbase migration repair --status reverted ${missingLocal.join(' ')}\`.`;
195
- } else if (outOfOrder.length && !includeAll) {
196
- error =
197
- `Found local migrations to be inserted before the last migration on the remote (${lastApplied}): ${outOfOrder.join(', ')}.\n` +
198
- 'Rerun with --include-all to apply them anyway.';
199
- }
200
- return { pending, missingLocal, outOfOrder, error };
201
- }
202
-
203
- /**
204
- * Fold migrations into one, Supabase's `migration squash`: kept under the
205
- * newest version (so the remote history still ends where it did), with every
206
- * up section in order and, only if every file has one, every down section in
207
- * reverse. A partial down would roll back a fraction of the squash.
208
- */
209
- function squash(migrations) {
210
- if (migrations.length < 2) throw new CliError('Nothing to squash: need at least two migrations.');
211
- const ordered = [...migrations].sort(byVersion);
212
- const last = ordered[ordered.length - 1];
213
- const label = file => `${file.version}_${file.name}`;
214
- const up = ordered.map(file => `-- ${label(file)}\n${file.sql.trim()}`).join('\n\n');
215
- const reversible = ordered.every(file => file.downSql);
216
- const down = reversible
217
- ? [...ordered].reverse().map(file => `-- ${label(file)}\n${file.downSql.trim()}`).join('\n\n')
218
- : null;
219
- const header = `-- Squashed from ${ordered.length} migrations: ${ordered.map(label).join(', ')}\n\n`;
220
- return {
221
- version: last.version,
222
- name: last.name,
223
- sql: `${header}${up}`,
224
- downSql: down,
225
- reversible,
226
- replaced: ordered.map(label),
227
- };
228
- }
229
-
230
- module.exports = {
231
- PROJECT_DIR,
232
- MIGRATIONS_DIR,
233
- TESTS_DIR,
234
- parseFileName,
235
- versionAt,
236
- fileNameFor,
237
- splitDown,
238
- renderFile,
239
- readLocalMigrations,
240
- readSeeds,
241
- readTests,
242
- reconcile,
243
- planPush,
244
- squash,
245
- };
1
+ 'use strict';
2
+
3
+ // The local half of the migration workflow: the `motherbase/` directory, laid
4
+ // out the way a Supabase project lays out `supabase/`:
5
+ //
6
+ // motherbase/
7
+ // migrations/<version>_<name>.sql one migration per file, applied in version order
8
+ // seed.sql data for `db reset` and `db push --include-seed`
9
+ // tests/*.sql pgTAP tests for `motherbase test db`
10
+ // config.json optional: { "db": { "seed": { "sql_paths": [...] } } }
11
+ //
12
+ // A file holds its up SQL, then optionally a `-- @down` line and the SQL that
13
+ // undoes it: the convention the Git sync reads (apps/server migration-files.ts).
14
+ //
15
+ // Everything here except the small fs readers is pure, so the parts that decide
16
+ // what gets applied (naming, ordering, reconciliation, squashing) are tested
17
+ // without a server.
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const { CliError } = require('../context');
22
+
23
+ const PROJECT_DIR = 'motherbase';
24
+ const MIGRATIONS_DIR = path.join(PROJECT_DIR, 'migrations');
25
+ const TESTS_DIR = path.join(PROJECT_DIR, 'tests');
26
+ const CONFIG_FILE = path.join(PROJECT_DIR, 'config.json');
27
+ const DEFAULT_SEED_PATHS = ['./seed.sql'];
28
+
29
+ const FILE_NAME = /^(\d+)_(.+)\.sql$/;
30
+ /** `-- @down`, `--- @down`, `-- migrate:down`. Own line, any case, any indent. */
31
+ const DOWN_MARKER = /^[ \t]*--+[ \t]*(?:migrate:)?@?down[ \t]*$/im;
32
+
33
+ // ── Names ────────────────────────────────────────────────────────────────────
34
+
35
+ /** `20260101120000_add_orders.sql` → { version, name }; null outside the convention. */
36
+ function parseFileName(fileName) {
37
+ const match = FILE_NAME.exec(fileName);
38
+ return match ? { version: match[1], name: match[2] } : null;
39
+ }
40
+
41
+ /** The version `migration new` stamps: UTC, to the second. */
42
+ function versionAt(date = new Date()) {
43
+ return date.toISOString().replace(/[-:T]/g, '').slice(0, 14);
44
+ }
45
+
46
+ /** A file name for a history entry. Studio names are free text: keep them file-safe. */
47
+ function fileNameFor(version, name) {
48
+ const safe = String(name).trim().replace(/[^A-Za-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') || 'migration';
49
+ return `${version}_${safe}.sql`;
50
+ }
51
+
52
+ function byVersion(a, b) {
53
+ return a.version < b.version ? -1 : a.version > b.version ? 1 : 0;
54
+ }
55
+
56
+ // ── File contents ────────────────────────────────────────────────────────────
57
+
58
+ /** Split a file into up and down SQL at the `-- @down` marker. */
59
+ function splitDown(contents) {
60
+ const marker = DOWN_MARKER.exec(contents);
61
+ if (!marker) return { sql: contents.trim(), downSql: null };
62
+ const down = contents.slice(marker.index + marker[0].length).trim();
63
+ // Comments only (what `migration new` scaffolds) is no down SQL at all.
64
+ const hasSql = down.replace(/\/\*[\s\S]*?\*\//g, '').replace(/--[^\n]*/g, '').trim();
65
+ return { sql: contents.slice(0, marker.index).trim(), downSql: hasSql ? down : null };
66
+ }
67
+
68
+ function renderFile(sql, downSql) {
69
+ const up = `${String(sql).trim()}\n`;
70
+ return downSql && downSql.trim() ? `${up}\n-- @down\n${downSql.trim()}\n` : up;
71
+ }
72
+
73
+ // ── Reading the directory ────────────────────────────────────────────────────
74
+
75
+ /**
76
+ * Every migration file, in version order, with its SQL. Files that do not
77
+ * match `<version>_<name>.sql` are reported, not applied, exactly as Supabase
78
+ * skips them.
79
+ */
80
+ function readLocalMigrations(cwd) {
81
+ const dir = path.join(cwd, MIGRATIONS_DIR);
82
+ if (!fs.existsSync(dir)) return { migrations: [], skipped: [] };
83
+ const migrations = [];
84
+ const skipped = [];
85
+ for (const fileName of fs.readdirSync(dir).sort()) {
86
+ if (!fileName.toLowerCase().endsWith('.sql')) continue;
87
+ const parsed = parseFileName(fileName);
88
+ if (!parsed) { skipped.push(fileName); continue; }
89
+ const file = path.join(dir, fileName);
90
+ migrations.push({ ...parsed, file, ...splitDown(fs.readFileSync(file, 'utf8')) });
91
+ }
92
+ return { migrations: migrations.sort(byVersion), skipped };
93
+ }
94
+
95
+ function readConfig(cwd) {
96
+ const file = path.join(cwd, CONFIG_FILE);
97
+ if (!fs.existsSync(file)) return {};
98
+ try {
99
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
100
+ } catch (err) {
101
+ throw new CliError(`${CONFIG_FILE} is not valid JSON: ${err.message}`);
102
+ }
103
+ }
104
+
105
+ /** A glob with `*` / `?` in the last segment, like Supabase's `sql_paths`. */
106
+ function expandPath(baseDir, pattern) {
107
+ const full = path.resolve(baseDir, pattern);
108
+ const base = path.basename(full);
109
+ if (!/[*?]/.test(base)) return fs.existsSync(full) ? [full] : [];
110
+ const dir = path.dirname(full);
111
+ if (!fs.existsSync(dir)) return [];
112
+ const re = new RegExp(`^${base.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
113
+ return fs.readdirSync(dir).filter(name => re.test(name)).sort().map(name => path.join(dir, name));
114
+ }
115
+
116
+ /**
117
+ * The seed files, in order: `db.seed.sql_paths` from motherbase/config.json
118
+ * (relative to motherbase/), or `seed.sql`. `db.seed.enabled: false` turns
119
+ * seeding off. A path that matches nothing is skipped; the default seed.sql is
120
+ * optional, as in Supabase.
121
+ */
122
+ function readSeeds(cwd) {
123
+ const seed = (readConfig(cwd).db || {}).seed || {};
124
+ if (seed.enabled === false) return [];
125
+ const patterns = Array.isArray(seed.sql_paths) && seed.sql_paths.length ? seed.sql_paths : DEFAULT_SEED_PATHS;
126
+ const baseDir = path.join(cwd, PROJECT_DIR);
127
+ const seen = new Set();
128
+ const files = [];
129
+ for (const pattern of patterns) {
130
+ for (const file of expandPath(baseDir, pattern)) {
131
+ if (seen.has(file)) continue;
132
+ seen.add(file);
133
+ files.push({ path: path.relative(cwd, file), sql: fs.readFileSync(file, 'utf8') });
134
+ }
135
+ }
136
+ return files;
137
+ }
138
+
139
+ /** pgTAP files under motherbase/tests, recursively, in path order. */
140
+ function readTests(cwd, only = []) {
141
+ if (only.length) return only.map(file => ({ path: file, sql: fs.readFileSync(path.resolve(cwd, file), 'utf8') }));
142
+ const root = path.join(cwd, TESTS_DIR);
143
+ const found = [];
144
+ const walk = dir => {
145
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
146
+ const full = path.join(dir, entry.name);
147
+ if (entry.isDirectory()) walk(full);
148
+ else if (entry.name.toLowerCase().endsWith('.sql')) found.push(full);
149
+ }
150
+ };
151
+ if (fs.existsSync(root)) walk(root);
152
+ return found.sort().map(file => ({ path: path.relative(cwd, file), sql: fs.readFileSync(file, 'utf8') }));
153
+ }
154
+
155
+ // ── Local vs remote ──────────────────────────────────────────────────────────
156
+
157
+ /**
158
+ * One row per version, local and remote side by side: what `migration list`
159
+ * prints. `remote` is the history entry or null; only an applied entry counts
160
+ * as "on the remote", a rolled-back one is history, not schema.
161
+ */
162
+ function reconcile(local, remote) {
163
+ const rows = new Map();
164
+ for (const file of local) rows.set(file.version, { version: file.version, name: file.name, local: file, remote: null });
165
+ for (const entry of remote) {
166
+ const row = rows.get(entry.version) || { version: entry.version, name: entry.name, local: null, remote: null };
167
+ row.remote = entry;
168
+ rows.set(entry.version, row);
169
+ }
170
+ return [...rows.values()].sort(byVersion).map(row => ({
171
+ ...row,
172
+ applied: Boolean(row.remote && row.remote.status === 'applied'),
173
+ }));
174
+ }
175
+
176
+ /**
177
+ * What `db push` would do, and why it must refuse, Supabase's rules:
178
+ * * a migration applied on the remote with no local file means local is
179
+ * behind: fetch or repair first, never push over it;
180
+ * * a pending file older than the last applied remote version was written
181
+ * against a schema the remote has moved past: only with --include-all.
182
+ */
183
+ function planPush(rows, { includeAll = false } = {}) {
184
+ const missingLocal = rows.filter(row => row.applied && !row.local).map(row => row.version);
185
+ const pending = rows.filter(row => row.local && !row.applied).map(row => row.local);
186
+ const lastApplied = rows.filter(row => row.applied).map(row => row.version).pop() || null;
187
+ const outOfOrder = lastApplied ? pending.filter(file => file.version < lastApplied).map(file => file.version) : [];
188
+
189
+ let error = null;
190
+ if (missingLocal.length) {
191
+ error =
192
+ `Remote migration versions not found in ${MIGRATIONS_DIR}: ${missingLocal.join(', ')}.\n` +
193
+ 'Pull them with `motherbase migration fetch`, or mark them reverted with ' +
194
+ `\`motherbase migration repair --status reverted ${missingLocal.join(' ')}\`.`;
195
+ } else if (outOfOrder.length && !includeAll) {
196
+ error =
197
+ `Found local migrations to be inserted before the last migration on the remote (${lastApplied}): ${outOfOrder.join(', ')}.\n` +
198
+ 'Rerun with --include-all to apply them anyway.';
199
+ }
200
+ return { pending, missingLocal, outOfOrder, error };
201
+ }
202
+
203
+ /**
204
+ * Fold migrations into one, Supabase's `migration squash`: kept under the
205
+ * newest version (so the remote history still ends where it did), with every
206
+ * up section in order and, only if every file has one, every down section in
207
+ * reverse. A partial down would roll back a fraction of the squash.
208
+ */
209
+ function squash(migrations) {
210
+ if (migrations.length < 2) throw new CliError('Nothing to squash: need at least two migrations.');
211
+ const ordered = [...migrations].sort(byVersion);
212
+ const last = ordered[ordered.length - 1];
213
+ const label = file => `${file.version}_${file.name}`;
214
+ const up = ordered.map(file => `-- ${label(file)}\n${file.sql.trim()}`).join('\n\n');
215
+ const reversible = ordered.every(file => file.downSql);
216
+ const down = reversible
217
+ ? [...ordered].reverse().map(file => `-- ${label(file)}\n${file.downSql.trim()}`).join('\n\n')
218
+ : null;
219
+ const header = `-- Squashed from ${ordered.length} migrations: ${ordered.map(label).join(', ')}\n\n`;
220
+ return {
221
+ version: last.version,
222
+ name: last.name,
223
+ sql: `${header}${up}`,
224
+ downSql: down,
225
+ reversible,
226
+ replaced: ordered.map(label),
227
+ };
228
+ }
229
+
230
+ module.exports = {
231
+ PROJECT_DIR,
232
+ MIGRATIONS_DIR,
233
+ TESTS_DIR,
234
+ parseFileName,
235
+ versionAt,
236
+ fileNameFor,
237
+ splitDown,
238
+ renderFile,
239
+ readLocalMigrations,
240
+ readSeeds,
241
+ readTests,
242
+ reconcile,
243
+ planPush,
244
+ squash,
245
+ };