@motherbase/cli 0.2.6 → 0.2.9

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 (62) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +8 -1
  3. package/bin/.motherbase/config.json +5 -0
  4. package/bin/motherbase.js +10 -10
  5. package/package.json +4 -4
  6. package/src/api.js +39 -16
  7. package/src/commands/_util.js +106 -106
  8. package/src/commands/backups.js +16 -16
  9. package/src/commands/branches.js +113 -113
  10. package/src/commands/completion.js +90 -90
  11. package/src/commands/config.js +47 -47
  12. package/src/commands/cron.js +65 -65
  13. package/src/commands/db-catalog.js +39 -1
  14. package/src/commands/db.js +103 -103
  15. package/src/commands/domains.js +124 -124
  16. package/src/commands/gen.js +178 -172
  17. package/src/commands/import.js +55 -55
  18. package/src/commands/index.js +86 -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 +101 -24
  23. package/src/commands/login.js +9 -3
  24. package/src/commands/logout.js +16 -16
  25. package/src/commands/mcp.js +61 -61
  26. package/src/commands/migrate.js +0 -5
  27. package/src/commands/migration.js +1 -1
  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 +131 -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 +29 -1
  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 -106
  49. package/src/edge-templates.js +194 -194
  50. package/src/format.js +92 -92
  51. package/src/inspect/csv.js +22 -22
  52. package/src/inspect/queries.js +562 -562
  53. package/src/main.js +145 -66
  54. package/src/mcp/platform-tools.js +233 -233
  55. package/src/mcp/protocol.js +152 -152
  56. package/src/mcp/storage-tools.js +93 -93
  57. package/src/mcp/tools.js +478 -478
  58. package/src/migration/commands.js +2 -2
  59. package/src/migration/files.js +13 -0
  60. package/src/prompt.js +91 -91
  61. package/src/storage/remote.js +62 -62
  62. package/src/ui.js +111 -111
@@ -8,7 +8,7 @@
8
8
  //
9
9
  // Registered from commands/migration.js, commands/db.js and commands/test.js.
10
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).
11
+ // side is /api/admin/db (apps/server controllers/admin/admin-db.controller.ts).
12
12
 
13
13
  const fs = require('fs');
14
14
  const path = require('path');
@@ -81,7 +81,7 @@ function writeMigration(ctx, name, sql) {
81
81
  const version = files.versionAt();
82
82
  const file = path.join(ctx.cwd, files.MIGRATIONS_DIR, files.fileNameFor(version, name));
83
83
  fs.mkdirSync(path.dirname(file), { recursive: true });
84
- fs.writeFileSync(file, files.renderFile(sql));
84
+ fs.writeFileSync(file, files.renderFile(sql), { flag: 'wx' });
85
85
  return { version, name, file: path.relative(ctx.cwd, file) };
86
86
  }
87
87
 
@@ -53,6 +53,16 @@ function byVersion(a, b) {
53
53
  return a.version < b.version ? -1 : a.version > b.version ? 1 : 0;
54
54
  }
55
55
 
56
+ function assertUniqueVersions(migrations) {
57
+ const seen = new Map();
58
+ for (const migration of migrations) {
59
+ if (seen.has(migration.version)) {
60
+ throw new CliError(`Duplicate migration version ${migration.version}: ${seen.get(migration.version)} and ${migration.name}. Give each migration a unique version before continuing.`);
61
+ }
62
+ seen.set(migration.version, migration.name);
63
+ }
64
+ }
65
+
56
66
  // ── File contents ────────────────────────────────────────────────────────────
57
67
 
58
68
  /** Split a file into up and down SQL at the `-- @down` marker. */
@@ -89,6 +99,7 @@ function readLocalMigrations(cwd) {
89
99
  const file = path.join(dir, fileName);
90
100
  migrations.push({ ...parsed, file, ...splitDown(fs.readFileSync(file, 'utf8')) });
91
101
  }
102
+ assertUniqueVersions(migrations);
92
103
  return { migrations: migrations.sort(byVersion), skipped };
93
104
  }
94
105
 
