@alexify/migronaut 1.0.0

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 (50) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/LICENSE +22 -0
  3. package/README.md +773 -0
  4. package/bin/migronaut.js +36 -0
  5. package/index.d.ts +903 -0
  6. package/index.js +1 -0
  7. package/migronaut.schema.json +135 -0
  8. package/package.json +105 -0
  9. package/src/cli/args.js +314 -0
  10. package/src/cli/commands/audit.js +40 -0
  11. package/src/cli/commands/create.js +29 -0
  12. package/src/cli/commands/down.js +30 -0
  13. package/src/cli/commands/dry-run.js +36 -0
  14. package/src/cli/commands/import.js +33 -0
  15. package/src/cli/commands/init.js +64 -0
  16. package/src/cli/commands/list.js +19 -0
  17. package/src/cli/commands/lock.js +35 -0
  18. package/src/cli/commands/redo.js +16 -0
  19. package/src/cli/commands/status.js +52 -0
  20. package/src/cli/commands/unlock.js +43 -0
  21. package/src/cli/commands/up.js +53 -0
  22. package/src/cli/exit-codes.js +38 -0
  23. package/src/cli/index.js +91 -0
  24. package/src/cli/shared.js +343 -0
  25. package/src/cli/spinner.js +59 -0
  26. package/src/cli/table.js +259 -0
  27. package/src/core/audit.js +152 -0
  28. package/src/core/changelog.js +231 -0
  29. package/src/core/config.js +479 -0
  30. package/src/core/context.js +16 -0
  31. package/src/core/import-runner.js +164 -0
  32. package/src/core/import.js +60 -0
  33. package/src/core/lock.js +348 -0
  34. package/src/core/migrator.js +1475 -0
  35. package/src/core/run.js +144 -0
  36. package/src/core/runner.js +150 -0
  37. package/src/errors/index.js +215 -0
  38. package/src/index.js +59 -0
  39. package/src/utils/checksum.js +23 -0
  40. package/src/utils/colors.js +68 -0
  41. package/src/utils/concurrency.js +32 -0
  42. package/src/utils/date.js +28 -0
  43. package/src/utils/env.js +51 -0
  44. package/src/utils/error.js +11 -0
  45. package/src/utils/loader.js +131 -0
  46. package/src/utils/logger.js +109 -0
  47. package/src/utils/redact.js +39 -0
  48. package/src/utils/sanitize.js +43 -0
  49. package/src/utils/template.js +615 -0
  50. package/src/utils/user.js +25 -0
