@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.
Files changed (47) hide show
  1. package/README.md +247 -134
  2. package/package.json +4 -3
  3. package/src/api.js +14 -2
  4. package/src/commands/_util.js +49 -6
  5. package/src/commands/backups.js +16 -0
  6. package/src/commands/branches.js +113 -0
  7. package/src/commands/completion.js +90 -0
  8. package/src/commands/db-catalog.js +326 -0
  9. package/src/commands/db.js +16 -5
  10. package/src/commands/domains.js +124 -0
  11. package/src/commands/functions.js +297 -274
  12. package/src/commands/gen.js +172 -126
  13. package/src/commands/import.js +23 -3
  14. package/src/commands/index.js +32 -6
  15. package/src/commands/inspect.js +142 -5
  16. package/src/commands/keys.js +72 -0
  17. package/src/commands/local.js +226 -17
  18. package/src/commands/mcp.js +61 -0
  19. package/src/commands/migrate.js +179 -0
  20. package/src/commands/migration.js +81 -6
  21. package/src/commands/network.js +83 -0
  22. package/src/commands/orgs.js +40 -0
  23. package/src/commands/postgres-config.js +113 -0
  24. package/src/commands/projects.js +8 -3
  25. package/src/commands/queues.js +133 -0
  26. package/src/commands/secrets.js +29 -8
  27. package/src/commands/signing.js +130 -0
  28. package/src/commands/snippets.js +44 -0
  29. package/src/commands/storage.js +192 -44
  30. package/src/commands/test.js +9 -0
  31. package/src/commands/users.js +30 -1
  32. package/src/commands/vault.js +82 -0
  33. package/src/commands/wrappers.js +150 -0
  34. package/src/edge-templates.js +194 -0
  35. package/src/format.js +92 -0
  36. package/src/inspect/csv.js +22 -0
  37. package/src/inspect/queries.js +562 -0
  38. package/src/main.js +41 -7
  39. package/src/mcp/platform-tools.js +233 -0
  40. package/src/mcp/protocol.js +149 -0
  41. package/src/mcp/storage-tools.js +93 -0
  42. package/src/mcp/tools.js +478 -0
  43. package/src/migration/commands.js +375 -0
  44. package/src/migration/files.js +245 -0
  45. package/src/prompt.js +1 -1
  46. package/src/storage/remote.js +62 -0
  47. package/src/commands/migrations.js +0 -85
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ // Project API keys. `secrets` next door is a different thing: those are values
4
+ // an edge function reads, these are what a client presents to reach the project.
5
+
6
+ const api = require('../api');
7
+ const { output, requireArg } = require('./_util');
8
+
9
+ const TYPES = ['publishable', 'secret'];
10
+
11
+ const subcommands = {
12
+ list: {
13
+ summary: 'List project API keys',
14
+ usage: 'motherbase keys list',
15
+ async run(ctx) {
16
+ const data = await api.adminRequest(ctx, 'GET', '/api-keys');
17
+ const keys = (data && data.keys) || [];
18
+ output(ctx, data, (_d, ui) =>
19
+ ui.table(
20
+ keys.map(k => ({
21
+ name: k.name,
22
+ type: k.type,
23
+ // A secret key has no stored clear value; the prefix is all there is.
24
+ key: k.token || `${k.prefix}…`,
25
+ status: k.revokedAt ? 'revoked' : 'active',
26
+ })),
27
+ [
28
+ { key: 'name', label: 'NAME' },
29
+ { key: 'type', label: 'TYPE' },
30
+ { key: 'key', label: 'KEY' },
31
+ { key: 'status', label: 'STATUS' },
32
+ ],
33
+ ),
34
+ );
35
+ },
36
+ },
37
+ create: {
38
+ summary: 'Create a key. A secret key is printed once and never again',
39
+ usage: 'motherbase keys create <NAME> [--type publishable|secret]',
40
+ async run(ctx, args, flags) {
41
+ const name = requireArg(args[0], 'NAME');
42
+ const type = flags.type || 'secret';
43
+ if (!TYPES.includes(type)) {
44
+ throw new Error(`--type must be one of: ${TYPES.join(', ')}`);
45
+ }
46
+ const data = await api.adminRequest(ctx, 'POST', '/api-keys', { body: { name, type } });
47
+ output(ctx, data, (d, ui) => {
48
+ ui.success(`Created ${type} key "${name}"`);
49
+ ui.print(d.clearToken);
50
+ if (type === 'secret') {
51
+ ui.warn('Copy it now. It is stored hashed and cannot be shown again.');
52
+ ui.warn('It bypasses row level security: keep it server side.');
53
+ }
54
+ });
55
+ },
56
+ },
57
+ revoke: {
58
+ summary: 'Revoke a key by id',
59
+ usage: 'motherbase keys revoke <ID>',
60
+ async run(ctx, args) {
61
+ const id = requireArg(args[0], 'ID');
62
+ const data = await api.adminRequest(ctx, 'DELETE', `/api-keys/${encodeURIComponent(id)}`);
63
+ output(ctx, data, (d, ui) => ui.success(`Revoked key ${d.name || id}`));
64
+ },
65
+ },
66
+ };
67
+
68
+ module.exports = {
69
+ summary: 'Project API keys (publishable and secret)',
70
+ usage: 'motherbase keys <list|create|revoke> ...',
71
+ subcommands,
72
+ };
@@ -1,15 +1,30 @@
1
1
  'use strict';
