feat(cli): add "reporter" option for the "new" command for improved logging

This commit is contained in:
Joakim Carlstein 2023-11-22 13:21:45 +01:00
parent 5e8572b67f
commit da1eee3c75
5 changed files with 132 additions and 54 deletions

View file

@ -0,0 +1,5 @@
---
'@emigrate/cli': minor
---
Add "reporter" option for the "new" command and use it for improved logging

View file

@ -97,6 +97,10 @@ const newMigration: Action = async (args) => {
type: 'string', type: 'string',
short: 'd', short: 'd',
}, },
reporter: {
type: 'string',
short: 'r',
},
template: { template: {
type: 'string', type: 'string',
short: 't', short: 't',
@ -123,6 +127,7 @@ Options:
-h, --help Show this help message and exit -h, --help Show this help message and exit
-d, --directory The directory where the migration files are located (required) -d, --directory The directory where the migration files are located (required)
-r, --reporter The reporter to use for reporting the migration file creation progress
-p, --plugin The plugin(s) to use (can be specified multiple times) -p, --plugin The plugin(s) to use (can be specified multiple times)
-t, --template A template file to use as contents for the new migration file -t, --template A template file to use as contents for the new migration file
(if the extension option is not provided the template file's extension will be used) (if the extension option is not provided the template file's extension will be used)
@ -145,13 +150,18 @@ Examples:
return; return;
} }
const { directory = config.directory, template = config.template, extension = config.extension } = values; const {
directory = config.directory,
template = config.template,
extension = config.extension,
reporter = config.reporter,
} = values;
const plugins = [...(config.plugins ?? []), ...(values.plugin ?? [])]; const plugins = [...(config.plugins ?? []), ...(values.plugin ?? [])];
const name = positionals.join(' ').trim(); const name = positionals.join(' ').trim();
try { try {
const { default: newCommand } = await import('./new-command.js'); const { default: newCommand } = await import('./new-command.js');
await newCommand({ directory, template, plugins, extension }, name); await newCommand({ directory, template, plugins, extension, reporter }, name);
} catch (error) { } catch (error) {
if (error instanceof ShowUsageError) { if (error instanceof ShowUsageError) {
console.error(error.message, '\n'); console.error(error.message, '\n');

View file

@ -1,12 +1,18 @@
import process from 'node:process'; import process from 'node:process';
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { getTimestampPrefix, sanitizeMigrationName, getOrLoadPlugin } from '@emigrate/plugin-tools'; import { getTimestampPrefix, sanitizeMigrationName, getOrLoadPlugin, getOrLoadReporter } from '@emigrate/plugin-tools';
import { type MigrationMetadata } from '@emigrate/plugin-tools/types';
import { BadOptionError, MissingArgumentsError, MissingOptionError, UnexpectedError } from './errors.js'; import { BadOptionError, MissingArgumentsError, MissingOptionError, UnexpectedError } from './errors.js';
import { type Config } from './types.js'; import { type Config } from './types.js';
import { withLeadingPeriod } from './with-leading-period.js'; import { withLeadingPeriod } from './with-leading-period.js';
export default async function newCommand({ directory, template, plugins = [], extension }: Config, name: string) { const lazyDefaultReporter = async () => import('./plugin-reporter-default.js');
export default async function newCommand(
{ directory, template, reporter: reporterConfig, plugins = [], extension }: Config,
name: string,
) {
if (!directory) { if (!directory) {
throw new MissingOptionError('directory'); throw new MissingOptionError('directory');
} }
@ -19,6 +25,19 @@ export default async function newCommand({ directory, template, plugins = [], ex
throw new MissingOptionError(['extension', 'template', 'plugin']); throw new MissingOptionError(['extension', 'template', 'plugin']);
} }
const cwd = process.cwd();
const reporter = await getOrLoadReporter([reporterConfig ?? lazyDefaultReporter]);
if (!reporter) {
throw new BadOptionError(
'reporter',
'No reporter found, please specify an existing reporter using the reporter option',
);
}
await reporter.onInit?.({ command: 'new', cwd, dry: false, directory });
let filename: string | undefined; let filename: string | undefined;
let content: string | undefined; let content: string | undefined;
@ -31,7 +50,11 @@ export default async function newCommand({ directory, template, plugins = [], ex
content = await fs.readFile(templatePath, 'utf8'); content = await fs.readFile(templatePath, 'utf8');
content = content.replaceAll('{{name}}', name); content = content.replaceAll('{{name}}', name);
} catch (error) { } catch (error) {
throw new UnexpectedError(`Failed to read template file: ${templatePath}`, { cause: error }); await reporter.onFinished?.(
[],
new UnexpectedError(`Failed to read template file: ${templatePath}`, { cause: error }),
);
return;
} }
filename = `${getTimestampPrefix()}_${sanitizeMigrationName(name)}${withLeadingPeriod(extension ?? fileExtension)}`; filename = `${getTimestampPrefix()}_${sanitizeMigrationName(name)}${withLeadingPeriod(extension ?? fileExtension)}`;
@ -67,8 +90,30 @@ export default async function newCommand({ directory, template, plugins = [], ex
const directoryPath = path.resolve(process.cwd(), directory); const directoryPath = path.resolve(process.cwd(), directory);
const filePath = path.resolve(directoryPath, filename); const filePath = path.resolve(directoryPath, filename);
await createDirectory(directoryPath); const migration: MigrationMetadata = {
await saveFile(filePath, content); name: filename,
filePath,
relativeFilePath: path.relative(cwd, filePath),
extension: withLeadingPeriod(path.extname(filename)),
directory,
cwd,
};
await reporter.onNewMigration?.(migration, content);
let saveError: Error | undefined;
try {
await createDirectory(directoryPath);
await saveFile(filePath, content);
} catch (error) {
saveError = error instanceof Error ? error : new Error(String(error));
}
await reporter.onFinished?.(
[{ ...migration, status: saveError ? 'failed' : 'done', error: saveError, duration: 0 }],
saveError,
);
} }
async function createDirectory(directoryPath: string) { async function createDirectory(directoryPath: string) {
@ -82,8 +127,6 @@ async function createDirectory(directoryPath: string) {
async function saveFile(filePath: string, content: string) { async function saveFile(filePath: string, content: string) {
try { try {
await fs.writeFile(filePath, content); await fs.writeFile(filePath, content);
console.log(`Created migration file: ${path.relative(process.cwd(), filePath)}`);
} catch (error) { } catch (error) {
throw new UnexpectedError(`Failed to write migration file: ${filePath}`, { cause: error }); throw new UnexpectedError(`Failed to write migration file: ${filePath}`, { cause: error });
} }

View file

@ -1,5 +1,5 @@
import path from 'node:path'; import path from 'node:path';
import ansis from 'ansis'; import { black, blueBright, bold, cyan, dim, gray, green, red, redBright, yellow } from 'ansis';
import logUpdate from 'log-update'; import logUpdate from 'log-update';
import elegantSpinner from 'elegant-spinner'; import elegantSpinner from 'elegant-spinner';
import figures from 'figures'; import figures from 'figures';
@ -9,6 +9,8 @@ import {
type MigrationMetadata, type MigrationMetadata,
type MigrationMetadataFinished, type MigrationMetadataFinished,
type EmigrateReporter, type EmigrateReporter,
type ReporterInitParameters,
type Awaitable,
} from '@emigrate/plugin-tools/types'; } from '@emigrate/plugin-tools/types';
type Status = ReturnType<typeof getMigrationStatus>; type Status = ReturnType<typeof getMigrationStatus>;
@ -19,12 +21,12 @@ const spinner = interactive ? elegantSpinner() : () => figures.pointerSmall;
const formatDuration = (duration: number): string => { const formatDuration = (duration: number): string => {
const pretty = prettyMs(duration); const pretty = prettyMs(duration);
return ansis.yellow(pretty.replaceAll(/([^\s\d]+)/g, ansis.dim('$1'))); return yellow(pretty.replaceAll(/([^\s\d]+)/g, dim('$1')));
}; };
const getTitle = ({ directory, dry, cwd }: { directory: string; dry: boolean; cwd: string }) => { const getTitle = ({ command, directory, dry, cwd }: ReporterInitParameters) => {
return `${ansis.bgBlueBright(ansis.black(' Emigrate '))} ${ansis.gray(cwd + path.sep)}${directory}${ return `${black.bgBlueBright` Emigrate `} ${blueBright.bold(command)} ${gray(cwd + path.sep)}${directory}${
dry ? ansis.yellow(' (dry run)') : '' dry ? yellow` (dry run)` : ''
}`; }`;
}; };
@ -42,23 +44,23 @@ const getMigrationStatus = (
const getIcon = (status: Status) => { const getIcon = (status: Status) => {
switch (status) { switch (status) {
case 'running': { case 'running': {
return ansis.cyan(spinner()); return cyan(spinner());
} }
case 'pending': { case 'pending': {
return ansis.gray(figures.pointerSmall); return gray(figures.pointerSmall);
} }
case 'done': { case 'done': {
return ansis.green(figures.tick); return green(figures.tick);
} }
case 'failed': { case 'failed': {
return ansis.red(figures.cross); return red(figures.cross);
} }
case 'skipped': { case 'skipped': {
return ansis.yellow(figures.circle); return yellow(figures.circle);
} }
default: { default: {
@ -70,11 +72,11 @@ const getIcon = (status: Status) => {
const getName = (name: string, status?: Status) => { const getName = (name: string, status?: Status) => {
switch (status) { switch (status) {
case 'failed': { case 'failed': {
return ansis.red(name); return red(name);
} }
case 'skipped': { case 'skipped': {
return ansis.yellow(name); return yellow(name);
} }
default: { default: {
@ -91,12 +93,12 @@ const getMigrationText = (
const status = getMigrationStatus(migration, activeMigration); const status = getMigrationStatus(migration, activeMigration);
const parts = [' ', getIcon(status)]; const parts = [' ', getIcon(status)];
parts.push(`${getName(nameWithoutExtension, status)}${ansis.dim(migration.extension)}`); parts.push(`${getName(nameWithoutExtension, status)}${dim(migration.extension)}`);
if ('status' in migration) { if ('status' in migration) {
parts.push(ansis.gray(`(${migration.status})`)); parts.push(gray(`(${migration.status})`));
} else if (migration.name === activeMigration?.name) { } else if (migration.name === activeMigration?.name) {
parts.push(ansis.gray('(running)')); parts.push(gray`(running)`);
} }
if ('duration' in migration && migration.duration) { if ('duration' in migration && migration.duration) {
@ -123,17 +125,20 @@ const getError = (error?: Error, indent = ' ') => {
errorTitle = error.message; errorTitle = error.message;
} }
const parts = [`${indent}${ansis.bold.red(errorTitle)}`, ...stack.map((line) => `${indent}${ansis.dim(line)}`)]; const parts = [`${indent}${bold.red(errorTitle)}`, ...stack.map((line) => `${indent}${dim(line)}`)];
if (error.cause instanceof Error) { if (error.cause instanceof Error) {
const nextIndent = `${indent} `; const nextIndent = `${indent} `;
parts.push(`\n${nextIndent}${ansis.bold('Original error cause:')}\n`, getError(error.cause, nextIndent)); parts.push(`\n${nextIndent}${bold('Original error cause:')}\n`, getError(error.cause, nextIndent));
} }
return parts.join('\n'); return parts.join('\n');
}; };
const getSummary = (migrations: Array<MigrationMetadata | MigrationMetadataFinished> = []) => { const getSummary = (
command: ReporterInitParameters['command'],
migrations: Array<MigrationMetadata | MigrationMetadataFinished> = [],
) => {
const total = migrations.length; const total = migrations.length;
let done = 0; let done = 0;
let failed = 0; let failed = 0;
@ -163,19 +168,21 @@ const getSummary = (migrations: Array<MigrationMetadata | MigrationMetadataFinis
} }
} }
const showTotal = command !== 'new';
const statusLine = [ const statusLine = [
failed ? ansis.bold.red(`${failed} failed`) : '', failed ? red.bold(`${failed} failed`) : '',
done ? ansis.bold.green(`${done} done`) : '', done ? green.bold(`${done} ${command === 'new' ? 'created' : 'done'}`) : '',
skipped ? ansis.bold.yellow(`${skipped} skipped`) : '', skipped ? yellow.bold(`${skipped} skipped`) : '',
] ]
.filter(Boolean) .filter(Boolean)
.join(ansis.dim(' | ')); .join(dim(' | '));
if (!statusLine) { if (!statusLine) {
return ''; return '';
} }
return ` ${statusLine}${ansis.gray(` (${total} total)`)}`; return ` ${statusLine}${showTotal ? gray(` (${total} total)`) : ''}`;
}; };
const getHeaderMessage = (migrations?: MigrationMetadata[], lockedMigrations?: MigrationMetadata[]) => { const getHeaderMessage = (migrations?: MigrationMetadata[], lockedMigrations?: MigrationMetadata[]) => {
@ -188,18 +195,16 @@ const getHeaderMessage = (migrations?: MigrationMetadata[], lockedMigrations?: M
} }
if (migrations.length === lockedMigrations.length) { if (migrations.length === lockedMigrations.length) {
return ` ${ansis.bold(migrations.length.toString())} ${ansis.dim('pending migrations to run')}`; return ` ${bold(migrations.length.toString())} ${dim('pending migrations to run')}`;
} }
if (lockedMigrations.length === 0) { if (lockedMigrations.length === 0) {
return ` ${ansis.bold(`0 of ${migrations.length}`)} ${ansis.dim('pending migrations to run')} ${ansis.redBright( return ` ${bold(`0 of ${migrations.length}`)} ${dim('pending migrations to run')} ${redBright('(all locked)')}`;
'(all locked)',
)}`;
} }
return ` ${ansis.bold(`${lockedMigrations.length} of ${migrations.length}`)} ${ansis.dim( return ` ${bold(`${lockedMigrations.length} of ${migrations.length}`)} ${dim('pending migrations to run')} ${yellow(
'pending migrations to run', `(${migrations.length - lockedMigrations.length} locked)`,
)} ${ansis.yellow(`(${migrations.length - lockedMigrations.length} locked)`)}`; )}`;
}; };
class DefaultFancyReporter implements Required<EmigrateReporter> { class DefaultFancyReporter implements Required<EmigrateReporter> {
@ -207,15 +212,11 @@ class DefaultFancyReporter implements Required<EmigrateReporter> {
#lockedMigrations: MigrationMetadata[] | undefined; #lockedMigrations: MigrationMetadata[] | undefined;
#activeMigration: MigrationMetadata | undefined; #activeMigration: MigrationMetadata | undefined;
#error: Error | undefined; #error: Error | undefined;
#directory!: string; #parameters!: ReporterInitParameters;
#cwd!: string;
#dry!: boolean;
#interval: NodeJS.Timeout | undefined; #interval: NodeJS.Timeout | undefined;
onInit(parameters: { directory: string; cwd: string; dry: boolean }): void | PromiseLike<void> { onInit(parameters: ReporterInitParameters): void | PromiseLike<void> {
this.#directory = parameters.directory; this.#parameters = parameters;
this.#dry = parameters.dry;
this.#cwd = parameters.cwd;
this.#start(); this.#start();
} }
@ -228,6 +229,10 @@ class DefaultFancyReporter implements Required<EmigrateReporter> {
this.#lockedMigrations = migrations; this.#lockedMigrations = migrations;
} }
onNewMigration(migration: MigrationMetadata, _content: string): Awaitable<void> {
this.#migrations = [migration];
}
onMigrationStart(migration: MigrationMetadata): void | PromiseLike<void> { onMigrationStart(migration: MigrationMetadata): void | PromiseLike<void> {
this.#activeMigration = migration; this.#activeMigration = migration;
} }
@ -244,7 +249,13 @@ class DefaultFancyReporter implements Required<EmigrateReporter> {
this.#finishMigration(migration); this.#finishMigration(migration);
} }
onFinished(_migrations: MigrationMetadataFinished[], error?: Error | undefined): void | PromiseLike<void> { onFinished(migrations: MigrationMetadataFinished[], error?: Error | undefined): void | PromiseLike<void> {
if (this.#parameters.command === 'new') {
for (const migration of migrations) {
this.#finishMigration(migration);
}
}
this.#error = error; this.#error = error;
this.#activeMigration = undefined; this.#activeMigration = undefined;
this.#stop(); this.#stop();
@ -264,10 +275,10 @@ class DefaultFancyReporter implements Required<EmigrateReporter> {
#render(): void { #render(): void {
const parts = [ const parts = [
getTitle({ directory: this.#directory, dry: this.#dry, cwd: this.#cwd }), getTitle(this.#parameters),
getHeaderMessage(this.#migrations, this.#lockedMigrations), getHeaderMessage(this.#migrations, this.#lockedMigrations),
this.#migrations?.map((migration) => getMigrationText(migration, this.#activeMigration)).join('\n') ?? '', this.#migrations?.map((migration) => getMigrationText(migration, this.#activeMigration)).join('\n') ?? '',
getSummary(this.#migrations), getSummary(this.#parameters.command, this.#migrations),
getError(this.#error), getError(this.#error),
]; ];
logUpdate('\n' + parts.filter(Boolean).join('\n\n') + '\n'); logUpdate('\n' + parts.filter(Boolean).join('\n\n') + '\n');
@ -293,8 +304,10 @@ class DefaultFancyReporter implements Required<EmigrateReporter> {
class DefaultReporter implements Required<EmigrateReporter> { class DefaultReporter implements Required<EmigrateReporter> {
#migrations?: MigrationMetadata[]; #migrations?: MigrationMetadata[];
#lockedMigrations?: MigrationMetadata[]; #lockedMigrations?: MigrationMetadata[];
#parameters!: ReporterInitParameters;
onInit(parameters: { directory: string; cwd: string; dry: boolean }): void | PromiseLike<void> { onInit(parameters: ReporterInitParameters): void | PromiseLike<void> {
this.#parameters = parameters;
console.log(''); console.log('');
console.log(getTitle(parameters)); console.log(getTitle(parameters));
console.log(''); console.log('');
@ -311,6 +324,10 @@ class DefaultReporter implements Required<EmigrateReporter> {
console.log(''); console.log('');
} }
onNewMigration(migration: MigrationMetadata, _content: string): Awaitable<void> {
console.log(getMigrationText(migration));
}
onMigrationStart(migration: MigrationMetadata): void | PromiseLike<void> { onMigrationStart(migration: MigrationMetadata): void | PromiseLike<void> {
console.log(getMigrationText(migration, migration)); console.log(getMigrationText(migration, migration));
} }
@ -329,7 +346,7 @@ class DefaultReporter implements Required<EmigrateReporter> {
onFinished(migrations: MigrationMetadataFinished[], error?: Error | undefined): void | PromiseLike<void> { onFinished(migrations: MigrationMetadataFinished[], error?: Error | undefined): void | PromiseLike<void> {
console.log(''); console.log('');
console.log(getSummary(migrations)); console.log(getSummary(this.#parameters.command, migrations));
console.log(''); console.log('');
if (error) { if (error) {

View file

@ -48,13 +48,16 @@ export default async function upCommand({
} }
const storage = await storagePlugin.initializeStorage(); const storage = await storagePlugin.initializeStorage();
const reporter = await getOrLoadReporter([lazyDefaultReporter, reporterConfig]); const reporter = await getOrLoadReporter([reporterConfig ?? lazyDefaultReporter]);
if (!reporter) { if (!reporter) {
throw new BadOptionError('reporter', 'No reporter found, please specify a reporter using the reporter option'); throw new BadOptionError(
'reporter',
'No reporter found, please specify an existing reporter using the reporter option',
);
} }
await reporter.onInit?.({ cwd, dry, directory }); await reporter.onInit?.({ command: 'up', cwd, dry, directory });
const path = await import('node:path'); const path = await import('node:path');
const fs = await import('node:fs/promises'); const fs = await import('node:fs/promises');