feat(cli): add "reporter" option for the "new" command for improved logging
This commit is contained in:
parent
5e8572b67f
commit
da1eee3c75
5 changed files with 132 additions and 54 deletions
5
.changeset/gorgeous-falcons-train.md
Normal file
5
.changeset/gorgeous-falcons-train.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@emigrate/cli': minor
|
||||
---
|
||||
|
||||
Add "reporter" option for the "new" command and use it for improved logging
|
||||
|
|
@ -97,6 +97,10 @@ const newMigration: Action = async (args) => {
|
|||
type: 'string',
|
||||
short: 'd',
|
||||
},
|
||||
reporter: {
|
||||
type: 'string',
|
||||
short: 'r',
|
||||
},
|
||||
template: {
|
||||
type: 'string',
|
||||
short: 't',
|
||||
|
|
@ -123,6 +127,7 @@ Options:
|
|||
|
||||
-h, --help Show this help message and exit
|
||||
-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)
|
||||
-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)
|
||||
|
|
@ -145,13 +150,18 @@ Examples:
|
|||
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 name = positionals.join(' ').trim();
|
||||
|
||||
try {
|
||||
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) {
|
||||
if (error instanceof ShowUsageError) {
|
||||
console.error(error.message, '\n');
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
import process from 'node:process';
|
||||
import fs from 'node:fs/promises';
|
||||
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 { type Config } from './types.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) {
|
||||
throw new MissingOptionError('directory');
|
||||
}
|
||||
|
|
@ -19,6 +25,19 @@ export default async function newCommand({ directory, template, plugins = [], ex
|
|||
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 content: string | undefined;
|
||||
|
||||
|
|
@ -31,7 +50,11 @@ export default async function newCommand({ directory, template, plugins = [], ex
|
|||
content = await fs.readFile(templatePath, 'utf8');
|
||||
content = content.replaceAll('{{name}}', name);
|
||||
} 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)}`;
|
||||
|
|
@ -67,8 +90,30 @@ export default async function newCommand({ directory, template, plugins = [], ex
|
|||
const directoryPath = path.resolve(process.cwd(), directory);
|
||||
const filePath = path.resolve(directoryPath, filename);
|
||||
|
||||
await createDirectory(directoryPath);
|
||||
await saveFile(filePath, content);
|
||||
const migration: MigrationMetadata = {
|
||||
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) {
|
||||
|
|
@ -82,8 +127,6 @@ async function createDirectory(directoryPath: string) {
|
|||
async function saveFile(filePath: string, content: string) {
|
||||
try {
|
||||
await fs.writeFile(filePath, content);
|
||||
|
||||
console.log(`Created migration file: ${path.relative(process.cwd(), filePath)}`);
|
||||
} catch (error) {
|
||||
throw new UnexpectedError(`Failed to write migration file: ${filePath}`, { cause: error });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 elegantSpinner from 'elegant-spinner';
|
||||
import figures from 'figures';
|
||||
|
|
@ -9,6 +9,8 @@ import {
|
|||
type MigrationMetadata,
|
||||
type MigrationMetadataFinished,
|
||||
type EmigrateReporter,
|
||||
type ReporterInitParameters,
|
||||
type Awaitable,
|
||||
} from '@emigrate/plugin-tools/types';
|
||||
|
||||
type Status = ReturnType<typeof getMigrationStatus>;
|
||||
|
|
@ -19,12 +21,12 @@ const spinner = interactive ? elegantSpinner() : () => figures.pointerSmall;
|
|||
const formatDuration = (duration: number): string => {
|
||||
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 }) => {
|
||||
return `${ansis.bgBlueBright(ansis.black(' Emigrate '))} ${ansis.gray(cwd + path.sep)}${directory}${
|
||||
dry ? ansis.yellow(' (dry run)') : ''
|
||||
const getTitle = ({ command, directory, dry, cwd }: ReporterInitParameters) => {
|
||||
return `${black.bgBlueBright` Emigrate `} ${blueBright.bold(command)} ${gray(cwd + path.sep)}${directory}${
|
||||
dry ? yellow` (dry run)` : ''
|
||||
}`;
|
||||
};
|
||||
|
||||
|
|
@ -42,23 +44,23 @@ const getMigrationStatus = (
|
|||
const getIcon = (status: Status) => {
|
||||
switch (status) {
|
||||
case 'running': {
|
||||
return ansis.cyan(spinner());
|
||||
return cyan(spinner());
|
||||
}
|
||||
|
||||
case 'pending': {
|
||||
return ansis.gray(figures.pointerSmall);
|
||||
return gray(figures.pointerSmall);
|
||||
}
|
||||
|
||||
case 'done': {
|
||||
return ansis.green(figures.tick);
|
||||
return green(figures.tick);
|
||||
}
|
||||
|
||||
case 'failed': {
|
||||
return ansis.red(figures.cross);
|
||||
return red(figures.cross);
|
||||
}
|
||||
|
||||
case 'skipped': {
|
||||
return ansis.yellow(figures.circle);
|
||||
return yellow(figures.circle);
|
||||
}
|
||||
|
||||
default: {
|
||||
|
|
@ -70,11 +72,11 @@ const getIcon = (status: Status) => {
|
|||
const getName = (name: string, status?: Status) => {
|
||||
switch (status) {
|
||||
case 'failed': {
|
||||
return ansis.red(name);
|
||||
return red(name);
|
||||
}
|
||||
|
||||
case 'skipped': {
|
||||
return ansis.yellow(name);
|
||||
return yellow(name);
|
||||
}
|
||||
|
||||
default: {
|
||||
|
|
@ -91,12 +93,12 @@ const getMigrationText = (
|
|||
const status = getMigrationStatus(migration, activeMigration);
|
||||
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) {
|
||||
parts.push(ansis.gray(`(${migration.status})`));
|
||||
parts.push(gray(`(${migration.status})`));
|
||||
} else if (migration.name === activeMigration?.name) {
|
||||
parts.push(ansis.gray('(running)'));
|
||||
parts.push(gray`(running)`);
|
||||
}
|
||||
|
||||
if ('duration' in migration && migration.duration) {
|
||||
|
|
@ -123,17 +125,20 @@ const getError = (error?: Error, indent = ' ') => {
|
|||
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) {
|
||||
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');
|
||||
};
|
||||
|
||||
const getSummary = (migrations: Array<MigrationMetadata | MigrationMetadataFinished> = []) => {
|
||||
const getSummary = (
|
||||
command: ReporterInitParameters['command'],
|
||||
migrations: Array<MigrationMetadata | MigrationMetadataFinished> = [],
|
||||
) => {
|
||||
const total = migrations.length;
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
|
|
@ -163,19 +168,21 @@ const getSummary = (migrations: Array<MigrationMetadata | MigrationMetadataFinis
|
|||
}
|
||||
}
|
||||
|
||||
const showTotal = command !== 'new';
|
||||
|
||||
const statusLine = [
|
||||
failed ? ansis.bold.red(`${failed} failed`) : '',
|
||||
done ? ansis.bold.green(`${done} done`) : '',
|
||||
skipped ? ansis.bold.yellow(`${skipped} skipped`) : '',
|
||||
failed ? red.bold(`${failed} failed`) : '',
|
||||
done ? green.bold(`${done} ${command === 'new' ? 'created' : 'done'}`) : '',
|
||||
skipped ? yellow.bold(`${skipped} skipped`) : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(ansis.dim(' | '));
|
||||
.join(dim(' | '));
|
||||
|
||||
if (!statusLine) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return ` ${statusLine}${ansis.gray(` (${total} total)`)}`;
|
||||
return ` ${statusLine}${showTotal ? gray(` (${total} total)`) : ''}`;
|
||||
};
|
||||
|
||||
const getHeaderMessage = (migrations?: MigrationMetadata[], lockedMigrations?: MigrationMetadata[]) => {
|
||||
|
|
@ -188,18 +195,16 @@ const getHeaderMessage = (migrations?: MigrationMetadata[], lockedMigrations?: M
|
|||
}
|
||||
|
||||
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) {
|
||||
return ` ${ansis.bold(`0 of ${migrations.length}`)} ${ansis.dim('pending migrations to run')} ${ansis.redBright(
|
||||
'(all locked)',
|
||||
)}`;
|
||||
return ` ${bold(`0 of ${migrations.length}`)} ${dim('pending migrations to run')} ${redBright('(all locked)')}`;
|
||||
}
|
||||
|
||||
return ` ${ansis.bold(`${lockedMigrations.length} of ${migrations.length}`)} ${ansis.dim(
|
||||
'pending migrations to run',
|
||||
)} ${ansis.yellow(`(${migrations.length - lockedMigrations.length} locked)`)}`;
|
||||
return ` ${bold(`${lockedMigrations.length} of ${migrations.length}`)} ${dim('pending migrations to run')} ${yellow(
|
||||
`(${migrations.length - lockedMigrations.length} locked)`,
|
||||
)}`;
|
||||
};
|
||||
|
||||
class DefaultFancyReporter implements Required<EmigrateReporter> {
|
||||
|
|
@ -207,15 +212,11 @@ class DefaultFancyReporter implements Required<EmigrateReporter> {
|
|||
#lockedMigrations: MigrationMetadata[] | undefined;
|
||||
#activeMigration: MigrationMetadata | undefined;
|
||||
#error: Error | undefined;
|
||||
#directory!: string;
|
||||
#cwd!: string;
|
||||
#dry!: boolean;
|
||||
#parameters!: ReporterInitParameters;
|
||||
#interval: NodeJS.Timeout | undefined;
|
||||
|
||||
onInit(parameters: { directory: string; cwd: string; dry: boolean }): void | PromiseLike<void> {
|
||||
this.#directory = parameters.directory;
|
||||
this.#dry = parameters.dry;
|
||||
this.#cwd = parameters.cwd;
|
||||
onInit(parameters: ReporterInitParameters): void | PromiseLike<void> {
|
||||
this.#parameters = parameters;
|
||||
|
||||
this.#start();
|
||||
}
|
||||
|
|
@ -228,6 +229,10 @@ class DefaultFancyReporter implements Required<EmigrateReporter> {
|
|||
this.#lockedMigrations = migrations;
|
||||
}
|
||||
|
||||
onNewMigration(migration: MigrationMetadata, _content: string): Awaitable<void> {
|
||||
this.#migrations = [migration];
|
||||
}
|
||||
|
||||
onMigrationStart(migration: MigrationMetadata): void | PromiseLike<void> {
|
||||
this.#activeMigration = migration;
|
||||
}
|
||||
|
|
@ -244,7 +249,13 @@ class DefaultFancyReporter implements Required<EmigrateReporter> {
|
|||
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.#activeMigration = undefined;
|
||||
this.#stop();
|
||||
|
|
@ -264,10 +275,10 @@ class DefaultFancyReporter implements Required<EmigrateReporter> {
|
|||
|
||||
#render(): void {
|
||||
const parts = [
|
||||
getTitle({ directory: this.#directory, dry: this.#dry, cwd: this.#cwd }),
|
||||
getTitle(this.#parameters),
|
||||
getHeaderMessage(this.#migrations, this.#lockedMigrations),
|
||||
this.#migrations?.map((migration) => getMigrationText(migration, this.#activeMigration)).join('\n') ?? '',
|
||||
getSummary(this.#migrations),
|
||||
getSummary(this.#parameters.command, this.#migrations),
|
||||
getError(this.#error),
|
||||
];
|
||||
logUpdate('\n' + parts.filter(Boolean).join('\n\n') + '\n');
|
||||
|
|
@ -293,8 +304,10 @@ class DefaultFancyReporter implements Required<EmigrateReporter> {
|
|||
class DefaultReporter implements Required<EmigrateReporter> {
|
||||
#migrations?: 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(getTitle(parameters));
|
||||
console.log('');
|
||||
|
|
@ -311,6 +324,10 @@ class DefaultReporter implements Required<EmigrateReporter> {
|
|||
console.log('');
|
||||
}
|
||||
|
||||
onNewMigration(migration: MigrationMetadata, _content: string): Awaitable<void> {
|
||||
console.log(getMigrationText(migration));
|
||||
}
|
||||
|
||||
onMigrationStart(migration: MigrationMetadata): void | PromiseLike<void> {
|
||||
console.log(getMigrationText(migration, migration));
|
||||
}
|
||||
|
|
@ -329,7 +346,7 @@ class DefaultReporter implements Required<EmigrateReporter> {
|
|||
|
||||
onFinished(migrations: MigrationMetadataFinished[], error?: Error | undefined): void | PromiseLike<void> {
|
||||
console.log('');
|
||||
console.log(getSummary(migrations));
|
||||
console.log(getSummary(this.#parameters.command, migrations));
|
||||
console.log('');
|
||||
|
||||
if (error) {
|
||||
|
|
|
|||
|
|
@ -48,13 +48,16 @@ export default async function upCommand({
|
|||
}
|
||||
|
||||
const storage = await storagePlugin.initializeStorage();
|
||||
const reporter = await getOrLoadReporter([lazyDefaultReporter, reporterConfig]);
|
||||
const reporter = await getOrLoadReporter([reporterConfig ?? lazyDefaultReporter]);
|
||||
|
||||
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 fs = await import('node:fs/promises');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue