feat(cli): improve "up" command output and flow a lot
This commit is contained in:
parent
e5eec7cdf1
commit
59ec16b87b
14 changed files with 533 additions and 62 deletions
|
|
@ -213,6 +213,4 @@ try {
|
|||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export * from './types.js';
|
||||
|
||||
export const emigrate = () => {
|
||||
console.log('Done!');
|
||||
// console.log('Done!');
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import path from 'node:path';
|
|||
import { getTimestampPrefix, sanitizeMigrationName, getOrLoadPlugin } from '@emigrate/plugin-tools';
|
||||
import { BadOptionError, MissingArgumentsError, MissingOptionError, UnexpectedError } from './errors.js';
|
||||
import { type Config } from './types.js';
|
||||
import { stripLeadingPeriod } from './strip-leading-period.js';
|
||||
import { withLeadingPeriod } from './with-leading-period.js';
|
||||
|
||||
export default async function newCommand({ directory, template, plugins = [], extension }: Config, name: string) {
|
||||
if (!directory) {
|
||||
|
|
@ -34,7 +34,7 @@ export default async function newCommand({ directory, template, plugins = [], ex
|
|||
throw new UnexpectedError(`Failed to read template file: ${templatePath}`, { cause: error });
|
||||
}
|
||||
|
||||
filename = `${getTimestampPrefix()}_${sanitizeMigrationName(name)}.${stripLeadingPeriod(
|
||||
filename = `${getTimestampPrefix()}_${sanitizeMigrationName(name)}.${withLeadingPeriod(
|
||||
extension ?? fileExtension,
|
||||
)}`;
|
||||
}
|
||||
|
|
@ -56,7 +56,7 @@ export default async function newCommand({ directory, template, plugins = [], ex
|
|||
|
||||
if (extension && !hasGeneratedFile) {
|
||||
content = '';
|
||||
filename = `${getTimestampPrefix()}_${sanitizeMigrationName(name)}.${stripLeadingPeriod(extension)}`;
|
||||
filename = `${getTimestampPrefix()}_${sanitizeMigrationName(name)}.${withLeadingPeriod(extension)}`;
|
||||
}
|
||||
|
||||
if (!filename || content === undefined) {
|
||||
|
|
|
|||
|
|
@ -1,39 +1,344 @@
|
|||
import { type ReporterPlugin } from '@emigrate/plugin-tools/types';
|
||||
import path from 'node:path';
|
||||
import ansis from 'ansis';
|
||||
import logUpdate from 'log-update';
|
||||
import elegantSpinner from 'elegant-spinner';
|
||||
import figures from 'figures';
|
||||
import isInteractive from 'is-interactive';
|
||||
import prettyMs from 'pretty-ms';
|
||||
import {
|
||||
type MigrationMetadata,
|
||||
type MigrationMetadataFinished,
|
||||
type ReporterPlugin,
|
||||
} from '@emigrate/plugin-tools/types';
|
||||
|
||||
const reporterDefault: ReporterPlugin = {
|
||||
onInit({ dry, directory }) {
|
||||
console.log(`Running migrations in: ${directory}${dry ? ' (dry run)' : ''}`);
|
||||
},
|
||||
onCollectedMigrations(migrations) {
|
||||
console.log(`Found ${migrations.length} pending migrations`);
|
||||
},
|
||||
onLockedMigrations(migrations) {
|
||||
console.log(`Locked ${migrations.length} migrations`);
|
||||
},
|
||||
onMigrationStart(migration) {
|
||||
console.log(`- ${migration.relativeFilePath} (running)`);
|
||||
},
|
||||
onMigrationSuccess(migration) {
|
||||
console.log(`- ${migration.relativeFilePath} (success) [${migration.duration}ms]`);
|
||||
},
|
||||
onMigrationError(migration, error) {
|
||||
console.error(`- ${migration.relativeFilePath} (failed!) [${migration.duration}ms]`);
|
||||
console.error(error.cause ?? error);
|
||||
},
|
||||
onMigrationSkip(migration) {
|
||||
console.log(`- ${migration.relativeFilePath} (skipped)`);
|
||||
},
|
||||
onFinished(migrations, error) {
|
||||
const totalDuration = migrations.reduce((total, migration) => total + migration.duration, 0);
|
||||
type Status = ReturnType<typeof getMigrationStatus>;
|
||||
|
||||
if (error) {
|
||||
console.error('Failed to run migrations! [total duration: %dms]', totalDuration);
|
||||
console.error(error.cause ?? error);
|
||||
const interactive = isInteractive();
|
||||
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')));
|
||||
};
|
||||
|
||||
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 getMigrationStatus = (
|
||||
migration: MigrationMetadata | MigrationMetadataFinished,
|
||||
activeMigration?: MigrationMetadata,
|
||||
) => {
|
||||
if ('status' in migration) {
|
||||
return migration.status;
|
||||
}
|
||||
|
||||
return migration.name === activeMigration?.name ? 'running' : 'pending';
|
||||
};
|
||||
|
||||
const getIcon = (status: Status) => {
|
||||
switch (status) {
|
||||
case 'running': {
|
||||
return ansis.cyan(spinner());
|
||||
}
|
||||
|
||||
case 'pending': {
|
||||
return ansis.gray(figures.pointerSmall);
|
||||
}
|
||||
|
||||
case 'done': {
|
||||
return ansis.green(figures.tick);
|
||||
}
|
||||
|
||||
case 'failed': {
|
||||
return ansis.red(figures.cross);
|
||||
}
|
||||
|
||||
case 'skipped': {
|
||||
return ansis.yellow(figures.circle);
|
||||
}
|
||||
|
||||
default: {
|
||||
return ' ';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getName = (name: string, status?: Status) => {
|
||||
switch (status) {
|
||||
case 'failed': {
|
||||
return ansis.red(name);
|
||||
}
|
||||
|
||||
case 'skipped': {
|
||||
return ansis.yellow(name);
|
||||
}
|
||||
|
||||
default: {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getMigrationText = (
|
||||
migration: MigrationMetadata | MigrationMetadataFinished,
|
||||
activeMigration?: MigrationMetadata,
|
||||
) => {
|
||||
const nameWithoutExtension = migration.name.slice(0, -migration.extension.length);
|
||||
const status = getMigrationStatus(migration, activeMigration);
|
||||
const parts = [' ', getIcon(status)];
|
||||
|
||||
parts.push(`${getName(nameWithoutExtension, status)}${ansis.dim(migration.extension)}`);
|
||||
|
||||
if ('status' in migration) {
|
||||
parts.push(ansis.gray(`(${migration.status})`));
|
||||
} else if (migration.name === activeMigration?.name) {
|
||||
parts.push(ansis.gray('(running)'));
|
||||
}
|
||||
|
||||
if ('duration' in migration && migration.duration) {
|
||||
parts.push(formatDuration(migration.duration));
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
const getError = (error?: Error, indent = ' ') => {
|
||||
if (!error) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let errorTitle: string;
|
||||
let stack: string[] = [];
|
||||
|
||||
if (error.stack) {
|
||||
// @ts-expect-error error won't be undefined here
|
||||
[errorTitle, ...stack] = error.stack.split('\n');
|
||||
} else if (error.name) {
|
||||
errorTitle = `${error.name}: ${error.message}`;
|
||||
} else {
|
||||
errorTitle = error.message;
|
||||
}
|
||||
|
||||
const parts = [`${indent}${ansis.bold.red(errorTitle)}`, ...stack.map((line) => `${indent}${ansis.dim(line)}`)];
|
||||
|
||||
if (error.cause instanceof Error) {
|
||||
const nextIndent = `${indent} `;
|
||||
parts.push(`\n${nextIndent}${ansis.bold('Original error cause:')}\n`, getError(error.cause, nextIndent));
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
};
|
||||
|
||||
const getSummary = (migrations: Array<MigrationMetadata | MigrationMetadataFinished> = []) => {
|
||||
const total = migrations.length;
|
||||
let done = 0;
|
||||
let failed = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const migration of migrations) {
|
||||
const status = getMigrationStatus(migration);
|
||||
switch (status) {
|
||||
case 'done': {
|
||||
done++;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'failed': {
|
||||
failed++;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'skipped': {
|
||||
skipped++;
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const statusLine = [
|
||||
failed ? ansis.bold.red(`${failed} failed`) : '',
|
||||
done ? ansis.bold.green(`${done} done`) : '',
|
||||
skipped ? ansis.bold.yellow(`${skipped} skipped`) : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(ansis.dim(' | '));
|
||||
|
||||
if (!statusLine) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return ` ${statusLine}${ansis.gray(` (${total} total)`)}`;
|
||||
};
|
||||
|
||||
const getHeaderMessage = (migrations?: MigrationMetadata[], lockedMigrations?: MigrationMetadata[]) => {
|
||||
if (!migrations || !lockedMigrations) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (migrations.length === 0) {
|
||||
return ' No pending migrations found';
|
||||
}
|
||||
|
||||
if (migrations.length === lockedMigrations.length) {
|
||||
return ` ${ansis.bold(migrations.length.toString())} ${ansis.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 ` ${ansis.bold(`${lockedMigrations.length} of ${migrations.length}`)} ${ansis.dim(
|
||||
'pending migrations to run',
|
||||
)} ${ansis.yellow(`(${migrations.length - lockedMigrations.length} locked)`)}`;
|
||||
};
|
||||
|
||||
class DefaultFancyReporter implements Required<ReporterPlugin> {
|
||||
#migrations: Array<MigrationMetadata | MigrationMetadataFinished> | undefined;
|
||||
#lockedMigrations: MigrationMetadata[] | undefined;
|
||||
#activeMigration: MigrationMetadata | undefined;
|
||||
#error: Error | undefined;
|
||||
#directory!: string;
|
||||
#cwd!: string;
|
||||
#dry!: boolean;
|
||||
#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;
|
||||
|
||||
this.#start();
|
||||
}
|
||||
|
||||
onCollectedMigrations(migrations: MigrationMetadata[]): void | PromiseLike<void> {
|
||||
this.#migrations = migrations;
|
||||
}
|
||||
|
||||
onLockedMigrations(migrations: MigrationMetadata[]): void | PromiseLike<void> {
|
||||
this.#lockedMigrations = migrations;
|
||||
}
|
||||
|
||||
onMigrationStart(migration: MigrationMetadata): void | PromiseLike<void> {
|
||||
this.#activeMigration = migration;
|
||||
}
|
||||
|
||||
onMigrationSuccess(migration: MigrationMetadataFinished): void | PromiseLike<void> {
|
||||
this.#finishMigration(migration);
|
||||
}
|
||||
|
||||
onMigrationError(migration: MigrationMetadataFinished, _error: Error): void | PromiseLike<void> {
|
||||
this.#finishMigration(migration);
|
||||
}
|
||||
|
||||
onMigrationSkip(migration: MigrationMetadataFinished): void | PromiseLike<void> {
|
||||
this.#finishMigration(migration);
|
||||
}
|
||||
|
||||
onFinished(_migrations: MigrationMetadataFinished[], error?: Error | undefined): void | PromiseLike<void> {
|
||||
this.#error = error;
|
||||
this.#activeMigration = undefined;
|
||||
this.#stop();
|
||||
}
|
||||
|
||||
#finishMigration(migration: MigrationMetadataFinished): void {
|
||||
if (!this.#migrations) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Successfully ran ${migrations.length} migrations! [total duration: ${totalDuration}ms]`);
|
||||
},
|
||||
};
|
||||
const index = this.#migrations.findIndex((m) => m.name === migration.name);
|
||||
|
||||
if (index !== -1) {
|
||||
this.#migrations[index] = migration;
|
||||
}
|
||||
}
|
||||
|
||||
#render(): void {
|
||||
const parts = [
|
||||
getTitle({ directory: this.#directory, dry: this.#dry, cwd: this.#cwd }),
|
||||
getHeaderMessage(this.#migrations, this.#lockedMigrations),
|
||||
this.#migrations?.map((migration) => getMigrationText(migration, this.#activeMigration)).join('\n') ?? '',
|
||||
getSummary(this.#migrations),
|
||||
getError(this.#error),
|
||||
];
|
||||
logUpdate('\n' + parts.filter(Boolean).join('\n\n') + '\n');
|
||||
}
|
||||
|
||||
#start(): void {
|
||||
this.#render();
|
||||
this.#interval = setInterval(() => {
|
||||
this.#render();
|
||||
}, 80).unref();
|
||||
}
|
||||
|
||||
#stop(): void {
|
||||
if (this.#interval) {
|
||||
clearInterval(this.#interval);
|
||||
this.#interval = undefined;
|
||||
}
|
||||
|
||||
this.#render();
|
||||
}
|
||||
}
|
||||
|
||||
class DefaultReporter implements Required<ReporterPlugin> {
|
||||
#migrations?: MigrationMetadata[];
|
||||
#lockedMigrations?: MigrationMetadata[];
|
||||
|
||||
onInit(parameters: { directory: string; cwd: string; dry: boolean }): void | PromiseLike<void> {
|
||||
console.log('');
|
||||
console.log(getTitle(parameters));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
onCollectedMigrations(migrations: MigrationMetadata[]): void | PromiseLike<void> {
|
||||
this.#migrations = migrations;
|
||||
}
|
||||
|
||||
onLockedMigrations(migrations: MigrationMetadata[]): void | PromiseLike<void> {
|
||||
this.#lockedMigrations = migrations;
|
||||
|
||||
console.log(getHeaderMessage(this.#migrations, this.#lockedMigrations));
|
||||
console.log('');
|
||||
}
|
||||
|
||||
onMigrationStart(migration: MigrationMetadata): void | PromiseLike<void> {
|
||||
console.log(getMigrationText(migration, migration));
|
||||
}
|
||||
|
||||
onMigrationSuccess(migration: MigrationMetadataFinished): void | PromiseLike<void> {
|
||||
console.log(getMigrationText(migration));
|
||||
}
|
||||
|
||||
onMigrationError(migration: MigrationMetadataFinished, _error: Error): void | PromiseLike<void> {
|
||||
console.error(getMigrationText(migration));
|
||||
}
|
||||
|
||||
onMigrationSkip(migration: MigrationMetadataFinished): void | PromiseLike<void> {
|
||||
console.log(getMigrationText(migration));
|
||||
}
|
||||
|
||||
onFinished(migrations: MigrationMetadataFinished[], error?: Error | undefined): void | PromiseLike<void> {
|
||||
console.log('');
|
||||
console.log(getSummary(migrations));
|
||||
console.log('');
|
||||
|
||||
if (error) {
|
||||
console.error(getError(error));
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reporterDefault = interactive ? new DefaultFancyReporter() : new DefaultReporter();
|
||||
|
||||
export default reporterDefault;
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export const stripLeadingPeriod = (string: string) => string.replace(/^\./, '');
|
||||
|
|
@ -18,7 +18,7 @@ import {
|
|||
MissingOptionError,
|
||||
} from './errors.js';
|
||||
import { type Config } from './types.js';
|
||||
import { stripLeadingPeriod } from './strip-leading-period.js';
|
||||
import { withLeadingPeriod } from './with-leading-period.js';
|
||||
import pluginLoaderJs from './plugin-loader-js.js';
|
||||
import pluginReporterDefault from './plugin-reporter-default.js';
|
||||
|
||||
|
|
@ -76,7 +76,7 @@ export default async function upCommand({ directory, dry = false, plugins = [] }
|
|||
name,
|
||||
filePath,
|
||||
relativeFilePath: path.relative(cwd, filePath),
|
||||
extension: stripLeadingPeriod(path.extname(name)),
|
||||
extension: withLeadingPeriod(path.extname(name)),
|
||||
directory,
|
||||
cwd,
|
||||
};
|
||||
|
|
@ -108,7 +108,7 @@ export default async function upCommand({ directory, dry = false, plugins = [] }
|
|||
[
|
||||
extension,
|
||||
loaderPlugins.find((plugin) =>
|
||||
plugin.loadableExtensions.some((loadableExtension) => stripLeadingPeriod(loadableExtension) === extension),
|
||||
plugin.loadableExtensions.some((loadableExtension) => withLeadingPeriod(loadableExtension) === extension),
|
||||
),
|
||||
] as const,
|
||||
),
|
||||
|
|
@ -123,33 +123,56 @@ export default async function upCommand({ directory, dry = false, plugins = [] }
|
|||
await reporter.onCollectedMigrations?.(migrationFiles);
|
||||
|
||||
if (migrationFiles.length === 0 || dry || migrationHistoryError) {
|
||||
await reporter.onLockedMigrations?.([]);
|
||||
await reporter.onLockedMigrations?.(migrationFiles);
|
||||
|
||||
for await (const migration of migrationFiles) {
|
||||
const finishedMigrations: MigrationMetadataFinished[] = migrationFiles.map((migration) => ({
|
||||
...migration,
|
||||
duration: 0,
|
||||
status: 'skipped',
|
||||
}));
|
||||
|
||||
for await (const migration of finishedMigrations) {
|
||||
await reporter.onMigrationSkip?.(migration);
|
||||
}
|
||||
|
||||
await reporter.onFinished?.(
|
||||
migrationFiles.map((migration) => ({ ...migration, status: 'skipped', duration: 0 })),
|
||||
migrationHistoryError,
|
||||
);
|
||||
await reporter.onFinished?.(finishedMigrations, migrationHistoryError);
|
||||
return;
|
||||
}
|
||||
|
||||
const lockedMigrationFiles = await storage.lock(migrationFiles);
|
||||
let lockedMigrationFiles: MigrationMetadata[] = [];
|
||||
|
||||
let cleaningUp = false;
|
||||
try {
|
||||
lockedMigrationFiles = await storage.lock(migrationFiles);
|
||||
|
||||
await reporter.onLockedMigrations?.(lockedMigrationFiles);
|
||||
} catch (error) {
|
||||
for await (const migration of migrationFiles) {
|
||||
await reporter.onMigrationSkip?.({ ...migration, duration: 0, status: 'skipped' });
|
||||
}
|
||||
|
||||
await reporter.onFinished?.([], error instanceof Error ? error : new Error(String(error)));
|
||||
return;
|
||||
}
|
||||
|
||||
const nonLockedMigrations = migrationFiles.filter((migration) => !lockedMigrationFiles.includes(migration));
|
||||
|
||||
for await (const migration of nonLockedMigrations) {
|
||||
await reporter.onMigrationSkip?.({ ...migration, duration: 0, status: 'skipped' });
|
||||
}
|
||||
|
||||
let cleaningUp: Promise<void> | undefined;
|
||||
|
||||
const cleanup = async () => {
|
||||
if (cleaningUp) {
|
||||
return;
|
||||
return cleaningUp;
|
||||
}
|
||||
|
||||
process.off('SIGINT', cleanup);
|
||||
process.off('SIGTERM', cleanup);
|
||||
|
||||
cleaningUp = true;
|
||||
await storage.unlock(lockedMigrationFiles);
|
||||
cleaningUp = storage.unlock(lockedMigrationFiles);
|
||||
|
||||
return cleaningUp;
|
||||
};
|
||||
|
||||
process.on('SIGINT', cleanup);
|
||||
|
|
@ -162,8 +185,9 @@ export default async function upCommand({ directory, dry = false, plugins = [] }
|
|||
const lastMigrationStatus = finishedMigrations.at(-1)?.status;
|
||||
|
||||
if (lastMigrationStatus === 'failed' || lastMigrationStatus === 'skipped') {
|
||||
await reporter.onMigrationSkip?.(migration);
|
||||
finishedMigrations.push({ ...migration, status: 'skipped', duration: 0 });
|
||||
const finishedMigration: MigrationMetadataFinished = { ...migration, status: 'skipped', duration: 0 };
|
||||
await reporter.onMigrationSkip?.(finishedMigration);
|
||||
finishedMigrations.push(finishedMigration);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -204,7 +228,7 @@ export default async function upCommand({ directory, dry = false, plugins = [] }
|
|||
const duration = getDuration(start);
|
||||
const finishedMigration: MigrationMetadataFinished = {
|
||||
...migration,
|
||||
status: 'done',
|
||||
status: 'failed',
|
||||
duration,
|
||||
error: errorInstance,
|
||||
};
|
||||
|
|
@ -218,7 +242,7 @@ export default async function upCommand({ directory, dry = false, plugins = [] }
|
|||
} finally {
|
||||
const firstError = finishedMigrations.find((migration) => migration.status === 'failed')?.error;
|
||||
|
||||
await reporter.onFinished?.(finishedMigrations, firstError);
|
||||
await cleanup();
|
||||
await reporter.onFinished?.(finishedMigrations, firstError);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
packages/cli/src/with-leading-period.ts
Normal file
1
packages/cli/src/with-leading-period.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const withLeadingPeriod = (string: string) => (string.startsWith('.') ? string : `.${string}`);
|
||||
Loading…
Add table
Add a link
Reference in a new issue