@ftschopp/dynatable-migrations 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.gitkeep +0 -0
  2. package/CHANGELOG.md +13 -0
  3. package/README.md +421 -0
  4. package/USAGE.md +557 -0
  5. package/bin/dynatable-migrate.js +30 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +104 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/commands/create.d.ts +2 -0
  11. package/dist/commands/create.d.ts.map +1 -0
  12. package/dist/commands/create.js +123 -0
  13. package/dist/commands/create.js.map +1 -0
  14. package/dist/commands/down.d.ts +3 -0
  15. package/dist/commands/down.d.ts.map +1 -0
  16. package/dist/commands/down.js +37 -0
  17. package/dist/commands/down.js.map +1 -0
  18. package/dist/commands/init.d.ts +2 -0
  19. package/dist/commands/init.d.ts.map +1 -0
  20. package/dist/commands/init.js +70 -0
  21. package/dist/commands/init.js.map +1 -0
  22. package/dist/commands/status.d.ts +3 -0
  23. package/dist/commands/status.d.ts.map +1 -0
  24. package/dist/commands/status.js +84 -0
  25. package/dist/commands/status.js.map +1 -0
  26. package/dist/commands/up.d.ts +3 -0
  27. package/dist/commands/up.d.ts.map +1 -0
  28. package/dist/commands/up.js +37 -0
  29. package/dist/commands/up.js.map +1 -0
  30. package/dist/core/config.d.ts +29 -0
  31. package/dist/core/config.d.ts.map +1 -0
  32. package/dist/core/config.js +160 -0
  33. package/dist/core/config.js.map +1 -0
  34. package/dist/core/loader.d.ts +47 -0
  35. package/dist/core/loader.d.ts.map +1 -0
  36. package/dist/core/loader.js +226 -0
  37. package/dist/core/loader.js.map +1 -0
  38. package/dist/core/runner.d.ts +38 -0
  39. package/dist/core/runner.d.ts.map +1 -0
  40. package/dist/core/runner.js +166 -0
  41. package/dist/core/runner.js.map +1 -0
  42. package/dist/core/tracker.d.ts +19 -0
  43. package/dist/core/tracker.d.ts.map +1 -0
  44. package/dist/core/tracker.js +172 -0
  45. package/dist/core/tracker.js.map +1 -0
  46. package/dist/index.d.ts +16 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/index.js +48 -0
  49. package/dist/index.js.map +1 -0
  50. package/dist/templates/migration.d.ts +5 -0
  51. package/dist/templates/migration.d.ts.map +1 -0
  52. package/dist/templates/migration.js +96 -0
  53. package/dist/templates/migration.js.map +1 -0
  54. package/dist/types/index.d.ts +159 -0
  55. package/dist/types/index.d.ts.map +1 -0
  56. package/dist/types/index.js +3 -0
  57. package/dist/types/index.js.map +1 -0
  58. package/migrations/.gitkeep +0 -0
  59. package/package.json +43 -0
  60. package/src/cli.ts +106 -0
  61. package/src/commands/create.ts +110 -0
  62. package/src/commands/down.ts +42 -0
  63. package/src/commands/init.ts +37 -0
  64. package/src/commands/status.ts +92 -0
  65. package/src/commands/up.ts +39 -0
  66. package/src/core/config.ts +140 -0
  67. package/src/core/loader.ts +233 -0
  68. package/src/core/runner.ts +223 -0
  69. package/src/core/tracker.ts +219 -0
  70. package/src/index.ts +23 -0
  71. package/src/templates/migration.ts +92 -0
  72. package/src/types/index.ts +196 -0
  73. package/tsconfig.json +19 -0