@@ -0,0 +1,343 @@
1
+ const { createInterface } = require('node:readline/promises');
2
+ const { createSpinner } = require('./spinner.js');
3
+ const { ConfigInvalidError, MigronautError } = require('../errors/index.js');
4
+ const { errorText } = require('../utils/error.js');
5
+ const { createLogger } = require('../utils/logger.js');
6
+ const { redactDeep, redactUris } = require('../utils/redact.js');
7
+
8
+ /** Write a value as pretty JSON to stdout, followed by a newline */
9
+ function emitJson(value) {
10
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
11
+ }
12
+
13
+ /** Build the partial config passed to MigratorKit from CLI flags */
14
+ function partialFromOpts(opts) {
15
+ const partial = {};
16
+ if (opts.uri) partial.uri = opts.uri;
17
+ if (opts.db) partial.dbName = opts.db;
18
+ if (opts.dir) partial.migrationsDir = opts.dir;
19
+ if (opts.strict) partial.strict = true;
20
+ if (opts.env === false) partial.envFile = false;
21
+ else if (opts.envFile) partial.envFile = opts.envFile;
22
+ return partial;
23
+ }
24
+
25
+ const { EXIT_CODES } = require('./exit-codes.js');
26
+
27
+ /** The exit code for a failure — see {@link EXIT_CODES} */
28
+ function exitCodeFor(error) {
29
+ if (!(error instanceof MigronautError)) return 1;
30
+ return EXIT_CODES[error.code] ?? 1;
31
+ }
32
+
33
+ /**
34
+ * Log level from the verbosity flags. `--quiet` keeps errors — the point is to
35
+ * drop the running commentary, not to hide failures.
36
+ */
37
+ function resolveLevel(opts) {
38
+ if (opts.verbose) return 'debug';
39
+ if (opts.quiet) return 'error';
40
+ return 'info';
41
+ }
42
+
43
+ /** Exit code convention for a process killed by a signal: 128 + signal number */
44
+ const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143 };
45
+
46
+ /**
47
+ * Turn SIGINT/SIGTERM into a graceful stop: the migration in flight finishes,
48
+ * the rest are skipped, and the lock is released — instead of leaving a
49
+ * half-applied migration and a lock held until its TTL expires. A second signal
50
+ * exits immediately for an operator who cannot wait.
51
+ *
52
+ * Returns a function that removes the handlers again, so a long-lived process
53
+ * calling the CLI repeatedly does not accumulate them.
54
+ */
55
+ function attachSignalHandlers(migrator, spinner, logger) {
56
+ let stopping = false;
57
+ const handlers = [];
58
+ for (const signal of ['SIGINT', 'SIGTERM']) {
59
+ const handler = () => {
60
+ if (stopping) {
61
+ spinner?.stop();
62
+ // process.exit(), not exitCode: the operator pressed twice — an
63
+ // immediate abort is exactly what they asked for, and the output
64
+ // truncation bin/migronaut.js avoids on the graceful path is the
65
+ // accepted cost here.
66
+ process.exit(SIGNAL_EXIT_CODES[signal]);
67
+ }
68
+ stopping = true;
69
+ spinner?.stop();
70
+ logger.warn(
71
+ `⚠ ${signal} received — finishing the current migration, then stopping. ` +
72
+ 'Press again to exit immediately.',
73
+ );
74
+ migrator.stop(`Received ${signal}`);
75
+ };
76
+ process.on(signal, handler);
77
+ handlers.push([signal, handler]);
78
+ }
79
+ return () => {
80
+ for (const [signal, handler] of handlers) process.off(signal, handler);
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Minimal signal handling for read-only commands: clear the spinner's frame,
86
+ * then re-deliver the signal to Node's default handler (exit). Without this,
87
+ * Ctrl-C during "Connecting to MongoDB…" leaves a partial frame the shell
88
+ * prompt then overwrites messily. No graceful-stop promise here — a read-only
89
+ * command has nothing to finish.
90
+ */
91
+ function attachCleanupHandlers(spinner) {
92
+ if (!spinner) return () => undefined;
93
+ const handlers = [];
94
+ for (const signal of ['SIGINT', 'SIGTERM']) {
95
+ const handler = () => {
96
+ spinner.stop();
97
+ process.off(signal, handler);
98
+ process.kill(process.pid, signal);
99
+ };
100
+ process.on(signal, handler);
101
+ handlers.push([signal, handler]);
102
+ }
103
+ return () => {
104
+ for (const [signal, handler] of handlers) process.off(signal, handler);
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Wrap a logger so every line clears and re-renders an active spinner instead
110
+ * of splicing into the middle of its frame.
111
+ */
112
+ function spinnerAwareLogger(logger, spinner) {
113
+ const wrap = (method) => (message, fields) =>
114
+ spinner.interrupt(() => logger[method](message, fields));
115
+ return { debug: wrap('debug'), info: wrap('info'), warn: wrap('warn'), error: wrap('error') };
116
+ }
117
+
118
+ /**
119
+ * Report a failure the same way everywhere: `--json` gets one structured
120
+ * document on stdout, humans get a readable line (plus the cause under
121
+ * `--verbose`). Every string leaving through this path is redacted — the
122
+ * driver echoes the raw connection URI (credentials included) in parse
123
+ * errors, and those surface as message, context.cause, and stack alike.
124
+ */
125
+ function reportError(error, { json, verbose, logger }) {
126
+ const message = errorText(error);
127
+ if (json) {
128
+ // `context` carries the detail that used to be dropped entirely —
129
+ // validation issues, the driver's cause — and `partial` the migrations
130
+ // that did land before the failure, which is what a deploy pipeline needs
131
+ // to decide how to recover.
132
+ const context = error instanceof MigronautError ? error.context : undefined;
133
+ const { results, ...rest } = context ?? {};
134
+ emitJson({
135
+ error: {
136
+ ...(error instanceof MigronautError ? { code: error.code } : {}),
137
+ message,
138
+ ...(Object.keys(rest).length > 0 ? { context: redactDeep(rest) } : {}),
139
+ },
140
+ ...(Array.isArray(results) ? { partial: results } : {}),
141
+ });
142
+ } else if (error instanceof MigronautError) {
143
+ logger.error(`✖ ${error.code}: ${message}`);
144
+ // The cause is the actionable half of a connection or migration failure;
145
+ // without --verbose it stays hidden so the common case reads cleanly.
146
+ if (verbose && error.cause instanceof Error) {
147
+ logger.debug(redactUris(error.cause.stack ?? error.cause.message));
148
+ }
149
+ } else {
150
+ logger.error(`✖ ${message}`);
151
+ if (verbose && error instanceof Error && error.stack) logger.debug(redactUris(error.stack));
152
+ }
153
+ process.exitCode = exitCodeFor(error);
154
+ }
155
+
156
+ /**
157
+ * Construct a MigratorKit from CLI options, run `fn(migrator, cli)`, always
158
+ * disconnect, and translate failures into a non-zero exit code with a
159
+ * readable message. `cli` is `{ logger, json, opts }` — the one level-aware
160
+ * logger every command must render through, so `--quiet`/`--verbose` apply to
161
+ * command output and not only to core's log lines.
162
+ */
163
+ async function withMigrator(opts, fn, options = {}) {
164
+ // Required here, not at module top: the orchestrator is the CLI's one heavy
165
+ // import, and `--help`/`--version` never reach this function.
166
+ const { MigratorKit } = require('../core/migrator.js');
167
+ const json = options.json ?? false;
168
+ const verbose = opts.verbose ?? false;
169
+ const level = resolveLevel(opts);
170
+ // In JSON mode the spinner is suppressed and all human output goes to stderr,
171
+ // so stdout carries exactly one JSON document. `--quiet` also suppresses it:
172
+ // the spinner is running commentary.
173
+ const spinner = options.spinner && !json && level === 'info' ? createSpinner() : undefined;
174
+
175
+ const partial = partialFromOpts(opts);
176
+ // One level-aware logger per invocation (defineCommand hands its own in so
177
+ // preflight and run render identically); while a spinner is active every
178
+ // line interrupts it instead of colliding.
179
+ const baseLogger =
180
+ options.baseLogger ?? createLogger(json ? process.stderr : process.stdout, level);
181
+ const cliLogger = spinner ? spinnerAwareLogger(baseLogger, spinner) : baseLogger;
182
+
183
+ const migratorOptions = {
184
+ ...(opts.config ? { configPath: opts.config } : {}),
185
+ // A *fallback*, not an override: a `logger` in the user's config file
186
+ // (pino, or `null` for silence) wins over the CLI's console logger — the
187
+ // README documents that pattern, and clobbering it silently broke it.
188
+ fallbackLogger: cliLogger,
189
+ };
190
+ if (spinner) {
191
+ const reporter = {
192
+ onStart: (name, direction) =>
193
+ spinner.start(`${direction === 'up' ? 'Applying' : 'Reverting'} ${name}…`),
194
+ // Stop (not succeed) — core logs the ✔/↩/✖ result line right after, so
195
+ // the outcome argument is deliberately unused here.
196
+ onStop: () => spinner.stop(),
197
+ };
198
+ migratorOptions.progress = reporter;
199
+ }
200
+ const migrator = new MigratorKit(partial, migratorOptions);
201
+ // Human-facing logger for withMigrator's own messages and command rendering;
202
+ // stderr in JSON mode. Errors survive --quiet: silencing a failure is never
203
+ // what an operator meant. Command output stays on the CLI logger even when
204
+ // the config supplies its own — a table or error is what the operator asked
205
+ // this invocation for, not a line for the app's structured sink.
206
+ const logger = cliLogger;
207
+ // Graceful-stop handlers only make sense for a command that runs migrations;
208
+ // a read-only command gets a minimal handler that clears the spinner (a
209
+ // stray frame otherwise survives Node's default exit) and re-raises.
210
+ const detachSignals = options.mutating
211
+ ? attachSignalHandlers(migrator, spinner, logger)
212
+ : attachCleanupHandlers(spinner);
213
+ try {
214
+ // Pre-connect is cosmetic (early spinner + early failure): every kit
215
+ // method connects on its own. `connect: false` opts out — `audit` reports
216
+ // a failed connection as one of its checks rather than dying on it.
217
+ const preConnect = options.connect ?? options.spinner === true;
218
+ if (preConnect) {
219
+ spinner?.start('Connecting to MongoDB…');
220
+ try {
221
+ await migrator.connect();
222
+ spinner?.stop();
223
+ } catch (error) {
224
+ spinner?.stop();
225
+ throw error;
226
+ }
227
+ }
228
+ await fn(migrator, { logger, json, opts });
229
+ } catch (error) {
230
+ // Safety net: clear any spinner still spinning before printing the error.
231
+ spinner?.stop();
232
+ reportError(error, { json, verbose, logger });
233
+ } finally {
234
+ detachSignals();
235
+ spinner?.stop();
236
+ // A failing close must not replace whatever this command was reporting.
237
+ await migrator.disconnect().catch(() => undefined);
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Ask a yes/no question on the terminal. Resolves `true` only when the user
243
+ * answers `y` or `yes` (case-insensitive); any other input is treated as no.
244
+ *
245
+ * The prompt goes to **stderr** so captured stdout stays clean. A closed or
246
+ * empty stdin (EOF, `< /dev/null` — a CI job that forgot `--yes`) never
247
+ * delivers a line; without the close guard the promise would hang forever and
248
+ * the process would then exit 0 having done nothing. It becomes a typed
249
+ * refusal instead.
250
+ */
251
+ async function confirm(question) {
252
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
253
+ let settled = false;
254
+ const closedEarly = new Promise((_resolve, reject) => {
255
+ rl.once('close', () => {
256
+ if (!settled) {
257
+ reject(
258
+ new ConfigInvalidError(
259
+ 'stdin closed before the confirmation was answered — pass --yes to confirm non-interactively',
260
+ ),
261
+ );
262
+ }
263
+ });
264
+ });
265
+ try {
266
+ const answer = await Promise.race([rl.question(question), closedEarly]);
267
+ return /^y(es)?$/i.test(answer.trim());
268
+ } finally {
269
+ settled = true;
270
+ rl.close();
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Register a command with the envelope every data command shares: parse
276
+ * `optsWithGlobals`, run `preflight` (validation/confirmation — before any
277
+ * connection), then `run` inside {@link withMigrator}, then emit the returned
278
+ * data as JSON or hand it to `render`. A `run` that returns `undefined` owns
279
+ * its output. `after(data, cli)` runs last in every mode (exit-code logic).
280
+ */
281
+ function defineCommand(program, spec) {
282
+ const command = program.command(spec.name).description(spec.description);
283
+ for (const [arg, help] of spec.args ?? []) command.argument(arg, help);
284
+ for (const [flags, help] of spec.options ?? []) command.option(flags, help);
285
+ // `lockable: true` declares the option AND computes the inversion once —
286
+ // the `--no-x` flag arriving as `opts.lock === false` is subtle enough that
287
+ // a per-command copy will eventually get it wrong.
288
+ if (spec.lockable) command.option('--no-lock', 'Skip the concurrency lock (dev only)');
289
+ command.action(async (...actionArgs) => {
290
+ const invoked = actionArgs[actionArgs.length - 1];
291
+ const positionals = actionArgs.slice(0, -2);
292
+ const opts = invoked.optsWithGlobals();
293
+ if (spec.lockable) opts.noLock = opts.lock === false;
294
+ const json = spec.jsonOutput === false ? false : Boolean(opts.json);
295
+ const verbose = opts.verbose ?? false;
296
+ const logger = createLogger(json ? process.stderr : process.stdout, resolveLevel(opts));
297
+
298
+ if (spec.preflight) {
299
+ try {
300
+ const proceed = await spec.preflight(opts, positionals, { logger, json });
301
+ if (proceed === false) return;
302
+ } catch (error) {
303
+ reportError(error, { json, verbose, logger });
304
+ return;
305
+ }
306
+ }
307
+
308
+ await withMigrator(
309
+ opts,
310
+ async (migrator, cli) => {
311
+ const data = await spec.run(migrator, opts, positionals, cli);
312
+ if (data !== undefined) {
313
+ if (json) emitJson(data);
314
+ else spec.render?.(data, cli);
315
+ }
316
+ spec.after?.(data, cli);
317
+ },
318
+ {
319
+ spinner: spec.spinner ?? true,
320
+ // The preflight logger is reused inside, so a warning printed before
321
+ // the run and one printed during it render identically.
322
+ baseLogger: logger,
323
+ ...(spec.connect !== undefined ? { connect: spec.connect } : {}),
324
+ ...(spec.mutating ? { mutating: true } : {}),
325
+ ...(json ? { json: true } : {}),
326
+ },
327
+ );
328
+ });
329
+ return command;
330
+ }
331
+
332
+ module.exports = {
333
+ emitJson,
334
+ partialFromOpts,
335
+ withMigrator,
336
+ confirm,
337
+ defineCommand,
338
+ reportError,
339
+ exitCodeFor,
340
+ resolveLevel,
341
+ attachSignalHandlers,
342
+ EXIT_CODES,
343
+ };
@@ -0,0 +1,59 @@
1
+ const { sanitize } = require('../utils/sanitize.js');
2
+
3
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
4
+ const INTERVAL_MS = 80;
5
+ const CLEAR_LINE = '\r\x1b[K';
6
+
7
+ /**
8
+ * Minimal terminal spinner with the start(text)/stop() surface the CLI needs.
9
+ * Renders only on an interactive TTY; on a pipe or redirect every method is
10
+ * a no-op, so control sequences never leak into captured output. Writes to
11
+ * stderr by default, keeping stdout clean for command output.
12
+ */
13
+ function createSpinner(stream = process.stderr) {
14
+ let timer = null;
15
+ let frame = 0;
16
+ let text = '';
17
+
18
+ const render = () => {
19
+ stream.write(`${CLEAR_LINE}${FRAMES[frame]} ${text}`);
20
+ frame = (frame + 1) % FRAMES.length;
21
+ };
22
+
23
+ const start = (message) => {
24
+ if (!stream.isTTY) return;
25
+ // Spinner text embeds migration filenames — untrusted, straight to a TTY.
26
+ text = sanitize(message ?? '');
27
+ if (timer === null) {
28
+ timer = setInterval(render, INTERVAL_MS);
29
+ if (typeof timer.unref === 'function') timer.unref();
30
+ }
31
+ render();
32
+ };
33
+
34
+ const stop = () => {
35
+ if (timer === null) return;
36
+ clearInterval(timer);
37
+ timer = null;
38
+ stream.write(CLEAR_LINE);
39
+ };
40
+
41
+ /**
42
+ * Run `fn` (which writes to the terminal) without splicing its output into
43
+ * the middle of a spinner frame: clear the line, let it write, re-render.
44
+ * A plain pass-through while the spinner is idle.
45
+ */
46
+ const interrupt = (fn) => {
47
+ if (timer === null) {
48
+ fn();
49
+ return;
50
+ }
51
+ stream.write(CLEAR_LINE);
52
+ fn();
53
+ render();
54
+ };
55
+
56
+ return { start, stop, interrupt };
57
+ }
58
+
59
+ module.exports = { createSpinner };
@@ -0,0 +1,259 @@
1
+ const { createColors, stripAnsi } = require('../utils/colors.js');
2
+ const { formatDateTime } = require('../utils/date.js');
3
+ // Shared with the logger and spinner — cell values come from the changelog and
4
+ // from migration files, and every sink that prints them sanitizes the same way.
5
+ // Migronaut's own colors are applied after sanitizing, so they survive.
6
+ const { sanitize } = require('../utils/sanitize.js');
7
+
8
+ /**
9
+ * Resolved per render rather than once at import: color depends on NO_COLOR /
10
+ * FORCE_COLOR / TTY, and `--no-color` is applied after this module is loaded.
11
+ */
12
+ const palette = () => createColors(process.stdout);
13
+
14
+ /**
15
+ * Codepoint ranges the terminal renders two columns wide (East Asian Wide and
16
+ * Fullwidth). Counting them as one — which `String.length` does — misaligns
17
+ * every column to the right of a CJK filename.
18
+ */
19
+ const WIDE_RANGES = [
20
+ [0x1100, 0x115f],
21
+ [0x2e80, 0x303e],
22
+ [0x3041, 0x33ff],
23
+ [0x3400, 0x4dbf],
24
+ [0x4e00, 0x9fff],
25
+ [0xa000, 0xa4cf],
26
+ [0xac00, 0xd7a3],
27
+ [0xf900, 0xfaff],
28
+ [0xfe30, 0xfe6f],
29
+ [0xff00, 0xff60],
30
+ [0xffe0, 0xffe6],
31
+ [0x1f300, 0x1f64f],
32
+ [0x1f680, 0x1f6ff],
33
+ [0x1f900, 0x1f9ff],
34
+ [0x20000, 0x3fffd],
35
+ ];
36
+
37
+ /** Terminal columns a single codepoint occupies (0 for combining marks) */
38
+ function charWidth(codepoint) {
39
+ // Combining marks attach to the previous glyph and take no width of their own.
40
+ if (codepoint >= 0x0300 && codepoint <= 0x036f) return 0;
41
+ // Variation selectors (VS15/VS16 among them) modify the preceding glyph.
42
+ if (codepoint >= 0xfe00 && codepoint <= 0xfe0f) return 0;
43
+ for (const [start, end] of WIDE_RANGES) {
44
+ if (codepoint >= start && codepoint <= end) return 2;
45
+ }
46
+ return 1;
47
+ }
48
+
49
+ /** Terminal columns of already-ANSI-free text — the shared codepoint walk */
50
+ function widthOfPlain(plain) {
51
+ let width = 0;
52
+ for (const char of plain) {
53
+ width += charWidth(char.codePointAt(0));
54
+ }
55
+ return width;
56
+ }
57
+
58
+ /** Number of terminal columns a cell occupies, ignoring ANSI color codes */
59
+ function visibleWidth(text) {
60
+ return widthOfPlain(stripAnsi(text));
61
+ }
62
+
63
+ /**
64
+ * Shorten `text` to at most `max` visible columns, ending with an ellipsis.
65
+ * Operates on the ANSI-stripped text: slicing raw characters could cut an
66
+ * escape sequence in half and leave the terminal restyled past the cell.
67
+ * One stripAnsi + one width walk per call — not one of each per check.
68
+ */
69
+ function truncate(text, max) {
70
+ const plain = stripAnsi(text);
71
+ if (widthOfPlain(plain) <= max) return text;
72
+ let out = '';
73
+ let width = 0;
74
+ for (const char of plain) {
75
+ const next = width + charWidth(char.codePointAt(0));
76
+ if (next > max - 1) break;
77
+ out += char;
78
+ width = next;
79
+ }
80
+ return `${out}…`;
81
+ }
82
+
83
+ /** Build a horizontal border line for the given column widths */
84
+ function borderLine(widths, left, mid, right) {
85
+ const segments = [];
86
+ for (const width of widths) segments.push('─'.repeat(width + 2));
87
+ return left + segments.join(mid) + right;
88
+ }
89
+
90
+ /**
91
+ * Render `head` + `rows` (arrays of string cells) as a box-drawing table.
92
+ * Column widths come from the widest cell; ANSI color codes are excluded
93
+ * from width so colored cells never skew the alignment. Within this function
94
+ * each cell is measured exactly once and the width reused for padding
95
+ * (truncate() above accounts for one more scan) — stripAnsi is not free at
96
+ * 5k rows.
97
+ */
98
+ function renderTable(head, rows) {
99
+ // Measure every cell once and settle the column widths in the same pass.
100
+ const headWidths = new Array(head.length);
101
+ const widths = new Array(head.length);
102
+ for (let index = 0; index < head.length; index++) {
103
+ headWidths[index] = visibleWidth(head[index]);
104
+ widths[index] = headWidths[index];
105
+ }
106
+ const cellWidths = new Array(rows.length);
107
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
108
+ const row = rows[rowIndex];
109
+ const measured = new Array(row.length);
110
+ for (let index = 0; index < row.length; index++) {
111
+ measured[index] = visibleWidth(row[index]);
112
+ if (measured[index] > widths[index]) widths[index] = measured[index];
113
+ }
114
+ cellWidths[rowIndex] = measured;
115
+ }
116
+ const renderRow = (cells, measured) => {
117
+ const padded = [];
118
+ for (let index = 0; index < cells.length; index++) {
119
+ padded.push(cells[index] + ' '.repeat(Math.max(0, widths[index] - measured[index])));
120
+ }
121
+ return `│ ${padded.join(' │ ')} │`;
122
+ };
123
+ const colors = palette();
124
+ const coloredHead = [];
125
+ for (const title of head) {
126
+ coloredHead.push(colors.cyan(title));
127
+ }
128
+ const lines = [
129
+ borderLine(widths, '┌', '┬', '┐'),
130
+ renderRow(coloredHead, headWidths),
131
+ borderLine(widths, '├', '┼', '┤'),
132
+ ];
133
+ for (let index = 0; index < rows.length; index++) {
134
+ lines.push(renderRow(rows[index], cellWidths[index]));
135
+ }
136
+ lines.push(borderLine(widths, '└', '┴', '┘'));
137
+ return lines.join('\n');
138
+ }
139
+
140
+ /** Render a checksum indicator cell */
141
+ function checksumCell(colors, value) {
142
+ if (value === null) return colors.dim('—');
143
+ return value ? colors.green('ok') : colors.red('MISMATCH');
144
+ }
145
+
146
+ /** Render a status cell */
147
+ function statusCell(colors, status) {
148
+ return status === 'applied' ? colors.green('applied') : colors.yellow('pending');
149
+ }
150
+
151
+ /** Longest description rendered before it is ellipsized */
152
+ const MAX_DESCRIPTION_WIDTH = 40;
153
+
154
+ /** Longest migration filename rendered before it is ellipsized */
155
+ const MAX_MIGRATION_WIDTH = 60;
156
+
157
+ /**
158
+ * Width budgets for the two truncatable columns, shrunk to fit the terminal.
159
+ *
160
+ * `fixedWidth` is the room the non-truncatable columns and the box-drawing
161
+ * frame take. Off-TTY (pipes, CI logs, tests) the static caps apply — output
162
+ * stays byte-stable regardless of the invoking terminal's size. On a narrow
163
+ * TTY the truncatable columns give way instead of every row wrapping into
164
+ * box-drawing noise — the common case over SSH at 80 columns.
165
+ */
166
+ function truncationBudgets(hasDescription, fixedWidth) {
167
+ const columns = process.stdout.isTTY ? process.stdout.columns : undefined;
168
+ if (!columns) {
169
+ return { migration: MAX_MIGRATION_WIDTH, description: MAX_DESCRIPTION_WIDTH };
170
+ }
171
+ const available = Math.max(0, columns - fixedWidth);
172
+ if (!hasDescription) {
173
+ return {
174
+ migration: Math.max(12, Math.min(MAX_MIGRATION_WIDTH, available)),
175
+ description: MAX_DESCRIPTION_WIDTH,
176
+ };
177
+ }
178
+ const migration = Math.max(12, Math.min(MAX_MIGRATION_WIDTH, Math.ceil(available * 0.6)));
179
+ const description = Math.max(8, Math.min(MAX_DESCRIPTION_WIDTH, available - migration));
180
+ return { migration, description };
181
+ }
182
+
183
+ /**
184
+ * Render status rows as a human-readable table string. The Description column
185
+ * appears only when at least one row has one, so the common case stays narrow.
186
+ */
187
+ function renderStatusTable(rows) {
188
+ const colors = palette();
189
+ let hasDescription = false;
190
+ for (const row of rows) {
191
+ if (row.description) {
192
+ hasDescription = true;
193
+ break;
194
+ }
195
+ }
196
+ // Status(7) + Batch(5) + Applied At(19) + Duration(8) + Checksum(8) plus
197
+ // `│ … │ ` framing: 3 columns of overhead per column and one closing bar.
198
+ const budgets = truncationBudgets(hasDescription, 47 + (hasDescription ? 7 : 6) * 3 + 1);
199
+ const head = ['Migration', 'Status', 'Batch', 'Applied At', 'Duration', 'Checksum'];
200
+ if (hasDescription) head.push('Description');
201
+ const cells = [];
202
+ for (const row of rows) {
203
+ const cell = [
204
+ truncate(sanitize(row.file), budgets.migration),
205
+ statusCell(colors, row.status),
206
+ row.batch === null ? '' : String(row.batch),
207
+ row.appliedAt ? formatDateTime(row.appliedAt) : '',
208
+ row.duration === null ? '' : `${row.duration}ms`,
209
+ checksumCell(colors, row.checksumOk),
210
+ ];
211
+ if (hasDescription) {
212
+ cell.push(truncate(sanitize(row.description ?? ''), budgets.description));
213
+ }
214
+ cells.push(cell);
215
+ }
216
+ return renderTable(head, cells);
217
+ }
218
+
219
+ /** Shared render for row-listing commands: the table, or a friendly empty line */
220
+ function renderRowsOrEmpty(rows, { logger }) {
221
+ if (rows.length === 0) logger.info('No migrations found');
222
+ else logger.info(renderStatusTable(rows));
223
+ }
224
+
225
+ /** Render the checksum-source cell for an import row */
226
+ function checksumSourceCell(colors, source) {
227
+ if (source === 'recomputed') return colors.green('recomputed');
228
+ if (source === 'reused') return colors.cyan('reused');
229
+ return colors.red('missing');
230
+ }
231
+
232
+ /** Render mapped import rows as a human-readable table string */
233
+ function renderImportTable(rows) {
234
+ const colors = palette();
235
+ // Batch(5) + Applied At(19) + Checksum(10) plus the 4-column frame.
236
+ const budgets = truncationBudgets(false, 34 + 4 * 3 + 1);
237
+ const head = ['Migration', 'Batch', 'Applied At', 'Checksum'];
238
+ const cells = [];
239
+ for (const row of rows) {
240
+ cells.push([
241
+ truncate(sanitize(row.file), budgets.migration),
242
+ String(row.batch),
243
+ formatDateTime(row.appliedAt),
244
+ checksumSourceCell(colors, row.checksumSource),
245
+ ]);
246
+ }
247
+ return renderTable(head, cells);
248
+ }
249
+
250
+ module.exports = {
251
+ charWidth,
252
+ sanitize,
253
+ visibleWidth,
254
+ truncate,
255
+ renderTable,
256
+ renderStatusTable,
257
+ renderImportTable,
258
+ renderRowsOrEmpty,
259
+ };