2
2
 
3
3
  // Local self-host workflow: thin wrappers over `docker compose`, plus `init`
4
- // scaffolding. No HTTP, no context resolution required. Ported and extended from
5
- // the original single-file CLI.
4
+ // scaffolding.
5
+ //
6
+ // `init` used to write a `.env` and nothing else, which left a directory that
7
+ // `motherbase start` could not start: `docker compose up` in a folder with no
8
+ // compose file is a raw Docker error, and that was the first thing a new user
9
+ // saw. It now writes a self-contained stack that runs.
6
10
 
11
+ const crypto = require('crypto');
7
12
  const fs = require('fs');
8
13
  const path = require('path');
9
14
  const { spawn } = require('child_process');
10
15
  const ui = require('../ui');
11
16
  const { CliError } = require('../context');
12
17
 
18
+ const COMPOSE_FILE = 'docker-compose.yml';
19
+ const ENV_FILE = '.env';
20
+
21
+ // Where the images are built from when the caller has no checkout. Docker
22
+ // Compose accepts a git URL as a build context, so a scaffolded directory needs
23
+ // nothing but Docker: no clone, no registry, no published image. The first `up`
24
+ // pays for the build; every one after that is cached.
25
+ const DEFAULT_REPO = 'https://github.com/JulienGuinot/BoilerPlate-Backend.git';
26
+ const DEFAULT_REF = 'master';
27
+
13
28
  function dockerCompose(composeArgs) {
14
29
  return new Promise((resolve, reject) => {
15
30
  const child = spawn('docker', ['compose', ...composeArgs], { stdio: 'inherit', windowsHide: true });
@@ -18,47 +33,241 @@ function dockerCompose(composeArgs) {
18
33
  });
19
34
  }
20
35
 
36
+ /**
37
+ * Refuse to run compose from a directory that has no stack, and say what to do.
38
+ * Without this the user gets Docker's own "no configuration file provided",
39
+ * which does not mention `motherbase init`.
40
+ */
41
+ function assertStack(ctx) {
42
+ const file = path.join(ctx.cwd, COMPOSE_FILE);
43
+ if (!fs.existsSync(file)) {
44
+ throw new CliError(
45
+ `no ${COMPOSE_FILE} here. Run \`motherbase init\` first, or cd into a directory that has one.`,
46
+ );
47
+ }
48
+ }
49
+
50
+ const hex = n => crypto.randomBytes(n).toString('hex');
51
+ const b64 = n => crypto.randomBytes(n).toString('base64');
52
+
53
+ function renderEnv(context) {
54
+ return [
55
+ '# Generated by `motherbase init`. Secrets below are unique to this install.',
56
+ '# Keep this file out of version control.',
57
+ '',
58
+ `PORT=${context.port}`,
59
+ `BASE_HOST=localhost:${context.port}`,
60
+ 'NODE_ENV=production',
61
+ '',
62
+ '# Secrets. Regenerating any of these invalidates what it protects:',
63
+ '# JWT_SECRET signs sessions, CONFIG_ENCRYPTION_KEY encrypts Studio-managed',
64
+ '# secrets (SMTP, OAuth credentials) at rest.',
65
+ `JWT_SECRET=${hex(32)}`,
66
+ `CONFIG_ENCRYPTION_KEY=${b64(32)}`,
67
+ `POSTGRES_PASSWORD=${hex(16)}`,
68
+ `S3_ACCESS_KEY_ID=${hex(8)}`,
69
+ `S3_SECRET_ACCESS_KEY=${hex(20)}`,
70
+ '',
71
+ 'S3_BUCKET=uploads',
72
+ 'S3_PUBLIC_BUCKET=public',
73
+ 'S3_REGION=us-east-1',
74
+ '',
75
+ '# Host ports. Change these if something already listens on them.',
76
+ `POSTGRES_PORT=${context.postgresPort}`,
77
+ 'MINIO_PORT=59000',
78
+ 'MINIO_CONSOLE_PORT=59001',
79
+ '',
80
+ '# First operator account, created on first boot.',
81
+ `SEED_ADMIN_EMAIL=${context.adminEmail}`,
82
+ `SEED_ADMIN_PASSWORD=${context.adminPassword}`,
83
+ '',
84
+ ].join('\n');
85
+ }
86
+
87
+ function renderCompose(context) {
88
+ const buildContext = `${context.repo}#${context.ref}`;
89
+ return `# Generated by \`motherbase init\`.
90
+ #
91
+ # Images are built from the public repository, so this directory needs Docker and
92
+ # nothing else: no checkout, no registry login. The first \`up\` builds them, which
93
+ # takes a few minutes; after that the layers are cached.
94
+ #
95
+ # Working from a checkout instead? Replace both \`context:\` values with the path
96
+ # to it and the build becomes local and incremental.
97
+ services:
98
+ db:
99
+ build:
100
+ context: ${buildContext}
101
+ dockerfile: deploy/postgres.Dockerfile
102
+ command: ['postgres', '-c', 'shared_preload_libraries=pg_cron,pg_stat_statements', '-c', 'cron.database_name=app']
103
+ restart: unless-stopped
104
+ environment:
105
+ POSTGRES_USER: postgres
106
+ POSTGRES_PASSWORD: \${POSTGRES_PASSWORD}
107
+ POSTGRES_DB: app
108
+ ports:
109
+ # Loopback only: reachable by psql, Prisma, Drizzle or a BI client on this
110
+ # machine, and by nothing on the network.
111
+ - '127.0.0.1:\${POSTGRES_PORT:-55432}:5432'
112
+ volumes:
113
+ - db_data:/var/lib/postgresql/data
114
+ healthcheck:
115
+ test: ['CMD-SHELL', 'pg_isready -U postgres']
116
+ interval: 5s
117
+ timeout: 5s
118
+ retries: 10
119
+
120
+ minio:
121
+ image: alpine/minio:RELEASE.2025-10-15T17-29-55Z
122
+ restart: unless-stopped
123
+ user: "0:0"
124
+ command: server /data --console-address ":9001"
125
+ environment:
126
+ MINIO_ROOT_USER: \${S3_ACCESS_KEY_ID}
127
+ MINIO_ROOT_PASSWORD: \${S3_SECRET_ACCESS_KEY}
128
+ ports:
129
+ - '127.0.0.1:\${MINIO_PORT:-59000}:9000'
130
+ - '127.0.0.1:\${MINIO_CONSOLE_PORT:-59001}:9001'
131
+ volumes:
132
+ - minio_data:/data
133
+ healthcheck:
134
+ test: ['CMD-SHELL', 'wget -qO- http://localhost:9000/minio/health/live >/dev/null']
135
+ interval: 5s
136
+ timeout: 5s
137
+ retries: 10
138
+
139
+ # One-shot: a private bucket and a separate anonymously-downloadable one.
140
+ createbuckets:
141
+ image: quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z
142
+ depends_on:
143
+ minio:
144
+ condition: service_healthy
145
+ entrypoint: >
146
+ /bin/sh -c "
147
+ mc alias set local http://minio:9000 \${S3_ACCESS_KEY_ID} \${S3_SECRET_ACCESS_KEY};
148
+ mc mb --ignore-existing local/\${S3_BUCKET:-uploads};
149
+ mc anonymous set none local/\${S3_BUCKET:-uploads};
150
+ mc mb --ignore-existing local/\${S3_PUBLIC_BUCKET:-public};
151
+ mc anonymous set download local/\${S3_PUBLIC_BUCKET:-public};
152
+ echo 'buckets ready';
153
+ "
154
+
155
+ api:
156
+ build:
157
+ context: ${buildContext}
158
+ restart: unless-stopped
159
+ depends_on:
160
+ db:
161
+ condition: service_healthy
162
+ minio:
163
+ condition: service_healthy
164
+ createbuckets:
165
+ condition: service_completed_successfully
166
+ env_file:
167
+ - ${ENV_FILE}
168
+ # Container-internal wiring, overriding the localhost values in .env so the
169
+ # same file works for both host tooling and the containers.
170
+ environment:
171
+ NODE_ENV: production
172
+ PORT: 5001
173
+ DATABASE_URL: postgresql://postgres:\${POSTGRES_PASSWORD}@db:5432/app?schema=public
174
+ STORAGE_DRIVER: s3
175
+ S3_ENDPOINT: http://minio:9000
176
+ DB_PORT: \${POSTGRES_PORT:-55432}
177
+ ports:
178
+ - '\${PORT:-5001}:5001'
179
+
180
+ volumes:
181
+ db_data:
182
+ minio_data:
183
+ `;
184
+ }
185
+
21
186
  const init = {
22
- summary: 'Scaffold a local project directory with a .env',
23
- usage: 'motherbase init [dir]',
24
- async run(ctx, args) {
187
+ summary: 'Scaffold a runnable local stack (compose file, .env, generated secrets)',
188
+ usage: 'motherbase init [dir] [--port 5001] [--repo URL] [--ref BRANCH] [--force]',
189
+ async run(ctx, args, flags = {}) {
25
190
  const dir = args[0] || '.';
26
191
  const target = path.resolve(ctx.cwd, dir);
27
192
  fs.mkdirSync(target, { recursive: true });
28
- const envPath = path.join(target, '.env');
29
- if (!fs.existsSync(envPath)) {
30
- const example = path.join(ctx.cwd, '.env.example');
31
- const template = fs.existsSync(example)
32
- ? fs.readFileSync(example, 'utf8')
33
- : 'PORT=5001\nDATABASE_URL=postgresql://postgres:postgres@localhost:5432/app?schema=public\nJWT_SECRET=change-me\n';
34
- fs.writeFileSync(envPath, template);
193
+
194
+ const context = {
195
+ port: Number(flags.port) || 5001,
196
+ postgresPort: Number(flags['postgres-port']) || 55432,
197
+ repo: flags.repo || DEFAULT_REPO,
198
+ ref: flags.ref || DEFAULT_REF,
199
+ adminEmail: flags['admin-email'] || 'admin@example.com',
200
+ // Generated, never a placeholder: a scaffold that ships `change-me` is a
201
+ // scaffold that reaches production with `change-me`.
202
+ adminPassword: flags['admin-password'] || crypto.randomBytes(12).toString('base64url'),
203
+ };
204
+
205
+ const written = [];
206
+ const skipped = [];
207
+ for (const [name, render] of [[ENV_FILE, renderEnv], [COMPOSE_FILE, renderCompose]]) {
208
+ const file = path.join(target, name);
209
+ // Never silently overwrite: .env holds the only copy of secrets that
210
+ // existing data was encrypted with.
211
+ if (fs.existsSync(file) && !flags.force) {
212
+ skipped.push(name);
213
+ continue;
214
+ }
215
+ fs.writeFileSync(file, render(context), name === ENV_FILE ? { mode: 0o600 } : undefined);
216
+ written.push(name);
35
217
  }
218
+
36
219
  ui.success(`Initialized Motherbase project at ${target}`);
220
+ if (written.length) ui.info(`wrote ${written.join(', ')}`);
221
+ if (skipped.length) {
222
+ ui.warn(`kept existing ${skipped.join(', ')} (use --force to overwrite, which discards their secrets)`);
223
+ }
224
+ if (written.includes(ENV_FILE)) {
225
+ ui.print('');
226
+ ui.print(` Studio: http://localhost:${context.port}`);
227
+ ui.print(` Sign in: ${context.adminEmail} / ${context.adminPassword}`);
228
+ ui.print('');
229
+ ui.print(' Next:');
230
+ if (dir !== '.') ui.print(` cd ${dir}`);
231
+ ui.print(' motherbase start # first run builds the images, give it a few minutes');
232
+ ui.print('');
233
+ }
37
234
  },
38
235
  };
39
236
 
40
237
  const start = {
41
238
  summary: 'Start the local docker compose stack',
42
239
  usage: 'motherbase start',
43
- async run() { await dockerCompose(['up', '-d']); },
240
+ async run(ctx) {
241
+ assertStack(ctx);
242
+ await dockerCompose(['up', '-d']);
243
+ },
44
244
  };
45
245
 
46
246
  const stop = {
47
247
  summary: 'Stop the local docker compose stack',
48
248
  usage: 'motherbase stop',
49
- async run() { await dockerCompose(['down']); },
249
+ async run(ctx) {
250
+ assertStack(ctx);
251
+ await dockerCompose(['down']);
252
+ },
50
253
  };
51
254
 
52
255
  const deploy = {
53
256
  summary: 'Rebuild and start the local docker compose stack',
54
257
  usage: 'motherbase deploy',
55
- async run() { await dockerCompose(['up', '-d', '--build']); },
258
+ async run(ctx) {
259
+ assertStack(ctx);
260
+ await dockerCompose(['up', '-d', '--build']);
261
+ },
56
262
  };
57
263
 
58
264
  const logs = {
59
265
  summary: 'Follow logs from the local docker compose stack',
60
266
  usage: 'motherbase logs [service...]',
61
- async run(ctx, args) { await dockerCompose(['logs', '-f', ...args]); },
267
+ async run(ctx, args) {
268
+ assertStack(ctx);
269
+ await dockerCompose(['logs', '-f', ...args]);
270
+ },
62
271
  };
63
272
 
64
- module.exports = { init, start, stop, deploy, logs };
273
+ module.exports = { init, start, stop, deploy, logs, renderEnv, renderCompose };
@@ -0,0 +1,61 @@
1
+ 'use strict';
2
+
3
+ // `motherbase mcp`: expose the linked project, and the console's organizations
4
+ // and projects, to an AI agent over the Model Context Protocol.
5
+ //
6
+ // The transport is stdio, which is how an MCP client launches a server: it spawns
7
+ // this process and talks JSON-RPC over its stdin/stdout. Nothing is printed to
8
+ // stdout except protocol messages, so diagnostics go to stderr.
9
+
10
+ const { serve, toolServer } = require('../mcp/protocol');
11
+ const { buildTools } = require('../mcp/tools');
12
+ const { CliError } = require('../context');
13
+
14
+ const pkg = require('../../package.json');
15
+
16
+ /** Whether a `require*()` resolves, without surfacing its error. */
17
+ function reaches(resolve) {
18
+ try {
19
+ resolve();
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ const mcp = {
27
+ summary: 'Serve the linked project and console to an AI agent over MCP (stdio)',
28
+ usage: 'motherbase mcp [--write]',
29
+ async run(ctx, _args, flags = {}) {
30
+ // Resolve what this session reaches up front. Failing here is a clear CLI
31
+ // error; failing on the first tool call is an opaque one inside someone's agent.
32
+ const scope = { project: reaches(() => ctx.requireProject()), console: reaches(() => ctx.requireConsole()) };
33
+ if (!scope.project && !scope.console) {
34
+ throw new CliError(
35
+ 'Nothing to serve. Run `motherbase login` for the organization and project tools, and `motherbase link` '
36
+ + '(or set MOTHERBASE_URL + MOTHERBASE_ADMIN_TOKEN) for the database tools.',
37
+ );
38
+ }
39
+
40
+ const write = Boolean(flags.write);
41
+ const tools = buildTools(ctx, { write, ...scope });
42
+
43
+ process.stderr.write(
44
+ `motherbase mcp: ${tools.length} tools, ${write ? 'read and write' : 'read only'}. `
45
+ + `Project: ${scope.project ? ctx.projectName || ctx.projectId || 'direct' : 'none linked'}. `
46
+ + `Console: ${scope.console ? ctx.consoleUrl : 'not logged in'}\n`,
47
+ );
48
+ if (!write) {
49
+ process.stderr.write(' Pass --write to expose apply_migration, raw SQL writes, function deploys and project lifecycle.\n');
50
+ }
51
+
52
+ await serve({
53
+ input: process.stdin,
54
+ output: process.stdout,
55
+ handlers: toolServer({ name: 'motherbase', version: pkg.version, tools }),
56
+ onError: err => process.stderr.write(`motherbase mcp: ${err && err.message ? err.message : err}\n`),
57
+ });
58
+ },
59
+ };
60
+
61
+ module.exports = mcp;
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+
3
+ // `motherbase migrate codemod <dir>`: port a Supabase codebase's imports, and
4
+ // report what this SDK does not implement.
5
+ //
6
+ // The rewrite is trivial and the report is the point. `@motherbase/client` is
7
+ // supabase-js shaped, so most files need one import line changed; what breaks a
8
+ // port is the handful of calls that do not exist here, and finding those at
9
+ // build time beats finding them when a user hits that screen.
10
+ //
11
+ // Deliberately a text pass, not an AST pass. A codemod that needs a TypeScript
12
+ // parser needs typescript as a CLI dependency, and the patterns below are
13
+ // import specifiers and method names, which do not need one. The cost is that a
14
+ // match inside a comment or a string is still reported: over-reporting in a
15
+ // list a human reads is the acceptable direction to be wrong in.
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { output } = require('./_util');
20
+ const { CliError } = require('../context');
21
+
22
+ const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts']);
23
+ const SKIP_DIRECTORIES = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', '.turbo']);
24
+
25
+ /** Import specifiers that map one to one. */
26
+ const IMPORT_REWRITES = [
27
+ { from: '@supabase/supabase-js', to: '@motherbase/client' },
28
+ { from: '@supabase/ssr', to: '@motherbase/ssr' },
29
+ ];
30
+
31
+ /**
32
+ * Calls this SDK does not implement, and what to do instead.
33
+ *
34
+ * Kept short and kept true: every entry here is a row marked `Not supported` in
35
+ * packages/client/COMPATIBILITY.md. An entry that becomes supported and is left
36
+ * behind sends people to rewrite working code, so this list is part of the same
37
+ * change as implementing the feature.
38
+ */
39
+ const UNSUPPORTED = [
40
+ {
41
+ pattern: /\.schema\(\s*['"`](?!public['"`])/g,
42
+ what: 'schema() on a schema other than public',
43
+ advice: 'The REST API exposes public only. Move the tables, or expose them through a view or an RPC.',
44
+ },
45
+ {
46
+ pattern: /exchangeCodeForSession\s*\(/g,
47
+ what: 'auth.exchangeCodeForSession()',
48
+ advice: 'This install runs the implicit flow. Read the session client side with auth.getSessionFromUrl().',
49
+ },
50
+ {
51
+ pattern: /signInAnonymously\s*\(/g,
52
+ what: 'auth.signInAnonymously()',
53
+ advice: 'Not implemented. Create a real user, or gate the feature behind sign-in.',
54
+ },
55
+ {
56
+ pattern: /transform\s*:\s*\{/g,
57
+ what: 'storage image transform options',
58
+ advice: 'No image pipeline yet. Store the sizes you need at upload time.',
59
+ },
60
+ {
61
+ pattern: /\.delete\(\s*\)\s*(?![\s\S]{0,200}?\.(eq|neq|in|gt|gte|lt|lte|like|ilike|is|filter|match|or|not)\()/g,
62
+ what: 'delete() with no filter',
63
+ advice: 'Refused with 400 on purpose, so a missing filter cannot empty a table. Add a filter.',
64
+ },
65
+ ];
66
+
67
+ function walk(dir, files = []) {
68
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
69
+ if (entry.name.startsWith('.') && entry.name !== '.') continue;
70
+ const full = path.join(dir, entry.name);
71
+ if (entry.isDirectory()) {
72
+ if (SKIP_DIRECTORIES.has(entry.name)) continue;
73
+ walk(full, files);
74
+ } else if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
75
+ files.push(full);
76
+ }
77
+ }
78
+ return files;
79
+ }
80
+
81
+ /** Line number of an offset, 1-based, for a report a human can act on. */
82
+ function lineAt(text, index) {
83
+ let line = 1;
84
+ for (let i = 0; i < index && i < text.length; i++) if (text[i] === '\n') line++;
85
+ return line;
86
+ }
87
+
88
+ function rewriteImports(text) {
89
+ let out = text;
90
+ let changed = 0;
91
+ for (const { from, to } of IMPORT_REWRITES) {
92
+ // Only inside a quoted specifier, so a mention in prose is left alone.
93
+ const pattern = new RegExp(`(['"\`])${from.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\1`, 'g');
94
+ out = out.replace(pattern, match => {
95
+ changed++;
96
+ return match.replace(from, to);
97
+ });
98
+ }
99
+ return { text: out, changed };
100
+ }
101
+
102
+ function scanUnsupported(text, file) {
103
+ const findings = [];
104
+ for (const rule of UNSUPPORTED) {
105
+ rule.pattern.lastIndex = 0;
106
+ let match;
107
+ while ((match = rule.pattern.exec(text)) !== null) {
108
+ findings.push({ file, line: lineAt(text, match.index), what: rule.what, advice: rule.advice });
109
+ if (match.index === rule.pattern.lastIndex) rule.pattern.lastIndex++;
110
+ }
111
+ }
112
+ return findings;
113
+ }
114
+
115
+ const codemod = {
116
+ summary: 'Rewrite Supabase imports and report what this SDK does not support',
117
+ usage: 'motherbase migrate codemod <dir> [--write]',
118
+ async run(ctx, args, flags) {
119
+ const target = path.resolve(ctx.cwd, args[0] || '.');
120
+ if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
121
+ throw new CliError(`${target} is not a directory.`);
122
+ }
123
+
124
+ const files = walk(target);
125
+ const rewritten = [];
126
+ const findings = [];
127
+
128
+ for (const file of files) {
129
+ const original = fs.readFileSync(file, 'utf8');
130
+ const { text, changed } = rewriteImports(original);
131
+ if (changed) {
132
+ rewritten.push({ file: path.relative(ctx.cwd, file), imports: changed });
133
+ // Scan the rewritten text so a line number matches what the file will
134
+ // hold after --write, not what it held before.
135
+ if (flags.write) fs.writeFileSync(file, text, 'utf8');
136
+ }
137
+ findings.push(...scanUnsupported(text, path.relative(ctx.cwd, file)));
138
+ }
139
+
140
+ const result = {
141
+ root: target,
142
+ scanned: files.length,
143
+ rewritten,
144
+ written: Boolean(flags.write),
145
+ findings,
146
+ };
147
+
148
+ output(ctx, result, (data, ui) => {
149
+ const verb = data.written ? 'Rewrote' : 'Would rewrite';
150
+ ui.info(`Scanned ${data.scanned} files under ${data.root}`);
151
+ if (data.rewritten.length) {
152
+ ui.info(`${verb} imports in ${data.rewritten.length} file(s):`);
153
+ for (const entry of data.rewritten) ui.info(` ${entry.file} (${entry.imports})`);
154
+ } else {
155
+ ui.info('No Supabase imports found.');
156
+ }
157
+ if (!data.written && data.rewritten.length) ui.info('Re-run with --write to apply.');
158
+
159
+ if (!data.findings.length) {
160
+ ui.success('No unsupported calls found.');
161
+ return;
162
+ }
163
+ ui.info('');
164
+ ui.info(`${data.findings.length} call(s) this SDK does not support:`);
165
+ for (const finding of data.findings) {
166
+ ui.info(` ${finding.file}:${finding.line} ${finding.what}`);
167
+ ui.info(` ${finding.advice}`);
168
+ }
169
+ });
170
+ },
171
+ };
172
+
173
+ module.exports = {
174
+ summary: 'Port an application from Supabase',
175
+ usage: 'motherbase migrate codemod <dir> [--write]',
176
+ subcommands: { codemod },
177
+ // Exported for the unit test: the scanner is the part worth pinning.
178
+ _internals: { rewriteImports, scanUnsupported },
179
+ };