package/src/cli.ts ADDED
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import { loadConfig } from './core/config';
5
+ import { createMigration } from './commands/create';
6
+ import { runMigrations } from './commands/up';
7
+ import { rollbackMigrations } from './commands/down';
8
+ import { showStatus } from './commands/status';
9
+ import { initProject } from './commands/init';
10
+
11
+ const program = new Command();
12
+
13
+ program
14
+ .name('dynatable-migrate')
15
+ .description('DynamoDB migration tool for single table design')
16
+ .version('0.1.0');
17
+
18
+ // Init command
19
+ program
20
+ .command('init')
21
+ .description('Initialize migrations in current project')
22
+ .action(async () => {
23
+ try {
24
+ await initProject();
25
+ } catch (error: any) {
26
+ console.error(`Error: ${error.message}`);
27
+ process.exit(1);
28
+ }
29
+ });
30
+
31
+ // Create command
32
+ program
33
+ .command('create <name>')
34
+ .description('Create a new migration file')
35
+ .option('-c, --config <path>', 'Path to config file')
36
+ .option('-t, --type <type>', 'Version bump type: major, minor, or patch (default: patch)')
37
+ .option('-e, --explicit <version>', 'Explicit version (e.g., 2.0.0)')
38
+ .action(async (name: string, options) => {
39
+ try {
40
+ let migrationsDir = './migrations';
41
+
42
+ // Try to load config to get migrations directory
43
+ try {
44
+ const config = await loadConfig(options.config);
45
+ migrationsDir = config.migrationsDir || './migrations';
46
+ } catch {
47
+ // If config doesn't exist, use default
48
+ }
49
+
50
+ await createMigration(name, migrationsDir, options.type, options.explicit);
51
+ } catch (error: any) {
52
+ console.error(`Error: ${error.message}`);
53
+ process.exit(1);
54
+ }
55
+ });
56
+
57
+ // Up command
58
+ program
59
+ .command('up')
60
+ .description('Run pending migrations')
61
+ .option('-c, --config <path>', 'Path to config file')
62
+ .option('-l, --limit <number>', 'Limit number of migrations to run')
63
+ .action(async (options) => {
64
+ try {
65
+ const config = await loadConfig(options.config);
66
+ const limit = options.limit ? parseInt(options.limit, 10) : undefined;
67
+ await runMigrations(config, limit);
68
+ } catch (error: any) {
69
+ console.error(`Error: ${error.message}`);
70
+ process.exit(1);
71
+ }
72
+ });
73
+
74
+ // Down command
75
+ program
76
+ .command('down')
77
+ .description('Rollback migrations')
78
+ .option('-c, --config <path>', 'Path to config file')
79
+ .option('-s, --steps <number>', 'Number of migrations to rollback', '1')
80
+ .action(async (options) => {
81
+ try {
82
+ const config = await loadConfig(options.config);
83
+ const steps = parseInt(options.steps, 10);
84
+ await rollbackMigrations(config, steps);
85
+ } catch (error: any) {
86
+ console.error(`Error: ${error.message}`);
87
+ process.exit(1);
88
+ }
89
+ });
90
+
91
+ // Status command
92
+ program
93
+ .command('status')
94
+ .description('Show migration status')
95
+ .option('-c, --config <path>', 'Path to config file')
96
+ .action(async (options) => {
97
+ try {
98
+ const config = await loadConfig(options.config);
99
+ await showStatus(config);
100
+ } catch (error: any) {
101
+ console.error(`Error: ${error.message}`);
102
+ process.exit(1);
103
+ }
104
+ });
105
+
106
+ program.parse(process.argv);
@@ -0,0 +1,110 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { MigrationLoader } from '../core/loader';
4
+ import { generateMigrationTemplate } from '../templates/migration';
5
+
6
+ type VersionBumpType = 'major' | 'minor' | 'patch';
7
+
8
+ /**
9
+ * Get next version based on bump type
10
+ */
11
+ async function getNextVersion(loader: MigrationLoader, bumpType: VersionBumpType): Promise<string> {
12
+ const migrations = await loader['loadMigrations']();
13
+
14
+ if (migrations.length === 0) {
15
+ return '0.1.0';
16
+ }
17
+
18
+ const lastMigration = migrations[migrations.length - 1];
19
+ if (!lastMigration) {
20
+ return '0.1.0';
21
+ }
22
+
23
+ const lastVersion = lastMigration.version;
24
+ const parts = lastVersion.split('.');
25
+
26
+ if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) {
27
+ return '0.1.0';
28
+ }
29
+
30
+ const major = parseInt(parts[0], 10);
31
+ const minor = parseInt(parts[1], 10);
32
+ const patch = parseInt(parts[2], 10);
33
+
34
+ switch (bumpType) {
35
+ case 'major':
36
+ return `${major + 1}.0.0`;
37
+ case 'minor':
38
+ return `${major}.${minor + 1}.0`;
39
+ case 'patch':
40
+ return `${major}.${minor}.${patch + 1}`;
41
+ default:
42
+ return `${major}.${minor}.${patch + 1}`;
43
+ }
44
+ }
45
+
46
+ export async function createMigration(
47
+ name: string,
48
+ migrationsDir: string = './migrations',
49
+ bumpType?: string,
50
+ explicitVersion?: string
51
+ ): Promise<void> {
52
+ // Ensure migrations directory exists
53
+ if (!fs.existsSync(migrationsDir)) {
54
+ fs.mkdirSync(migrationsDir, { recursive: true });
55
+ console.log(`šŸ“ Created migrations directory: ${migrationsDir}`);
56
+ }
57
+
58
+ // Generate version
59
+ const loader = new MigrationLoader(migrationsDir);
60
+ let version: string;
61
+
62
+ if (explicitVersion) {
63
+ // Validate explicit version is semver
64
+ if (!/^\d+\.\d+\.\d+$/.test(explicitVersion)) {
65
+ throw new Error(
66
+ `Invalid version format: ${explicitVersion}. Expected semver format (e.g., 1.0.0)`
67
+ );
68
+ }
69
+ version = explicitVersion;
70
+ } else if (bumpType) {
71
+ // Validate bump type
72
+ const validTypes: VersionBumpType[] = ['major', 'minor', 'patch'];
73
+ if (!validTypes.includes(bumpType as VersionBumpType)) {
74
+ throw new Error(`Invalid bump type: ${bumpType}. Expected: major, minor, or patch`);
75
+ }
76
+ version = await getNextVersion(loader, bumpType as VersionBumpType);
77
+ } else {
78
+ // Default: increment patch
79
+ version = await getNextVersion(loader, 'patch');
80
+ }
81
+
82
+ // Sanitize name (replace spaces and special chars with underscores)
83
+ const sanitizedName = name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase();
84
+
85
+ // Generate file name
86
+ const fileName = `${version}_${sanitizedName}.ts`;
87
+ const filePath = path.join(migrationsDir, fileName);
88
+
89
+ // Check if file already exists
90
+ if (fs.existsSync(filePath)) {
91
+ throw new Error(`Migration file already exists: ${filePath}`);
92
+ }
93
+
94
+ // Generate migration content
95
+ const content = generateMigrationTemplate(version, sanitizedName);
96
+
97
+ // Write file
98
+ fs.writeFileSync(filePath, content, 'utf-8');
99
+
100
+ // Determine version type for informational message
101
+ const versionType = bumpType ? bumpType.toUpperCase() : explicitVersion ? 'EXPLICIT' : 'PATCH';
102
+
103
+ console.log(`\nāœ… Created migration: ${fileName}`);
104
+ console.log(` Path: ${filePath}`);
105
+ console.log(` Version: ${version} (${versionType})\n`);
106
+ console.log(`Next steps:`);
107
+ console.log(` 1. Edit ${fileName}`);
108
+ console.log(` 2. Implement up() and down() functions`);
109
+ console.log(` 3. Run: dynatable-migrate up\n`);
110
+ }
@@ -0,0 +1,42 @@
1
+ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2
+ import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
3
+ import { MigrationRunner } from '../core/runner';
4
+ import { MigrationConfig } from '../types';
5
+
6
+ export async function rollbackMigrations(
7
+ config: MigrationConfig,
8
+ steps: number = 1
9
+ ): Promise<void> {
10
+ // Create DynamoDB client
11
+ const ddbClient = new DynamoDBClient({
12
+ region: config.client.region,
13
+ endpoint: config.client.endpoint,
14
+ credentials: config.client.credentials,
15
+ });
16
+
17
+ const client = DynamoDBDocumentClient.from(ddbClient, {
18
+ marshallOptions: {
19
+ removeUndefinedValues: true,
20
+ convertClassInstanceToMap: true,
21
+ },
22
+ unmarshallOptions: {
23
+ wrapNumbers: false,
24
+ },
25
+ });
26
+
27
+ const runner = new MigrationRunner(client, config);
28
+
29
+ try {
30
+ const rolledBack = await runner.down(steps);
31
+
32
+ if (rolledBack.length > 0) {
33
+ console.log(`\nšŸŽ‰ Successfully rolled back ${rolledBack.length} migration(s)`);
34
+
35
+ const currentVersion = await runner.getCurrentVersion();
36
+ console.log(`šŸ“Œ Current version: ${currentVersion}\n`);
37
+ }
38
+ } catch (error: any) {
39
+ console.error(`\nāŒ Rollback failed: ${error.message}\n`);
40
+ process.exit(1);
41
+ }
42
+ }
@@ -0,0 +1,37 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { ConfigLoader } from '../core/config';
4
+
5
+ export async function initProject(): Promise<void> {
6
+ const cwd = process.cwd();
7
+
8
+ console.log('\nšŸš€ Initializing DynamoDB migrations\n');
9
+
10
+ // Create migrations directory
11
+ const migrationsDir = path.join(cwd, 'migrations');
12
+ if (!fs.existsSync(migrationsDir)) {
13
+ fs.mkdirSync(migrationsDir, { recursive: true });
14
+ console.log(`āœ… Created: ${migrationsDir}/`);
15
+
16
+ // Create .gitkeep
17
+ fs.writeFileSync(path.join(migrationsDir, '.gitkeep'), '');
18
+ } else {
19
+ console.log(`āš ļø Directory already exists: ${migrationsDir}/`);
20
+ }
21
+
22
+ // Create config file
23
+ const configPath = path.join(cwd, 'dynatable.config.js');
24
+ if (!fs.existsSync(configPath)) {
25
+ ConfigLoader.createDefaultConfig(configPath);
26
+ } else {
27
+ console.log(`āš ļø Config already exists: ${configPath}`);
28
+ }
29
+
30
+ console.log('\nāœ… Initialization complete!\n');
31
+ console.log('Next steps:');
32
+ console.log(' 1. Edit dynatable.config.js with your DynamoDB settings');
33
+ console.log(' 2. Create your first migration:');
34
+ console.log(' dynatable-migrate create add_user_email');
35
+ console.log(' 3. Run migrations:');
36
+ console.log(' dynatable-migrate up\n');
37
+ }
@@ -0,0 +1,92 @@
1
+ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2
+ import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
3
+ import { MigrationRunner } from '../core/runner';
4
+ import { MigrationConfig } from '../types';
5
+
6
+ export async function showStatus(config: MigrationConfig): Promise<void> {
7
+ // Create DynamoDB client
8
+ const ddbClient = new DynamoDBClient({
9
+ region: config.client.region,
10
+ endpoint: config.client.endpoint,
11
+ credentials: config.client.credentials,
12
+ });
13
+
14
+ const client = DynamoDBDocumentClient.from(ddbClient, {
15
+ marshallOptions: {
16
+ removeUndefinedValues: true,
17
+ convertClassInstanceToMap: true,
18
+ },
19
+ unmarshallOptions: {
20
+ wrapNumbers: false,
21
+ },
22
+ });
23
+
24
+ const runner = new MigrationRunner(client, config);
25
+
26
+ try {
27
+ const statuses = await runner.status();
28
+ const currentVersion = await runner.getCurrentVersion();
29
+
30
+ console.log(`\nšŸ“Š Migration Status\n`);
31
+ console.log(`Table: ${config.tableName}`);
32
+ console.log(`Current version: ${currentVersion || 'v0000 (no migrations)'}`);
33
+ console.log(`Migrations directory: ${config.migrationsDir || './migrations'}\n`);
34
+
35
+ if (statuses.length === 0) {
36
+ console.log('No migrations found.\n');
37
+ return;
38
+ }
39
+
40
+ // Group by status
41
+ const pending = statuses.filter((s) => s.status === 'pending');
42
+ const applied = statuses.filter((s) => s.status === 'applied');
43
+ const failed = statuses.filter((s) => s.status === 'failed');
44
+ const rolledBack = statuses.filter((s) => s.status === 'rolled_back');
45
+
46
+ // Show applied migrations
47
+ if (applied.length > 0) {
48
+ console.log(`āœ… Applied (${applied.length}):`);
49
+ for (const status of applied) {
50
+ const date = status.appliedAt ? new Date(status.appliedAt).toLocaleString() : '';
51
+ console.log(` ${status.version} - ${status.name} (${date})`);
52
+ }
53
+ console.log();
54
+ }
55
+
56
+ // Show pending migrations
57
+ if (pending.length > 0) {
58
+ console.log(`ā³ Pending (${pending.length}):`);
59
+ for (const status of pending) {
60
+ console.log(` ${status.version} - ${status.name}`);
61
+ }
62
+ console.log();
63
+ }
64
+
65
+ // Show rolled back migrations
66
+ if (rolledBack.length > 0) {
67
+ console.log(`ā¬…ļø Rolled Back (${rolledBack.length}):`);
68
+ for (const status of rolledBack) {
69
+ console.log(` ${status.version} - ${status.name}`);
70
+ }
71
+ console.log();
72
+ }
73
+
74
+ // Show failed migrations
75
+ if (failed.length > 0) {
76
+ console.log(`āŒ Failed (${failed.length}):`);
77
+ for (const status of failed) {
78
+ console.log(` ${status.version} - ${status.name}`);
79
+ if (status.error) {
80
+ console.log(` Error: ${status.error}`);
81
+ }
82
+ }
83
+ console.log();
84
+ }
85
+
86
+ // Summary
87
+ console.log(`Total: ${statuses.length} migration(s)\n`);
88
+ } catch (error: any) {
89
+ console.error(`\nāŒ Failed to get status: ${error.message}\n`);
90
+ process.exit(1);
91
+ }
92
+ }
@@ -0,0 +1,39 @@
1
+ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2
+ import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
3
+ import { MigrationRunner } from '../core/runner';
4
+ import { MigrationConfig } from '../types';
5
+
6
+ export async function runMigrations(config: MigrationConfig, limit?: number): Promise<void> {
7
+ // Create DynamoDB client
8
+ const ddbClient = new DynamoDBClient({
9
+ region: config.client.region,
10
+ endpoint: config.client.endpoint,
11
+ credentials: config.client.credentials,
12
+ });
13
+
14
+ const client = DynamoDBDocumentClient.from(ddbClient, {
15
+ marshallOptions: {
16
+ removeUndefinedValues: true,
17
+ convertClassInstanceToMap: true,
18
+ },
19
+ unmarshallOptions: {
20
+ wrapNumbers: false,
21
+ },
22
+ });
23
+
24
+ const runner = new MigrationRunner(client, config);
25
+
26
+ try {
27
+ const executed = await runner.up(limit);
28
+
29
+ if (executed.length > 0) {
30
+ console.log(`\nšŸŽ‰ Successfully applied ${executed.length} migration(s)`);
31
+
32
+ const currentVersion = await runner.getCurrentVersion();
33
+ console.log(`šŸ“Œ Current version: ${currentVersion}\n`);
34
+ }
35
+ } catch (error: any) {
36
+ console.error(`\nāŒ Migration failed: ${error.message}\n`);
37
+ process.exit(1);
38
+ }
39
+ }
@@ -0,0 +1,140 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { MigrationConfig } from '../types';
4
+
5
+ /**
6
+ * Load migration configuration
7
+ */
8
+ export class ConfigLoader {
9
+ private configPath: string;
10
+
11
+ constructor(configPath?: string) {
12
+ this.configPath = configPath || this.findConfigFile() || 'dynatable.config.js';
13
+ }
14
+
15
+ /**
16
+ * Find config file in current directory or parent directories
17
+ */
18
+ private findConfigFile(): string | null {
19
+ const configFileNames = [
20
+ 'dynatable.config.js',
21
+ 'dynatable.config.json',
22
+ '.dynatablerc.js',
23
+ '.dynatablerc.json',
24
+ ];
25
+
26
+ let currentDir = process.cwd();
27
+ const root = path.parse(currentDir).root;
28
+
29
+ while (currentDir !== root) {
30
+ for (const fileName of configFileNames) {
31
+ const filePath = path.join(currentDir, fileName);
32
+ if (fs.existsSync(filePath)) {
33
+ return filePath;
34
+ }
35
+ }
36
+
37
+ currentDir = path.dirname(currentDir);
38
+ }
39
+
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Load configuration from file
45
+ */
46
+ async load(): Promise<MigrationConfig> {
47
+ if (!fs.existsSync(this.configPath)) {
48
+ throw new Error(
49
+ `Configuration file not found: ${this.configPath}\n\nPlease create a dynatable.config.js file in your project root.`
50
+ );
51
+ }
52
+
53
+ try {
54
+ let config: MigrationConfig;
55
+
56
+ if (this.configPath.endsWith('.json')) {
57
+ const content = fs.readFileSync(this.configPath, 'utf-8');
58
+ config = JSON.parse(content);
59
+ } else {
60
+ // JavaScript config file
61
+ const module = await import(this.configPath);
62
+ config = module.default || module;
63
+ }
64
+
65
+ return this.validateAndNormalizeConfig(config);
66
+ } catch (error: any) {
67
+ throw new Error(`Failed to load configuration: ${error.message}`);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Validate and normalize configuration
73
+ */
74
+ private validateAndNormalizeConfig(config: Partial<MigrationConfig>): MigrationConfig {
75
+ if (!config.tableName) {
76
+ throw new Error("Configuration error: 'tableName' is required");
77
+ }
78
+
79
+ if (!config.client?.region) {
80
+ throw new Error("Configuration error: 'client.region' is required");
81
+ }
82
+
83
+ return {
84
+ tableName: config.tableName,
85
+ client: {
86
+ region: config.client.region,
87
+ endpoint: config.client.endpoint,
88
+ credentials: config.client.credentials,
89
+ },
90
+ migrationsDir: config.migrationsDir || './migrations',
91
+ trackingPrefix: config.trackingPrefix || '_SCHEMA#VERSION',
92
+ gsi1Name: config.gsi1Name || 'GSI1',
93
+ };
94
+ }
95
+
96
+ /**
97
+ * Create default config file
98
+ */
99
+ static createDefaultConfig(targetPath: string): void {
100
+ const defaultConfig = `module.exports = {
101
+ // DynamoDB table name
102
+ tableName: "MyTable",
103
+
104
+ // DynamoDB client configuration
105
+ client: {
106
+ region: "us-east-1",
107
+
108
+ // Optional: For local DynamoDB
109
+ endpoint: "http://localhost:8000",
110
+
111
+ // Optional: Credentials (use AWS_PROFILE or IAM role in production)
112
+ credentials: {
113
+ accessKeyId: "local",
114
+ secretAccessKey: "local",
115
+ },
116
+ },
117
+
118
+ // Optional: Migrations directory (default: ./migrations)
119
+ migrationsDir: "./migrations",
120
+
121
+ // Optional: Tracking prefix in single table (default: _SCHEMA#VERSION)
122
+ trackingPrefix: "_SCHEMA#VERSION",
123
+
124
+ // Optional: GSI name for tracking (default: GSI1)
125
+ gsi1Name: "GSI1",
126
+ };
127
+ `;
128
+
129
+ fs.writeFileSync(targetPath, defaultConfig, 'utf-8');
130
+ console.log(`āœ… Created config file: ${targetPath}`);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Get config from environment or file
136
+ */
137
+ export async function loadConfig(configPath?: string): Promise<MigrationConfig> {
138
+ const loader = new ConfigLoader(configPath);
139
+ return loader.load();
140
+ }