UNPKG

ts-migrate-mongoose

Version:

A migration framework for Mongoose, built with TypeScript.

829 lines (814 loc) 30.7 kB
#!/usr/bin/env node import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { parseArgs } from 'node:util'; import mongoose, { Schema } from 'mongoose'; import readline from 'node:readline'; const chalk = { red: (text) => `\x1B[31m${text}\x1B[0m`, green: (text) => `\x1B[32m${text}\x1B[0m`, yellow: (text) => `\x1B[33m${text}\x1B[0m`, cyan: (text) => `\x1B[36m${text}\x1B[0m`, dim: (text) => `\x1B[2m${text}\x1B[0m`, bold: (text) => `\x1B[1m${text}\x1B[0m` }; const defaults = { MIGRATE_CONFIG_PATH: "./migrate", MIGRATE_MONGO_COLLECTION: "migrations", MIGRATE_MIGRATIONS_PATH: "./migrations", MIGRATE_AUTOSYNC: false, MIGRATE_CLI: false }; const LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm; const parse = (src) => { const obj = {}; const lines = src.replaceAll(/\r\n?/gm, "\n"); for (const match of lines.matchAll(LINE)) { const key = match[1]; let value = (match[2] ?? "").trim(); const maybeQuote = value[0]; value = value.replaceAll(/^(['"`])([\s\S]*)\1$/gm, "$2"); if (maybeQuote === '"') { value = value.replaceAll(String.raw`\n`, "\n"); value = value.replaceAll(String.raw`\r`, "\r"); } obj[key] = value; } return obj; }; const populate = (parsed, override = false) => { for (const [key, value] of Object.entries(parsed)) { if (override || !(key in process.env)) { process.env[key] = value; } } }; const config = (options) => { const envPath = options?.path ? path.resolve(options.path) : path.resolve(process.cwd(), ".env"); try { const src = fs.readFileSync(envPath, "utf8"); const parsed = parse(src); populate(parsed, options?.override); return { parsed }; } catch (error) { const err = error instanceof Error ? error : new Error("Failed to read .env file"); if (!options?.quiet) { console.error(err.message); } return { parsed: {}, error: err }; } }; const MIGRATION_FILE_EXTENSIONS = ["ts", "js", "mjs", "cjs"]; const MIGRATION_FILE_REGEX = new RegExp(String.raw`(?<!\.d)\.(${MIGRATION_FILE_EXTENSIONS.join("|")})$`); const MIGRATION_NAME_REGEX = /^(?!.*\.\.)[A-Za-z0-9._-]+$/; let loaded = false; const loader = async () => { if (loaded) return; await import('tsx').then(() => { loaded = true; }).catch(() => { loaded = false; }); }; const getMigrationModel = (connection, collection) => { const MigrationSchema = new Schema( { name: { type: String, required: true }, state: { type: String, enum: ["down", "up"], default: "down" }, createdAt: { type: Date, index: true }, updatedAt: { type: Date } }, { collection, autoCreate: true, timestamps: true } ); MigrationSchema.virtual("filename").get(function() { return `${this.createdAt.getTime().toString()}-${this.name}`; }); return connection.model(collection, MigrationSchema); }; const ansi = { cursorUp: (n) => `\x1B[${n}A`, eraseDown: "\x1B[J", cursorHide: "\x1B[?25l", cursorShow: "\x1B[?25h" }; const getPageSize = () => { return Math.max((process.stdout.rows || 20) - 4, 5); }; const checkbox = async (options) => { const { message, choices } = options; if (!process.stdin.isTTY) { return choices.map((c) => c.value); } return new Promise((resolve) => { let cursor = 0; const selected = /* @__PURE__ */ new Set(); let pageSize = getPageSize(); let scrollOffset = 0; function getVisibleRange() { if (choices.length <= pageSize) { return { start: 0, end: choices.length }; } if (cursor < scrollOffset) { scrollOffset = cursor; } else if (cursor >= scrollOffset + pageSize) { scrollOffset = cursor - pageSize + 1; } return { start: scrollOffset, end: Math.min(scrollOffset + pageSize, choices.length) }; } function getRenderedLines() { const { start, end } = getVisibleRange(); return end - start + 2; } let lastRenderedLines = 0; const render = () => { if (lastRenderedLines > 0) { process.stdout.write(ansi.cursorUp(lastRenderedLines)); } process.stdout.write(ansi.eraseDown); const { start, end } = getVisibleRange(); let output = `${chalk.green("?")} ${chalk.bold(message)} `; for (let i = start; i < end; i++) { const choice = choices[i]; const isActive = i === cursor; const isChecked = selected.has(i); const pointer = isActive ? chalk.cyan("\u276F") : " "; const check = isChecked ? chalk.green("\u25C9") : chalk.dim("\u25EF"); output += `${pointer} ${check} ${choice.name} `; } const hints = ["\u2191\u2193 navigate", "space select", "a all", "enter submit"]; if (choices.length > pageSize) { hints.unshift(`${start + 1}-${end}/${choices.length}`); } output += chalk.dim(hints.join(" \u2022 ")); process.stdout.write(output); lastRenderedLines = getRenderedLines(); }; const onResize = () => { pageSize = getPageSize(); render(); }; process.stdout.write(ansi.cursorHide); process.stdout.write("\n".repeat(getRenderedLines())); lastRenderedLines = getRenderedLines(); render(); readline.emitKeypressEvents(process.stdin); process.stdin.setRawMode(true); process.stdin.resume(); const restoreCursor = () => { process.stdout.write(ansi.cursorShow); }; process.on("exit", restoreCursor); process.on("SIGTERM", restoreCursor); process.on("SIGHUP", restoreCursor); process.stdout.on("resize", onResize); const moveCursor = (direction) => { cursor = (cursor + direction + choices.length) % choices.length; render(); }; const toggleCurrent = () => { selected.has(cursor) ? selected.delete(cursor) : selected.add(cursor); render(); }; const toggleAll = () => { if (selected.size === choices.length) { selected.clear(); } else { for (let i = 0; i < choices.length; i++) selected.add(i); } render(); }; const submit = () => { cleanup(); if (lastRenderedLines > 0) { process.stdout.write(ansi.cursorUp(lastRenderedLines)); } process.stdout.write(ansi.eraseDown); const sorted = [...selected].sort((a, b) => a - b); const selectedValues = sorted.map((i) => choices[i].value); const summary = sorted.length ? sorted.map((i) => choices[i].name).join(", ") : "none"; process.stdout.write(`${chalk.green("\u2714")} ${chalk.bold(message)} ${chalk.cyan(summary)} `); process.stdout.write(ansi.cursorShow); resolve(selectedValues); }; const keyActions = { up: () => moveCursor(-1), down: () => moveCursor(1), space: toggleCurrent, a: toggleAll, return: submit }; const onKeypress = (_str, key) => { if (key.ctrl && key.name === "c") { cleanup(); process.stdout.write(`${ansi.cursorShow} `); process.exit(130); } const action = key.name ? keyActions[key.name] : void 0; if (action) action(); }; const cleanup = () => { process.stdin.removeListener("keypress", onKeypress); process.stdin.setRawMode(false); process.stdin.pause(); process.removeListener("exit", restoreCursor); process.removeListener("SIGTERM", restoreCursor); process.removeListener("SIGHUP", restoreCursor); process.stdout.removeListener("resize", onResize); }; process.stdin.on("keypress", onKeypress); }); }; const template = `// Import your schemas here import type { Connection } from 'mongoose' export async function up (connection: Connection): Promise<void> { // Write migration here } export async function down (connection: Connection): Promise<void> { // Write migration here } `; var Env = /* @__PURE__ */ ((Env2) => { Env2["MIGRATE_CONFIG_PATH"] = "MIGRATE_CONFIG_PATH"; Env2["MIGRATE_MONGO_COLLECTION"] = "MIGRATE_MONGO_COLLECTION"; Env2["MIGRATE_MIGRATIONS_PATH"] = "MIGRATE_MIGRATIONS_PATH"; Env2["MIGRATE_AUTOSYNC"] = "MIGRATE_AUTOSYNC"; Env2["MIGRATE_CLI"] = "MIGRATE_CLI"; Env2["MIGRATE_MODE"] = "MIGRATE_MODE"; Env2["MIGRATE_MONGO_URI"] = "MIGRATE_MONGO_URI"; Env2["MIGRATE_TEMPLATE_PATH"] = "MIGRATE_TEMPLATE_PATH"; return Env2; })(Env || {}); const resolveMigrationFile = (basePath) => { if (fs.existsSync(basePath)) { return basePath; } for (const ext of MIGRATION_FILE_EXTENSIONS) { const pathWithExt = `${basePath}.${ext}`; if (fs.existsSync(pathWithExt)) { return pathWithExt; } } return basePath; }; class Migrator { migrationModel; connection; uri; template; migrationsPath; collection; autosync; cli; constructor(options) { mongoose.set("strictQuery", false); this.template = this.getTemplate(options.templatePath); this.migrationsPath = path.resolve(options.migrationsPath ?? defaults.MIGRATE_MIGRATIONS_PATH); this.collection = options.collection ?? defaults.MIGRATE_MONGO_COLLECTION; this.autosync = options.autosync ?? defaults.MIGRATE_AUTOSYNC; this.cli = options.cli ?? defaults.MIGRATE_CLI; this.ensureMigrationsPath(); if (options.uri) { this.uri = options.uri; this.connection = mongoose.createConnection(this.uri, options.connectOptions); } else { throw new Error("No mongoose connection or mongo uri provided to migrator"); } this.migrationModel = getMigrationModel(this.connection, this.collection); } /** * Asynchronously creates a new migrator instance */ static async connect(options) { await loader(); const migrator = new Migrator(options); try { await migrator.connected(); return migrator; } catch (error) { await migrator.close().catch(() => void 0); throw error; } } /** * Close the underlying connection to mongo */ async close() { await this.connection.close(); } /** * Lists all migrations in the database and their status */ async list() { await this.sync(); const migrations = await this.migrationModel.find().sort({ createdAt: 1, _id: 1 }).exec(); if (!migrations.length) this.log(chalk.yellow("There are no migrations to list")); return migrations.map((migration) => { this.logMigrationStatus(migration.state, migration.filename); return migration; }); } /** * Create a new migration file */ async create(migrationName) { if (!MIGRATION_NAME_REGEX.test(migrationName)) { throw new Error(`Invalid migration name '${migrationName}'. Allowed characters: letters, digits, underscore, hyphen, and non-consecutive dots.`); } const existingMigration = await this.migrationModel.findOne({ name: migrationName }).exec(); if (existingMigration) { throw new Error(`There is already a migration with name '${migrationName}' in the database`); } await this.sync(); const now = Date.now(); const newMigrationFile = `${now.toString()}-${migrationName}.ts`; fs.writeFileSync(path.join(this.migrationsPath, newMigrationFile), this.template); const migrationCreated = await this.migrationModel.create({ name: migrationName, createdAt: now }); this.log(`${chalk.green("Created")} migration ${chalk.cyan(migrationName)} in ${this.migrationsPath}`); return migrationCreated; } /** * Runs migrations up to or down to a given migration name */ async run(direction, migrationName, single = false) { if (migrationName !== void 0 && typeof migrationName !== "string") { throw new Error(`migrationName must be a string, received ${typeof migrationName}`); } await this.sync(); const isUp = direction === "up"; const state = isUp ? "down" : "up"; const key = isUp ? "$lte" : "$gte"; const sort = isUp ? 1 : -1; const untilMigration = await this.findUntilMigration(migrationName, state, single, sort); if (!untilMigration) { if (migrationName) { throw new Error(`Could not find migration with name '${migrationName}' in the database`); } return this.noPendingMigrations(); } const migrationsToRun = await this.collectMigrationsToRun(untilMigration, key, state, single, sort); if (!migrationsToRun.length) { return this.noPendingMigrations(); } const migrationsRan = await this.runMigrations(migrationsToRun, direction); if (migrationsToRun.length === migrationsRan.length && migrationsRan.length > 0) { this.log(chalk.green("All migrations finished successfully")); } return migrationsRan; } /** * Resolves the `untilMigration` boundary document for `run()`. Either the * explicitly-named migration, or the most-recent/least-recent candidate * in the target state depending on whether we're running a single step * or a full batch. */ async findUntilMigration(migrationName, state, single, sort) { if (migrationName) { return this.migrationModel.findOne({ name: migrationName }).exec(); } const tieBreaker = single ? sort : -sort; return this.migrationModel.findOne({ state }).sort({ createdAt: tieBreaker, _id: tieBreaker }).exec(); } /** * Builds the ordered list of migrations to execute for `run()`. In single * mode, returns just the boundary doc; otherwise returns every pending * migration on the same side of the boundary, sorted deterministically. */ async collectMigrationsToRun(untilMigration, key, state, single, sort) { if (single) { return [untilMigration]; } const query = { createdAt: { [key]: untilMigration.createdAt }, state }; return this.migrationModel.find(query).sort({ createdAt: sort, _id: sort }).exec(); } /** * Looks at the file system migrations and imports any migrations that are * on the file system but missing in the database into the database * * This functionality is opposite of prune() */ async sync() { try { const { migrationsInFs } = await this.getMigrations(); let migrationsToImport = migrationsInFs.filter((file) => !file.existsInDatabase).map((file) => file.filename); migrationsToImport = await this.choseMigrations(migrationsToImport, "The following migrations exist in the migrations folder but not in the database.\nSelect the ones you want to import into the database"); return this.syncMigrations(migrationsToImport); } catch (error) { throw new Error("Could not synchronize migrations in the migrations folder up to the database", { cause: error }); } } /** * Removes files in migration directory which don't exist in database. * This is useful when you want to remove old migrations from the file system * And then remove them from the database using prune() * * This functionality is opposite of sync(). */ async prune() { try { let migrationsDeleted = []; const { migrationsInDb, migrationsInFs } = await this.getMigrations(); let migrationsToDelete = migrationsInDb.filter((migration) => !migrationsInFs.some((file) => file.filename === migration.filename)).map((migration) => migration.name); migrationsToDelete = await this.choseMigrations(migrationsToDelete, "The following migrations exist in the database but not in the migrations folder.\nSelect the ones you want to remove from the database"); if (migrationsToDelete.length) { migrationsDeleted = await this.migrationModel.find({ name: { $in: migrationsToDelete } }).exec(); this.log(`${chalk.red("Removing")} migration(s) from database: ${chalk.cyan(migrationsToDelete.join("\n"))}`); await this.migrationModel.deleteMany({ name: { $in: migrationsToDelete } }).exec(); } return migrationsDeleted; } catch (error) { throw new Error("Could not prune extraneous migrations from database", { cause: error }); } } /** * Logs a message to the console if there are no pending migrations * In cli mode, it also lists all migrations and their status */ async noPendingMigrations() { this.log(chalk.yellow("There are no pending migrations")); if (this.cli) { this.log(chalk.cyan("Current migrations status:")); await this.list(); } return []; } /** * Logs a message to the console if the migrator is running in cli mode or if force is true */ log(message) { if (this.cli) { console.log(message); } } /** * Logs migration status to the console */ logMigrationStatus(direction, filename) { const color = direction === "up" ? "green" : "red"; const directionWithColor = chalk[color](`${direction}:`); this.log(`${directionWithColor} ${filename}`); } /** * Gets template from file system */ getTemplate(templatePath) { if (templatePath && fs.existsSync(templatePath)) { return fs.readFileSync(templatePath, "utf8"); } return template; } /** * Ensures that the migrations path exists */ ensureMigrationsPath() { if (!fs.existsSync(this.migrationsPath)) { fs.mkdirSync(this.migrationsPath, { recursive: true }); } } /** * Connection status of the migrator to the database */ async connected() { return this.connection.asPromise(); } /** * Creates a new migration in database to reflect the changes in file system */ async syncMigrations(migrationsInFs) { const promises = migrationsInFs.map(async (filename) => { const filePath = path.join(this.migrationsPath, filename); const timestampSeparatorIndex = filename.indexOf("-"); const timestamp = filename.slice(0, timestampSeparatorIndex); const migrationName = filename.slice(timestampSeparatorIndex + 1); this.log(`${chalk.green("Adding")} migration ${chalk.cyan(filePath)} into database. State is ${chalk.red("down")}`); return this.migrationModel.create({ name: migrationName, createdAt: timestamp }); }); return Promise.all(promises); } /** * Get migrations in database and in file system at the same time */ async getMigrations() { const files = fs.readdirSync(this.migrationsPath); const migrationsInDb = await this.migrationModel.find({}).exec(); const migrationsInFs = files.filter((filename) => /^\d{13,}-/.test(filename) && MIGRATION_FILE_REGEX.test(filename)).map((filename) => { const filenameWithoutExtension = filename.replace(MIGRATION_FILE_REGEX, ""); const [time] = filename.split("-"); const timestamp = Number.parseInt(time ?? "", 10); const createdAt = new Date(timestamp); const existsInDatabase = migrationsInDb.some((migration) => filenameWithoutExtension === migration.filename); return { createdAt, filename: filenameWithoutExtension, existsInDatabase }; }); return { migrationsInDb, migrationsInFs }; } /** * Creates a prompt for the user to chose the migrations to run */ async choseMigrations(migrations, message) { if (!this.autosync && migrations.length) { const selected = await checkbox({ message, choices: migrations.map((migration) => ({ name: migration, value: migration })) }); return selected; } return migrations; } /** * Run migrations in a given direction */ async runMigrations(migrationsToRun, direction) { const migrationsRan = []; const migrationsRoot = path.resolve(this.migrationsPath) + path.sep; for (const migration of migrationsToRun) { const baseMigrationPath = path.resolve(path.join(this.migrationsPath, migration.filename)); if (!baseMigrationPath.startsWith(migrationsRoot)) { throw new Error(`Refusing to import migration '${migration.filename}' \u2014 resolved path escapes the migrations directory.`); } const migrationFilePath = resolveMigrationFile(baseMigrationPath); const fileUrl = pathToFileURL(migrationFilePath).href; const migrationFunctions = await import(fileUrl); const migrationFunction = migrationFunctions.default?.[direction] ?? migrationFunctions[direction]; if (!migrationFunction) { throw new Error(`The '${direction}' export is not defined in ${migration.filename}.`); } try { await migrationFunction(this.connection); this.logMigrationStatus(direction, migration.filename); await this.migrationModel.updateMany({ name: migration.name }, { $set: { state: direction } }).exec(); migrationsRan.push(migration); } catch (error) { throw new Error(`Failed to run migration with name '${migration.name}' due to an error`, { cause: error }); } } return migrationsRan; } } const fileExists = async (filePath) => { return fs.promises.access(filePath).then(() => true).catch(() => false); }; const resolveConfigPath = async (configPath) => { const validExtensions = [".ts", ".js", ".json"]; const message = `Config file must have an extension of ${validExtensions.join(", ")}`; const extension = path.extname(configPath); if (extension) { if (!validExtensions.includes(extension)) { throw new Error(message); } return path.resolve(configPath); } for (const ext of validExtensions) { const configFilePath = path.resolve(configPath + ext); const exists = await fileExists(configFilePath); if (exists) { return configFilePath; } } throw new Error(message); }; const loadModule = async (configPath) => { const resolvedConfig = await resolveConfigPath(configPath); const fileUrl = pathToFileURL(resolvedConfig).href; await loader(); const extension = path.extname(resolvedConfig); if (extension === ".ts") { return await import(fileUrl); } return await import(fileUrl); }; const extractOptions = (module) => { if (module.default) { return "default" in module.default ? module.default.default : module.default; } return module; }; const logError = (error) => { if (error instanceof Error) { console.error(chalk.red(error.message)); } }; const getConfig = async (configPath, quiet = false) => { let configOptions = {}; if (configPath) { try { const configFilePath = path.resolve(configPath); const module = await loadModule(configFilePath); const fileOptions = extractOptions(module); if (fileOptions) { configOptions = fileOptions; } } catch (error) { if (!quiet) logError(error); configOptions = {}; } } return configOptions; }; const toCamelCase = (str) => { return str.toLocaleLowerCase().replaceAll(/_([a-z])/g, (g) => g[1] ? g[1].toUpperCase() : ""); }; const getEnv = (key) => { return process.env[key] ?? process.env[toCamelCase(key)]; }; const getEnvBoolean = (key) => { const value = getEnv(key); return value === "true" ? true : void 0; }; const getMigrator = async (options) => { config({ path: ".env", quiet: true }); config({ path: ".env.local", quiet: true, override: true }); const mode = options.mode ?? getEnv(Env.MIGRATE_MODE); if (mode) { config({ path: `.env.${mode}`, quiet: true, override: true }); config({ path: `.env.${mode}.local`, quiet: true, override: true }); } const configPath = options.configPath ?? getEnv(Env.MIGRATE_CONFIG_PATH) ?? defaults.MIGRATE_CONFIG_PATH; const isDefaultConfig = configPath === defaults.MIGRATE_CONFIG_PATH; const fileOptions = await getConfig(configPath, isDefaultConfig); const uri = options.uri ?? getEnv(Env.MIGRATE_MONGO_URI) ?? fileOptions.uri; const connectOptions = fileOptions.connectOptions; const collection = options.collection ?? getEnv(Env.MIGRATE_MONGO_COLLECTION) ?? fileOptions.collection ?? defaults.MIGRATE_MONGO_COLLECTION; const migrationsPath = options.migrationsPath ?? getEnv(Env.MIGRATE_MIGRATIONS_PATH) ?? fileOptions.migrationsPath ?? defaults.MIGRATE_MIGRATIONS_PATH; const templatePath = options.templatePath ?? getEnv(Env.MIGRATE_TEMPLATE_PATH) ?? fileOptions.templatePath; const autosync = Boolean(options.autosync ?? getEnvBoolean(Env.MIGRATE_AUTOSYNC) ?? fileOptions.autosync ?? defaults.MIGRATE_AUTOSYNC); if (!uri) { throw new Error("You need to provide the MongoDB Connection URI to persist migration status.\nUse option --uri / -d to provide the URI."); } const migratorOptions = { migrationsPath, uri, collection, autosync, cli: true }; if (templatePath) { migratorOptions.templatePath = templatePath; } if (connectOptions) { migratorOptions.connectOptions = connectOptions; } return Migrator.connect(migratorOptions); }; const commands = [ { usage: "list", description: "list all migrations" }, { usage: "create <migration-name>", description: "create a new migration file" }, { usage: "up [migration-name]", description: "run all migrations or a specific migration if name provided" }, { usage: "down <migration-name>", description: "roll back migrations down to given name" }, { usage: "prune", description: "delete extraneous migrations from migration folder or database" } ]; const optionDefs = { "config-path": { type: "string", short: "f", arg: "<path>", description: "path to the config file" }, uri: { type: "string", short: "d", arg: "<string>", description: "mongo connection string" }, collection: { type: "string", short: "c", arg: "<string>", description: "collection name to use for the migrations" }, autosync: { type: "string", short: "a", arg: "<boolean>", description: "automatically sync new migrations without prompt" }, "migrations-path": { type: "string", short: "m", arg: "<path>", description: "path to the migration files" }, "template-path": { type: "string", short: "t", arg: "<path>", description: "template file to use when creating a migration" }, mode: { type: "string", arg: "<string>", description: "environment mode to use .env.[mode] file" }, single: { type: "boolean", short: "s", description: "run single migration (up/down only)", default: false }, help: { type: "boolean", short: "h", description: "display help" }, version: { type: "boolean", short: "v", description: "display version" } }; const formatHelp = () => { const lines = [chalk.cyan("CLI migration tool for mongoose"), "", "Usage: migrate <command> [options]", "", "Commands:"]; const cmdPadding = Math.max(...commands.map((c) => c.usage.length)) + 4; for (const cmd of commands) { lines.push(` ${cmd.usage.padEnd(cmdPadding)}${cmd.description}`); } lines.push("", "Options:"); const optEntries = Object.entries(optionDefs).map(([name, opt]) => { const short = opt.short ? `-${opt.short}, ` : " "; const arg = opt.arg ? ` ${opt.arg}` : ""; const long = `--${name}${arg}`; return { flag: ` ${short}${long}`, description: opt.description }; }); const flagPadding = Math.max(...optEntries.map((e) => e.flag.length)) + 4; for (const entry of optEntries) { lines.push(`${entry.flag.padEnd(flagPadding)}${entry.description}`); } return `${lines.join("\n")} `; }; const parseArgsOptions = Object.fromEntries(Object.entries(optionDefs).map(([name, { description, arg, ...rest }]) => [name, rest])); const parseOptions = { options: parseArgsOptions, allowPositionals: true }; class Migrate { migrator; parsedOptions = {}; async finish(exit, error) { if (this.migrator instanceof Migrator) { await this.migrator.close(); } if (error) { console.error(chalk.red(error.message)); if (exit) process.exit(1); throw error; } if (exit) process.exit(0); return this.parsedOptions; } parseOptions(values) { const options = {}; if (values["config-path"]) options.configPath = values["config-path"]; if (values.uri) options.uri = values.uri; if (values.collection) options.collection = values.collection; if (values.autosync !== void 0) options.autosync = values.autosync === "true"; if (values["migrations-path"]) options.migrationsPath = values["migrations-path"]; if (values["template-path"]) options.templatePath = values["template-path"]; if (values.mode) options.mode = values.mode; return options; } async dispatch(command, positionals, single) { switch (command) { case "list": { console.log(chalk.cyan("Listing migrations")); await this.migrator.list(); break; } case "create": { const migrationName = positionals[1]; if (!migrationName) throw new Error("Migration name is required for create command"); await this.migrator.create(migrationName); const migrateUp = chalk.cyan(`migrate up ${migrationName}`); console.log(`Migration created. Run ${migrateUp} to apply the migration`); break; } case "up": { await this.migrator.run("up", positionals[1], single); break; } case "down": { const migrationName = positionals[1]; if (!migrationName) throw new Error("Migration name is required for down command"); await this.migrator.run("down", migrationName, single); break; } case "prune": { await this.migrator.prune(); break; } default: { console.error(formatHelp()); throw new Error(`Unknown command: ${command}`); } } } async run(exit = true) { try { const args = process.argv.slice(2); const { values, positionals } = parseArgs({ ...parseOptions, args }); if (values.version) { const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")); console.log(pkg.version); return await this.finish(exit); } const command = positionals[0]; if (values.help || !command) { console.log(formatHelp()); return await this.finish(exit); } if (values.single && command !== "up" && command !== "down") { throw new Error(`Option --single is only valid for 'up' and 'down' commands`); } this.parsedOptions = this.parseOptions(values); this.migrator = await getMigrator(this.parsedOptions); await this.dispatch(command, positionals, values.single); return await this.finish(exit); } catch (error) { return await this.finish(exit, error instanceof Error ? error : new Error("An unknown error occurred", { cause: error })); } } } const migrate = new Migrate(); migrate.run();