From 53cdb2323761932b691038d0a16edecd32e1bd89 Mon Sep 17 00:00:00 2001 From: Joakim Carlstein Date: Wed, 22 Nov 2023 14:19:10 +0100 Subject: [PATCH] feat(cli): implement the "list" command for listing migration history and pending migrations --- .changeset/four-zoos-kiss.md | 5 ++ packages/cli/src/cli.ts | 54 ++++++++++++++++-- packages/cli/src/commands/list.ts | 82 +++++++++++++++++++++++++++ packages/cli/src/commands/up.ts | 24 +------- packages/cli/src/errors.ts | 2 +- packages/cli/src/get-migrations.ts | 28 +++++++++ packages/cli/src/reporters/default.ts | 40 ++++++++++--- packages/plugin-tools/src/types.ts | 2 +- 8 files changed, 199 insertions(+), 38 deletions(-) create mode 100644 .changeset/four-zoos-kiss.md create mode 100644 packages/cli/src/commands/list.ts create mode 100644 packages/cli/src/get-migrations.ts diff --git a/.changeset/four-zoos-kiss.md b/.changeset/four-zoos-kiss.md new file mode 100644 index 0000000..bf71938 --- /dev/null +++ b/.changeset/four-zoos-kiss.md @@ -0,0 +1,5 @@ +--- +'@emigrate/cli': minor +--- + +Implement the "list" command for listing the full migration history and any pending migrations diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 93524b5..a75c49c 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -175,6 +175,7 @@ Examples: }; const list: Action = async (args) => { + const config = await getConfig('list'); const { values } = parseArgs({ args, options: { @@ -182,17 +183,60 @@ const list: Action = async (args) => { type: 'boolean', short: 'h', }, - plugin: { + directory: { type: 'string', - short: 'p', - multiple: true, - default: [], + short: 'd', + }, + reporter: { + type: 'string', + short: 'r', + }, + storage: { + type: 'string', + short: 's', }, }, allowPositionals: false, }); - console.log(values); + const usage = `Usage: emigrate list [options] + +List all migrations and their status. This command does not run any migrations. + +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 migrations + -s, --storage The storage to use to get the migration history (required) + +Examples: + + emigrate list -d migrations -s fs + emigrate list --directory ./migrations --storage postgres --reporter json +`; + + if (values.help) { + console.log(usage); + process.exitCode = 1; + return; + } + + const { directory = config.directory, storage = config.storage, reporter = config.reporter } = values; + + try { + const { default: listCommand } = await import('./commands/list.js'); + await listCommand({ directory, storage, reporter }); + } catch (error) { + if (error instanceof ShowUsageError) { + console.error(error.message, '\n'); + console.log(usage); + process.exitCode = 1; + return; + } + + throw error; + } }; const commands: Record = { diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts new file mode 100644 index 0000000..10664e6 --- /dev/null +++ b/packages/cli/src/commands/list.ts @@ -0,0 +1,82 @@ +import process from 'node:process'; +import path from 'node:path'; +import { getOrLoadReporter, getOrLoadStorage } from '@emigrate/plugin-tools'; +import { type MigrationMetadataFinished } from '@emigrate/plugin-tools/types'; +import { BadOptionError, MigrationHistoryError, MissingOptionError } from '../errors.js'; +import { type Config } from '../types.js'; +import { withLeadingPeriod } from '../with-leading-period.js'; +import { getMigrations } from '../get-migrations.js'; + +const lazyDefaultReporter = async () => import('../reporters/default.js'); + +export default async function listCommand({ directory, reporter: reporterConfig, storage: storageConfig }: Config) { + if (!directory) { + throw new MissingOptionError('directory'); + } + + const cwd = process.cwd(); + const storagePlugin = await getOrLoadStorage([storageConfig]); + + if (!storagePlugin) { + throw new BadOptionError('storage', 'No storage found, please specify a storage using the storage option'); + } + + const storage = await storagePlugin.initializeStorage(); + 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: 'list', cwd, dry: false, directory }); + + const migrationFiles = await getMigrations(cwd, directory); + + let migrationHistoryError: MigrationHistoryError | undefined; + + const finishedMigrations: MigrationMetadataFinished[] = []; + + for await (const migrationHistoryEntry of storage.getHistory()) { + const filePath = path.resolve(cwd, directory, migrationHistoryEntry.name); + const finishedMigration: MigrationMetadataFinished = { + name: migrationHistoryEntry.name, + status: migrationHistoryEntry.status, + filePath, + relativeFilePath: path.relative(cwd, filePath), + extension: withLeadingPeriod(path.extname(migrationHistoryEntry.name)), + directory, + cwd, + duration: 0, + }; + + if (migrationHistoryEntry.status === 'failed') { + migrationHistoryError = new MigrationHistoryError( + `Migration ${migrationHistoryEntry.name} is in a failed state`, + migrationHistoryEntry, + ); + + await reporter.onMigrationError?.(finishedMigration, migrationHistoryError); + } else { + await reporter.onMigrationSuccess?.(finishedMigration); + } + + finishedMigrations.push(finishedMigration); + + const index = migrationFiles.findIndex((migrationFile) => migrationFile.name === migrationHistoryEntry.name); + + if (index !== -1) { + migrationFiles.splice(index, 1); + } + } + + for await (const migration of migrationFiles) { + const finishedMigration: MigrationMetadataFinished = { ...migration, status: 'pending', duration: 0 }; + await reporter.onMigrationSkip?.(finishedMigration); + finishedMigrations.push(finishedMigration); + } + + await reporter.onFinished?.(finishedMigrations, migrationHistoryError); +} diff --git a/packages/cli/src/commands/up.ts b/packages/cli/src/commands/up.ts index 22315b6..4614f13 100644 --- a/packages/cli/src/commands/up.ts +++ b/packages/cli/src/commands/up.ts @@ -17,6 +17,7 @@ import { import { type Config } from '../types.js'; import { withLeadingPeriod } from '../with-leading-period.js'; import pluginLoaderJs from '../plugin-loader-js.js'; +import { getMigrations } from '../get-migrations.js'; type ExtraFlags = { dry?: boolean; @@ -59,28 +60,7 @@ export default async function upCommand({ await reporter.onInit?.({ command: 'up', cwd, dry, directory }); - const path = await import('node:path'); - const fs = await import('node:fs/promises'); - - const allFilesInMigrationDirectory = await fs.readdir(path.resolve(process.cwd(), directory), { - withFileTypes: true, - }); - - const migrationFiles: MigrationMetadata[] = allFilesInMigrationDirectory - .filter((file) => file.isFile() && !file.name.startsWith('.') && !file.name.startsWith('_')) - .sort((a, b) => a.name.localeCompare(b.name)) - .map(({ name }) => { - const filePath = path.resolve(process.cwd(), directory, name); - - return { - name, - filePath, - relativeFilePath: path.relative(cwd, filePath), - extension: withLeadingPeriod(path.extname(name)), - directory, - cwd, - }; - }); + const migrationFiles = await getMigrations(cwd, directory); let migrationHistoryError: MigrationHistoryError | undefined; diff --git a/packages/cli/src/errors.ts b/packages/cli/src/errors.ts index 0aa7af6..1644b1c 100644 --- a/packages/cli/src/errors.ts +++ b/packages/cli/src/errors.ts @@ -46,7 +46,7 @@ export class MigrationHistoryError extends EmigrateError { message: string, public entry: MigrationHistoryEntry, ) { - super('ERR_MIGRATION_HISTORY', message); + super('ERR_MIGRATION_HISTORY', message, { cause: entry.error }); } } diff --git a/packages/cli/src/get-migrations.ts b/packages/cli/src/get-migrations.ts new file mode 100644 index 0000000..4b63ebb --- /dev/null +++ b/packages/cli/src/get-migrations.ts @@ -0,0 +1,28 @@ +import path from 'node:path'; +import fs from 'node:fs/promises'; +import { type MigrationMetadata } from '@emigrate/plugin-tools/types'; +import { withLeadingPeriod } from './with-leading-period.js'; + +export const getMigrations = async (cwd: string, directory: string): Promise => { + const allFilesInMigrationDirectory = await fs.readdir(path.resolve(cwd, directory), { + withFileTypes: true, + }); + + const migrationFiles: MigrationMetadata[] = allFilesInMigrationDirectory + .filter((file) => file.isFile() && !file.name.startsWith('.') && !file.name.startsWith('_')) + .sort((a, b) => a.name.localeCompare(b.name)) + .map(({ name }) => { + const filePath = path.resolve(cwd, directory, name); + + return { + name, + filePath, + relativeFilePath: path.relative(cwd, filePath), + extension: withLeadingPeriod(path.extname(name)), + directory, + cwd, + }; + }); + + return migrationFiles; +}; diff --git a/packages/cli/src/reporters/default.ts b/packages/cli/src/reporters/default.ts index ccb9881..cd69c58 100644 --- a/packages/cli/src/reporters/default.ts +++ b/packages/cli/src/reporters/default.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { black, blueBright, bold, cyan, dim, gray, green, red, redBright, yellow } from 'ansis'; +import { black, blueBright, bold, cyan, dim, faint, gray, green, red, redBright, yellow } from 'ansis'; import logUpdate from 'log-update'; import elegantSpinner from 'elegant-spinner'; import figures from 'figures'; @@ -25,7 +25,7 @@ const formatDuration = (duration: number): string => { }; const getTitle = ({ command, directory, dry, cwd }: ReporterInitParameters) => { - return `${black.bgBlueBright` Emigrate `} ${blueBright.bold(command)} ${gray(cwd + path.sep)}${directory}${ + return `${black.bgBlueBright(' Emigrate ').trim()} ${blueBright.bold(command)} ${gray(cwd + path.sep)}${directory}${ dry ? yellow` (dry run)` : '' }`; }; @@ -79,6 +79,10 @@ const getName = (name: string, status?: Status) => { return yellow(name); } + case 'pending': { + return faint(name); + } + default: { return name; } @@ -108,7 +112,18 @@ const getMigrationText = ( return parts.join(' '); }; -const getError = (error?: Error, indent = ' ') => { +type ErrorLike = { + name?: string; + message: string; + stack?: string; + cause?: unknown; +}; + +const isErrorLike = (error: unknown): error is ErrorLike => { + return typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string'; +}; + +const getError = (error?: ErrorLike, indent = ' ') => { if (!error) { return ''; } @@ -127,7 +142,7 @@ const getError = (error?: Error, indent = ' ') => { const parts = [`${indent}${bold.red(errorTitle)}`, ...stack.map((line) => `${indent}${dim(line)}`)]; - if (error.cause instanceof Error) { + if (isErrorLike(error.cause)) { const nextIndent = `${indent} `; parts.push(`\n${nextIndent}${bold('Original error cause:')}\n`, getError(error.cause, nextIndent)); } @@ -143,9 +158,10 @@ const getSummary = ( let done = 0; let failed = 0; let skipped = 0; + let pending = 0; for (const migration of migrations) { - const status = getMigrationStatus(migration); + const status = 'status' in migration ? migration.status : undefined; switch (status) { case 'done': { done++; @@ -162,6 +178,11 @@ const getSummary = ( break; } + case 'pending': { + pending++; + break; + } + default: { break; } @@ -174,6 +195,7 @@ const getSummary = ( failed ? red.bold(`${failed} failed`) : '', done ? green.bold(`${done} ${command === 'new' ? 'created' : 'done'}`) : '', skipped ? yellow.bold(`${skipped} skipped`) : '', + pending ? cyan.bold(`${pending} pending`) : '', ] .filter(Boolean) .join(dim(' | ')); @@ -262,13 +284,13 @@ class DefaultFancyReporter implements Required { } #finishMigration(migration: MigrationMetadataFinished): void { - if (!this.#migrations) { - return; - } + this.#migrations ??= []; const index = this.#migrations.findIndex((m) => m.name === migration.name); - if (index !== -1) { + if (index === -1) { + this.#migrations.push(migration); + } else { this.#migrations[index] = migration; } } diff --git a/packages/plugin-tools/src/types.ts b/packages/plugin-tools/src/types.ts index cb1d061..23d254c 100644 --- a/packages/plugin-tools/src/types.ts +++ b/packages/plugin-tools/src/types.ts @@ -2,7 +2,7 @@ export type Awaitable = T | PromiseLike; export type StringOrModule = string | T | (() => Awaitable) | (() => Awaitable<{ default: T }>); -export type MigrationStatus = 'failed' | 'done'; +export type MigrationStatus = 'failed' | 'done' | 'pending'; export type MigrationHistoryEntry = { name: string;