@@ -160,6 +171,7 @@ function readTests(cwd, only = []) {
160
171
  * as "on the remote", a rolled-back one is history, not schema.
161
172
  */
162
173
  function reconcile(local, remote) {
174
+ assertUniqueVersions(local);
163
175
  const rows = new Map();
164
176
  for (const file of local) rows.set(file.version, { version: file.version, name: file.name, local: file, remote: null });
165
177
  for (const entry of remote) {
@@ -207,6 +219,7 @@ function planPush(rows, { includeAll = false } = {}) {
207
219
  * reverse. A partial down would roll back a fraction of the squash.
208
220
  */
209
221
  function squash(migrations) {
222
+ assertUniqueVersions(migrations);
210
223
  if (migrations.length < 2) throw new CliError('Nothing to squash: need at least two migrations.');
211
224
  const ordered = [...migrations].sort(byVersion);
212
225
  const last = ordered[ordered.length - 1];
package/src/prompt.js CHANGED
@@ -1,91 +1,91 @@
1
- 'use strict';
2
-
3
- // readline-based prompting, including hidden password entry. Built-ins only.
4
-
5
- const readline = require('readline');
6
-
7
- function question(query) {
8
- return new Promise(resolve => {
9
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
10
- rl.question(query, answer => {
11
- rl.close();
12
- resolve(answer.trim());
13
- });
14
- });
15
- }
16
-
17
- // Hidden input. Reads the TTY in raw mode so nothing echoes, masking each typed
18
- // character with '*'. Handles backspace, Enter, Ctrl+C, Ctrl+D and swallows
19
- // escape sequences (arrow keys etc.) so they never land in the value. When
20
- // stdin is not a TTY (pipes, CI) we fall back to a plain, unmasked line read.
21
- function password(query = 'Password: ') {
22
- const stdin = process.stdin;
23
- const stdout = process.stdout;
24
- if (!stdin.isTTY) return question(query);
25
-
26
- return new Promise(resolve => {
27
- let value = '';
28
- let inEscape = false;
29
- stdout.write(query);
30
- stdin.setRawMode(true);
31
- stdin.resume();
32
- stdin.setEncoding('utf8');
33
-
34
- const done = result => {
35
- stdin.removeListener('data', onData);
36
- stdin.setRawMode(false);
37
- stdin.pause();
38
- stdout.write('\n');
39
- resolve(result);
40
- };
41
-
42
- const onData = chunk => {
43
- for (const ch of chunk) {
44
- if (inEscape) {
45
- // Terminators of a CSI/escape sequence (e.g. arrow keys): stop swallowing.
46
- if (/[a-zA-Z~]/.test(ch)) inEscape = false;
47
- continue;
48
- }
49
- switch (ch) {
50
- case '\r':
51
- case '\n':
52
- case '\u0004': // Ctrl-D
53
- return done(value);
54
- case '\u0003': // Ctrl-C
55
- stdin.setRawMode(false);
56
- stdin.pause();
57
- stdout.write('\n');
58
- process.exit(130);
59
- return;
60
- case '\u007f': // Backspace / Delete
61
- case '\b':
62
- if (value.length > 0) {
63
- value = value.slice(0, -1);
64
- stdout.write('\b \b');
65
- }
66
- break;
67
- case '\u001b': // ESC: start of an escape sequence
68
- inEscape = true;
69
- break;
70
- default:
71
- // Ignore remaining control characters; mask printable ones.
72
- if (ch >= ' ') {
73
- value += ch;
74
- stdout.write('*');
75
- }
76
- }
77
- }
78
- };
79
-
80
- stdin.on('data', onData);
81
- });
82
- }
83
-
84
- async function confirm(query, defaultYes = false) {
85
- const suffix = defaultYes ? ' [Y/n] ' : ' [y/N] ';
86
- const answer = (await question(query + suffix)).toLowerCase();
87
- if (!answer) return defaultYes;
88
- return answer === 'y' || answer === 'yes';
89
- }
90
-
91
- module.exports = { question, password, confirm };
1
+ 'use strict';
2
+
3
+ // readline-based prompting, including hidden password entry. Built-ins only.
4
+
5
+ const readline = require('readline');
6
+
7
+ function question(query) {
8
+ return new Promise(resolve => {
9
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
10
+ rl.question(query, answer => {
11
+ rl.close();
12
+ resolve(answer.trim());
13
+ });
14
+ });
15
+ }
16
+
17
+ // Hidden input. Reads the TTY in raw mode so nothing echoes, masking each typed
18
+ // character with '*'. Handles backspace, Enter, Ctrl+C, Ctrl+D and swallows
19
+ // escape sequences (arrow keys etc.) so they never land in the value. When
20
+ // stdin is not a TTY (pipes, CI) we fall back to a plain, unmasked line read.
21
+ function password(query = 'Password: ') {
22
+ const stdin = process.stdin;
23
+ const stdout = process.stdout;
24
+ if (!stdin.isTTY) return question(query);
25
+
26
+ return new Promise(resolve => {
27
+ let value = '';
28
+ let inEscape = false;
29
+ stdout.write(query);
30
+ stdin.setRawMode(true);
31
+ stdin.resume();
32
+ stdin.setEncoding('utf8');
33
+
34
+ const done = result => {
35
+ stdin.removeListener('data', onData);
36
+ stdin.setRawMode(false);
37
+ stdin.pause();
38
+ stdout.write('\n');
39
+ resolve(result);
40
+ };
41
+
42
+ const onData = chunk => {
43
+ for (const ch of chunk) {
44
+ if (inEscape) {
45
+ // Terminators of a CSI/escape sequence (e.g. arrow keys): stop swallowing.
46
+ if (/[a-zA-Z~]/.test(ch)) inEscape = false;
47
+ continue;
48
+ }
49
+ switch (ch) {
50
+ case '\r':
51
+ case '\n':
52
+ case '\u0004': // Ctrl-D
53
+ return done(value);
54
+ case '\u0003': // Ctrl-C
55
+ stdin.setRawMode(false);
56
+ stdin.pause();
57
+ stdout.write('\n');
58
+ process.exit(130);
59
+ return;
60
+ case '\u007f': // Backspace / Delete
61
+ case '\b':
62
+ if (value.length > 0) {
63
+ value = value.slice(0, -1);
64
+ stdout.write('\b \b');
65
+ }
66
+ break;
67
+ case '\u001b': // ESC: start of an escape sequence
68
+ inEscape = true;
69
+ break;
70
+ default:
71
+ // Ignore remaining control characters; mask printable ones.
72
+ if (ch >= ' ') {
73
+ value += ch;
74
+ stdout.write('*');
75
+ }
76
+ }
77
+ }
78
+ };
79
+
80
+ stdin.on('data', onData);
81
+ });
82
+ }
83
+
84
+ async function confirm(query, defaultYes = false) {
85
+ const suffix = defaultYes ? ' [Y/n] ' : ' [y/N] ';
86
+ const answer = (await question(query + suffix)).toLowerCase();
87
+ if (!answer) return defaultYes;
88
+ return answer === 'y' || answer === 'yes';
89
+ }
90
+
91
+ module.exports = { question, password, confirm };
@@ -1,62 +1,62 @@
1
- 'use strict';
2
-
3
- // Storage paths in the Supabase CLI's form: `ss:///<bucket>/<key>`. Anything
4
- // else is a local path. Kept free of I/O so the path rules can be tested alone.
5
-
6
- const path = require('path');
7
- const { CliError } = require('../context');
8
-
9
- const SCHEME = 'ss:///';
10
-
11
- const isRemote = value => typeof value === 'string' && value.startsWith('ss://');
12
-
13
- /** `ss:///bucket/a/b` → `{ bucket: 'bucket', key: 'a/b' }`; `ss:///` → `{ bucket: '', key: '' }`. */
14
- function parseRemote(value) {
15
- if (!value.startsWith(SCHEME)) {
16
- throw new CliError(`Storage paths look like ${SCHEME}<bucket>/<key>, got "${value}".`);
17
- }
18
- const [bucket = '', ...rest] = value.slice(SCHEME.length).split('/');
19
- return { bucket, key: rest.join('/') };
20
- }
21
-
22
- function requireBucket(remote, value) {
23
- if (!remote.bucket) throw new CliError(`"${value}" names no bucket.`);
24
- return remote;
25
- }
26
-
27
- /** A key as a URL path: every segment encoded, the slashes between them kept. */
28
- const encodeKey = key => key.split('/').map(encodeURIComponent).join('/');
29
-
30
- /** `a/b/` and `a/b` both mean the prefix `a/b/`; the empty prefix is the bucket root. */
31
- const asPrefix = key => (key ? key.replace(/\/*$/, '/') : '');
32
-
33
- /**
34
- * One directory level of a listing, as `ls` shows it: the objects directly
35
- * under `prefix`, and each deeper key collapsed into its first folder (with a
36
- * trailing slash), deduplicated, in order.
37
- */
38
- function childrenOf(keys, prefix) {
39
- const seen = new Set();
40
- for (const key of keys) {
41
- if (!key.startsWith(prefix)) continue;
42
- const rest = key.slice(prefix.length);
43
- const slash = rest.indexOf('/');
44
- seen.add(slash < 0 ? rest : rest.slice(0, slash + 1));
45
- }
46
- return [...seen].filter(Boolean);
47
- }
48
-
49
- /**
50
- * Where a single file lands. A destination ending in `/` (or the bucket root)
51
- * is a folder, so the source's basename is appended, as `cp` does locally.
52
- */
53
- function destinationKey(dstKey, sourceName) {
54
- return !dstKey || dstKey.endsWith('/') ? `${dstKey}${path.basename(sourceName)}` : dstKey;
55
- }
56
-
57
- /** The key under `toPrefix` that `key` maps to when moving/copying `fromPrefix`. */
58
- function rebase(key, fromPrefix, toPrefix) {
59
- return `${toPrefix}${key.slice(fromPrefix.length)}`;
60
- }
61
-
62
- module.exports = { SCHEME, isRemote, parseRemote, requireBucket, encodeKey, asPrefix, childrenOf, destinationKey, rebase };
1
+ 'use strict';
2
+
3
+ // Storage paths in the Supabase CLI's form: `ss:///<bucket>/<key>`. Anything
4
+ // else is a local path. Kept free of I/O so the path rules can be tested alone.
5
+
6
+ const path = require('path');
7
+ const { CliError } = require('../context');
8
+
9
+ const SCHEME = 'ss:///';
10
+
11
+ const isRemote = value => typeof value === 'string' && value.startsWith('ss://');
12
+
13
+ /** `ss:///bucket/a/b` → `{ bucket: 'bucket', key: 'a/b' }`; `ss:///` → `{ bucket: '', key: '' }`. */
14
+ function parseRemote(value) {
15
+ if (!value.startsWith(SCHEME)) {
16
+ throw new CliError(`Storage paths look like ${SCHEME}<bucket>/<key>, got "${value}".`);
17
+ }
18
+ const [bucket = '', ...rest] = value.slice(SCHEME.length).split('/');
19
+ return { bucket, key: rest.join('/') };
20
+ }
21
+
22
+ function requireBucket(remote, value) {
23
+ if (!remote.bucket) throw new CliError(`"${value}" names no bucket.`);
24
+ return remote;
25
+ }
26
+
27
+ /** A key as a URL path: every segment encoded, the slashes between them kept. */
28
+ const encodeKey = key => key.split('/').map(encodeURIComponent).join('/');
29
+
30
+ /** `a/b/` and `a/b` both mean the prefix `a/b/`; the empty prefix is the bucket root. */
31
+ const asPrefix = key => (key ? key.replace(/\/*$/, '/') : '');
32
+
33
+ /**
34
+ * One directory level of a listing, as `ls` shows it: the objects directly
35
+ * under `prefix`, and each deeper key collapsed into its first folder (with a
36
+ * trailing slash), deduplicated, in order.
37
+ */
38
+ function childrenOf(keys, prefix) {
39
+ const seen = new Set();
40
+ for (const key of keys) {
41
+ if (!key.startsWith(prefix)) continue;
42
+ const rest = key.slice(prefix.length);
43
+ const slash = rest.indexOf('/');
44
+ seen.add(slash < 0 ? rest : rest.slice(0, slash + 1));
45
+ }
46
+ return [...seen].filter(Boolean);
47
+ }
48
+
49
+ /**
50
+ * Where a single file lands. A destination ending in `/` (or the bucket root)
51
+ * is a folder, so the source's basename is appended, as `cp` does locally.
52
+ */
53
+ function destinationKey(dstKey, sourceName) {
54
+ return !dstKey || dstKey.endsWith('/') ? `${dstKey}${path.basename(sourceName)}` : dstKey;
55
+ }
56
+
57
+ /** The key under `toPrefix` that `key` maps to when moving/copying `fromPrefix`. */
58
+ function rebase(key, fromPrefix, toPrefix) {
59
+ return `${toPrefix}${key.slice(fromPrefix.length)}`;
60
+ }
61
+
62
+ module.exports = { SCHEME, isRemote, parseRemote, requireBucket, encodeKey, asPrefix, childrenOf, destinationKey, rebase };
package/src/ui.js CHANGED
@@ -1,111 +1,111 @@
1
- 'use strict';
2
-
3
- // Minimal, dependency-free presentation helpers: ANSI colour (auto-off when not a
4
- // TTY or NO_COLOR is set), a table printer, a key/value printer, a spinner, and a
5
- // --json passthrough.
6
-
7
- const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
8
-
9
- function wrap(code) {
10
- return s => (useColor ? `[${code}m${s}` : String(s));
11
- }
12
-
13
- const color = {
14
- bold: wrap('1'),
15
- dim: wrap('2'),
16
- red: wrap('31'),
17
- green: wrap('32'),
18
- yellow: wrap('33'),
19
- cyan: wrap('36'),
20
- gray: wrap('90'),
21
- };
22
-
23
- function print(msg = '') {
24
- process.stdout.write(msg + '\n');
25
- }
26
-
27
- function printErr(msg = '') {
28
- process.stderr.write(msg + '\n');
29
- }
30
-
31
- function success(msg) {
32
- print(color.green('✓ ') + msg);
33
- }
34
-
35
- function info(msg) {
36
- print(color.cyan('• ') + msg);
37
- }
38
-
39
- function warn(msg) {
40
- printErr(color.yellow('! ') + msg);
41
- }
42
-
43
- function json(value) {
44
- print(JSON.stringify(value, null, 2));
45
- }
46
-
47
- // Render an array of flat objects as an aligned table using the given columns
48
- // (array of keys or { key, label } specs). Falls back to a friendly note if empty.
49
- function table(rows, columns) {
50
- if (!Array.isArray(rows) || rows.length === 0) {
51
- print(color.dim('(no rows)'));
52
- return;
53
- }
54
- const cols = (columns || Object.keys(rows[0])).map(c =>
55
- typeof c === 'string' ? { key: c, label: c } : c,
56
- );
57
- const widths = cols.map(c => c.label.length);
58
- const cells = rows.map(row =>
59
- cols.map((c, i) => {
60
- const v = row[c.key];
61
- const s = v === undefined || v === null ? '' : String(v);
62
- if (s.length > widths[i]) widths[i] = s.length;
63
- return s;
64
- }),
65
- );
66
- const header = cols.map((c, i) => color.bold(c.label.padEnd(widths[i]))).join(' ');
67
- print(header);
68
- print(color.dim(cols.map((_, i) => '-'.repeat(widths[i])).join(' ')));
69
- for (const rowCells of cells) {
70
- print(rowCells.map((s, i) => s.padEnd(widths[i])).join(' '));
71
- }
72
- }
73
-
74
- // Print a { key: value } object as aligned "key : value" lines.
75
- function keyValue(obj) {
76
- if (!obj || typeof obj !== 'object') {
77
- print(String(obj));
78
- return;
79
- }
80
- const keys = Object.keys(obj);
81
- const w = keys.reduce((m, k) => Math.max(m, k.length), 0);
82
- for (const k of keys) {
83
- let v = obj[k];
84
- if (v && typeof v === 'object') v = JSON.stringify(v);
85
- print(`${color.gray(k.padEnd(w))} ${v === undefined || v === null ? '' : v}`);
86
- }
87
- }
88
-
89
- // A tiny spinner. No-op (single status line) when stderr is not a TTY.
90
- function spinner(text) {
91
- const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
92
- if (!process.stderr.isTTY) {
93
- printErr(color.dim(text + ' ...'));
94
- return { stop() {}, succeed() {}, fail() {} };
95
- }
96
- let i = 0;
97
- const timer = setInterval(() => {
98
- process.stderr.write(`\r${color.cyan(frames[i++ % frames.length])} ${text}`);
99
- }, 80);
100
- const clear = () => {
101
- clearInterval(timer);
102
- process.stderr.write('\r');
103
- };
104
- return {
105
- stop: clear,
106
- succeed(msg) { clear(); if (msg) printErr(color.green('✓ ') + msg); },
107
- fail(msg) { clear(); if (msg) printErr(color.red('✗ ') + msg); },
108
- };
109
- }
110
-
111
- module.exports = { color, print, printErr, success, info, warn, json, table, keyValue, spinner };
1
+ 'use strict';
2
+
3
+ // Minimal, dependency-free presentation helpers: ANSI colour (auto-off when not a
4
+ // TTY or NO_COLOR is set), a table printer, a key/value printer, a spinner, and a
5
+ // --json passthrough.
6
+
7
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
8
+
9
+ function wrap(code) {
10
+ return s => (useColor ? `[${code}m${s}` : String(s));
11
+ }
12
+
13
+ const color = {
14
+ bold: wrap('1'),
15
+ dim: wrap('2'),
16
+ red: wrap('31'),
17
+ green: wrap('32'),
18
+ yellow: wrap('33'),
19
+ cyan: wrap('36'),
20
+ gray: wrap('90'),
21
+ };
22
+
23
+ function print(msg = '') {
24
+ process.stdout.write(msg + '\n');
25
+ }
26
+
27
+ function printErr(msg = '') {
28
+ process.stderr.write(msg + '\n');
29
+ }
30
+
31
+ function success(msg) {
32
+ print(color.green('✓ ') + msg);
33
+ }
34
+
35
+ function info(msg) {
36
+ print(color.cyan('• ') + msg);
37
+ }
38
+
39
+ function warn(msg) {
40
+ printErr(color.yellow('! ') + msg);
41
+ }
42
+
43
+ function json(value) {
44
+ print(JSON.stringify(value, null, 2));
45
+ }
46
+
47
+ // Render an array of flat objects as an aligned table using the given columns
48
+ // (array of keys or { key, label } specs). Falls back to a friendly note if empty.
49
+ function table(rows, columns) {
50
+ if (!Array.isArray(rows) || rows.length === 0) {
51
+ print(color.dim('(no rows)'));
52
+ return;
53
+ }
54
+ const cols = (columns || Object.keys(rows[0])).map(c =>
55
+ typeof c === 'string' ? { key: c, label: c } : c,
56
+ );
57
+ const widths = cols.map(c => c.label.length);
58
+ const cells = rows.map(row =>
59
+ cols.map((c, i) => {
60
+ const v = row[c.key];
61
+ const s = v === undefined || v === null ? '' : String(v);
62
+ if (s.length > widths[i]) widths[i] = s.length;
63
+ return s;
64
+ }),
65
+ );
66
+ const header = cols.map((c, i) => color.bold(c.label.padEnd(widths[i]))).join(' ');
67
+ print(header);
68
+ print(color.dim(cols.map((_, i) => '-'.repeat(widths[i])).join(' ')));
69
+ for (const rowCells of cells) {
70
+ print(rowCells.map((s, i) => s.padEnd(widths[i])).join(' '));
71
+ }
72
+ }
73
+
74
+ // Print a { key: value } object as aligned "key : value" lines.
75
+ function keyValue(obj) {
76
+ if (!obj || typeof obj !== 'object') {
77
+ print(String(obj));
78
+ return;
79
+ }
80
+ const keys = Object.keys(obj);
81
+ const w = keys.reduce((m, k) => Math.max(m, k.length), 0);
82
+ for (const k of keys) {
83
+ let v = obj[k];
84
+ if (v && typeof v === 'object') v = JSON.stringify(v);
85
+ print(`${color.gray(k.padEnd(w))} ${v === undefined || v === null ? '' : v}`);
86
+ }
87
+ }
88
+
89
+ // A tiny spinner. No-op (single status line) when stderr is not a TTY.
90
+ function spinner(text) {
91
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
92
+ if (!process.stderr.isTTY) {
93
+ printErr(color.dim(text + ' ...'));
94
+ return { stop() {}, succeed() {}, fail() {} };
95
+ }
96
+ let i = 0;
97
+ const timer = setInterval(() => {
98
+ process.stderr.write(`\r${color.cyan(frames[i++ % frames.length])} ${text}`);
99
+ }, 80);
100
+ const clear = () => {
101
+ clearInterval(timer);
102
+ process.stderr.write('\r');
103
+ };
104
+ return {
105
+ stop: clear,
106
+ succeed(msg) { clear(); if (msg) printErr(color.green('✓ ') + msg); },
107
+ fail(msg) { clear(); if (msg) printErr(color.red('✗ ') + msg); },
108
+ };
109
+ }
110
+
111
+ module.exports = { color, print, printErr, success, info, warn, json, table, keyValue, spinner };