@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.
- package/CHANGELOG.md +128 -0
- package/LICENSE +22 -0
- package/README.md +773 -0
- package/bin/migronaut.js +36 -0
- package/index.d.ts +903 -0
- package/index.js +1 -0
- package/migronaut.schema.json +135 -0
- package/package.json +105 -0
- package/src/cli/args.js +314 -0
- package/src/cli/commands/audit.js +40 -0
- package/src/cli/commands/create.js +29 -0
- package/src/cli/commands/down.js +30 -0
- package/src/cli/commands/dry-run.js +36 -0
- package/src/cli/commands/import.js +33 -0
- package/src/cli/commands/init.js +64 -0
- package/src/cli/commands/list.js +19 -0
- package/src/cli/commands/lock.js +35 -0
- package/src/cli/commands/redo.js +16 -0
- package/src/cli/commands/status.js +52 -0
- package/src/cli/commands/unlock.js +43 -0
- package/src/cli/commands/up.js +53 -0
- package/src/cli/exit-codes.js +38 -0
- package/src/cli/index.js +91 -0
- package/src/cli/shared.js +343 -0
- package/src/cli/spinner.js +59 -0
- package/src/cli/table.js +259 -0
- package/src/core/audit.js +152 -0
- package/src/core/changelog.js +231 -0
- package/src/core/config.js +479 -0
- package/src/core/context.js +16 -0
- package/src/core/import-runner.js +164 -0
- package/src/core/import.js +60 -0
- package/src/core/lock.js +348 -0
- package/src/core/migrator.js +1475 -0
- package/src/core/run.js +144 -0
- package/src/core/runner.js +150 -0
- package/src/errors/index.js +215 -0
- package/src/index.js +59 -0
- package/src/utils/checksum.js +23 -0
- package/src/utils/colors.js +68 -0
- package/src/utils/concurrency.js +32 -0
- package/src/utils/date.js +28 -0
- package/src/utils/env.js +51 -0
- package/src/utils/error.js +11 -0
- package/src/utils/loader.js +131 -0
- package/src/utils/logger.js +109 -0
- package/src/utils/redact.js +39 -0
- package/src/utils/sanitize.js +43 -0
- package/src/utils/template.js +615 -0
- 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 };
|
package/src/cli/table.js
ADDED
|
@@ -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
|
+
};
|