@shiva-fw/cli 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 (71) hide show
  1. package/.editorconfig +38 -0
  2. package/.gitattributes +18 -0
  3. package/.nvmrc +1 -0
  4. package/README.md +179 -0
  5. package/bin/shiva.js +4 -0
  6. package/package.json +44 -0
  7. package/recipes/full-rp.json +77 -0
  8. package/recipes/minimal.json +30 -0
  9. package/recipes/standard.json +46 -0
  10. package/src/commands/ai/context.js +89 -0
  11. package/src/commands/ai/link.js +38 -0
  12. package/src/commands/ai/mcp.js +39 -0
  13. package/src/commands/config/validate.js +65 -0
  14. package/src/commands/docs/api.js +81 -0
  15. package/src/commands/docs/build.js +14 -0
  16. package/src/commands/docs/deploy.js +14 -0
  17. package/src/commands/docs/serve.js +14 -0
  18. package/src/commands/init.js +167 -0
  19. package/src/commands/install.js +108 -0
  20. package/src/commands/locale/missing.js +83 -0
  21. package/src/commands/make/contract.js +45 -0
  22. package/src/commands/make/migration.js +69 -0
  23. package/src/commands/make/model.js +63 -0
  24. package/src/commands/make/module.js +115 -0
  25. package/src/commands/make/seed.js +51 -0
  26. package/src/commands/make/service.js +60 -0
  27. package/src/commands/make/test.js +53 -0
  28. package/src/commands/mcp.js +26 -0
  29. package/src/commands/migrate/rollback.js +155 -0
  30. package/src/commands/migrate/run.js +159 -0
  31. package/src/commands/migrate/status.js +137 -0
  32. package/src/commands/module/list.js +46 -0
  33. package/src/commands/module/status.js +64 -0
  34. package/src/commands/outdated.js +59 -0
  35. package/src/commands/remove.js +61 -0
  36. package/src/commands/seed.js +108 -0
  37. package/src/commands/test.js +88 -0
  38. package/src/commands/update.js +90 -0
  39. package/src/generators/index.js +78 -0
  40. package/src/generators/templates/contract.lua.tpl +12 -0
  41. package/src/generators/templates/migration.lua.tpl +15 -0
  42. package/src/generators/templates/model.lua.tpl +14 -0
  43. package/src/generators/templates/module/client/init.lua.tpl +5 -0
  44. package/src/generators/templates/module/config/config.lua.tpl +4 -0
  45. package/src/generators/templates/module/fxmanifest.lua.tpl +41 -0
  46. package/src/generators/templates/module/locales/en.lua.tpl +4 -0
  47. package/src/generators/templates/module/module.lua.tpl +10 -0
  48. package/src/generators/templates/module/server/init.lua.tpl +2 -0
  49. package/src/generators/templates/module/shared/init.lua.tpl +5 -0
  50. package/src/generators/templates/seed.lua.tpl +10 -0
  51. package/src/generators/templates/service.lua.tpl +7 -0
  52. package/src/generators/templates/test.lua.tpl +39 -0
  53. package/src/index.js +113 -0
  54. package/src/mcp/resources/contracts.js +68 -0
  55. package/src/mcp/resources/docs.js +56 -0
  56. package/src/mcp/resources/examples.js +235 -0
  57. package/src/mcp/server.js +121 -0
  58. package/src/mcp/tools/config.js +53 -0
  59. package/src/mcp/tools/contracts.js +37 -0
  60. package/src/mcp/tools/database.js +93 -0
  61. package/src/mcp/tools/docs.js +38 -0
  62. package/src/mcp/tools/events.js +26 -0
  63. package/src/mcp/tools/items.js +280 -0
  64. package/src/mcp/tools/modules.js +25 -0
  65. package/src/packages/lockfile.js +53 -0
  66. package/src/packages/registry.js +99 -0
  67. package/src/packages/resolver.js +83 -0
  68. package/src/utils/config-reader.js +74 -0
  69. package/src/utils/lua-annotations.js +319 -0
  70. package/src/utils/lua-parser.js +119 -0
  71. package/src/utils/server-root.js +66 -0
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const chalk = require('chalk');
6
+ const inquirer = require('inquirer');
7
+
8
+ const { requireServerRoot, getShivaModulesDir } = require('../../utils/server-root');
9
+ const { writeTemplate, toPascalCase, normalizeModuleName } = require('../../generators/index');
10
+
11
+ /**
12
+ * Register the `shiva make:module` command.
13
+ * @param {import('commander').Command} program
14
+ */
15
+ function makeModuleCommand(program) {
16
+ program
17
+ .command('make:module <name>')
18
+ .description('Scaffold a new Shiva module')
19
+ .option('-d, --description <desc>', 'Module description')
20
+ .option('-a, --author <author>', 'Module author')
21
+ .option('--no-git', 'Skip .gitkeep files')
22
+ .action(async (name, options) => {
23
+ await run(name, options);
24
+ });
25
+ }
26
+
27
+ async function run(rawName, options) {
28
+ const moduleName = normalizeModuleName(rawName);
29
+ const moduleShort = moduleName.replace(/^shiva-/, '');
30
+ const PascalName = toPascalCase(moduleShort);
31
+
32
+ let description = options.description;
33
+ let author = options.author;
34
+
35
+ if (!description || !author) {
36
+ const answers = await inquirer.prompt([
37
+ !description && {
38
+ type: 'input',
39
+ name: 'description',
40
+ message: 'Module description:',
41
+ default: `${PascalName} module for Shiva`,
42
+ },
43
+ !author && {
44
+ type: 'input',
45
+ name: 'author',
46
+ message: 'Author:',
47
+ default: '',
48
+ },
49
+ ].filter(Boolean));
50
+
51
+ description = description || answers.description;
52
+ author = author || answers.author || '';
53
+ }
54
+
55
+ const serverRoot = requireServerRoot();
56
+ const modulesDir = getShivaModulesDir(serverRoot);
57
+ const moduleDir = path.join(modulesDir, moduleName);
58
+
59
+ if (fs.existsSync(moduleDir)) {
60
+ console.error(chalk.red(`✖ Module already exists: ${moduleDir}`));
61
+ process.exit(1);
62
+ }
63
+
64
+ const vars = {
65
+ module_name: moduleName,
66
+ module_short: moduleShort,
67
+ PascalName,
68
+ description,
69
+ author,
70
+ };
71
+
72
+ const files = [
73
+ ['module/fxmanifest.lua.tpl', 'fxmanifest.lua'],
74
+ ['module/module.lua.tpl', 'module.lua'],
75
+ ['module/client/init.lua.tpl', 'client/boot.lua'],
76
+ ['module/server/init.lua.tpl', 'server/boot.lua'],
77
+ ['module/shared/init.lua.tpl', `shared/sh_${moduleShort}.lua`],
78
+ ['module/config/config.lua.tpl', 'config/config.lua'],
79
+ ['module/locales/en.lua.tpl', 'locales/en.lua'],
80
+ ];
81
+
82
+ for (const [tpl, dest] of files) {
83
+ writeTemplate(tpl, path.join(moduleDir, dest), vars);
84
+ }
85
+
86
+ // Create empty directories with .gitkeep
87
+ const emptyDirs = ['migrations', 'tests'];
88
+ for (const dir of emptyDirs) {
89
+ const dirPath = path.join(moduleDir, dir);
90
+ fs.mkdirSync(dirPath, { recursive: true });
91
+ if (options.git !== false) {
92
+ fs.writeFileSync(path.join(dirPath, '.gitkeep'), '', 'utf-8');
93
+ }
94
+ }
95
+
96
+ console.log('');
97
+ console.log(chalk.green(`✔ Created module: ${chalk.bold(moduleName)}`));
98
+ console.log(chalk.gray(` Path: ${path.relative(process.cwd(), moduleDir)}`));
99
+ console.log('');
100
+ console.log(' Files created:');
101
+ for (const [, dest] of files) {
102
+ console.log(chalk.gray(` ${dest}`));
103
+ }
104
+ for (const dir of emptyDirs) {
105
+ console.log(chalk.gray(` ${dir}/`));
106
+ }
107
+ console.log('');
108
+ console.log(chalk.bold('Next steps:'));
109
+ console.log(chalk.gray(` shiva make:service ${PascalName}Service --module ${moduleName}`));
110
+ console.log(chalk.gray(` shiva make:model ${PascalName} --module ${moduleName}`));
111
+ console.log(chalk.gray(` shiva make:migration create_${rawName.replace(/-/g, '_')}_table --module ${moduleName}`));
112
+ console.log('');
113
+ }
114
+
115
+ module.exports = makeModuleCommand;
@@ -0,0 +1,51 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const chalk = require('chalk');
6
+
7
+ const { requireServerRoot, getShivaModulesDir } = require('../../utils/server-root');
8
+ const { writeTemplate, toSnakeCase, normalizeModuleName } = require('../../generators/index');
9
+
10
+ function makeSeedCommand(program) {
11
+ program
12
+ .command('make:seed <name>')
13
+ .description('Scaffold a database seeder inside a module')
14
+ .requiredOption('-m, --module <module>', 'Target module name')
15
+ .option('-t, --table <table>', 'Target database table name')
16
+ .action((name, options) => { run(name, options); });
17
+ }
18
+
19
+ function run(rawName, options) {
20
+ const seedName = toSnakeCase(rawName).replace(/-/g, '_');
21
+ const moduleName = normalizeModuleName(options.module);
22
+ const tableName = options.table || seedName;
23
+ const fileName = `${seedName}.lua`;
24
+
25
+ const serverRoot = requireServerRoot();
26
+ const moduleDir = path.join(getShivaModulesDir(serverRoot), moduleName);
27
+
28
+ if (!fs.existsSync(moduleDir)) {
29
+ console.error(chalk.red(`✖ Module not found: ${moduleName}`));
30
+ process.exit(1);
31
+ }
32
+
33
+ const seedsDir = path.join(moduleDir, 'seeds');
34
+ fs.mkdirSync(seedsDir, { recursive: true });
35
+
36
+ const destFile = path.join(seedsDir, fileName);
37
+ if (fs.existsSync(destFile)) {
38
+ console.error(chalk.red(`✖ Seed already exists: ${destFile}`));
39
+ process.exit(1);
40
+ }
41
+
42
+ writeTemplate('seed.lua.tpl', destFile, { table_name: tableName });
43
+
44
+ console.log('');
45
+ console.log(chalk.green(`✔ Created seed: ${chalk.bold(fileName)}`));
46
+ console.log(chalk.gray(` Path: ${path.relative(process.cwd(), destFile)}`));
47
+ console.log(chalk.gray(` Run: shiva seed --module ${moduleName}`));
48
+ console.log('');
49
+ }
50
+
51
+ module.exports = makeSeedCommand;
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const chalk = require('chalk');
6
+
7
+ const { requireServerRoot, getShivaModulesDir } = require('../../utils/server-root');
8
+ const { writeTemplate, toPascalCase, normalizeModuleName } = require('../../generators/index');
9
+
10
+ /**
11
+ * Register the `shiva make:service` command.
12
+ * @param {import('commander').Command} program
13
+ */
14
+ function makeServiceCommand(program) {
15
+ program
16
+ .command('make:service <name>')
17
+ .description('Scaffold a new service inside a module')
18
+ .requiredOption('-m, --module <module>', 'Target module name')
19
+ .action((name, options) => {
20
+ run(name, options);
21
+ });
22
+ }
23
+
24
+ function run(rawName, options) {
25
+ const ServiceName = toPascalCase(rawName);
26
+ const moduleName = normalizeModuleName(options.module);
27
+
28
+ const serverRoot = requireServerRoot();
29
+ const modulesDir = getShivaModulesDir(serverRoot);
30
+ const moduleDir = path.join(modulesDir, moduleName);
31
+
32
+ if (!fs.existsSync(moduleDir)) {
33
+ console.error(chalk.red(`✖ Module not found: ${moduleName}`));
34
+ console.error(chalk.gray(` Expected path: ${moduleDir}`));
35
+ console.error(chalk.gray(` Run \`shiva make:module ${moduleName}\` first.`));
36
+ process.exit(1);
37
+ }
38
+
39
+ const destDir = path.join(moduleDir, 'server', 'services');
40
+ const destFile = path.join(destDir, `${ServiceName}.lua`);
41
+
42
+ if (fs.existsSync(destFile)) {
43
+ console.error(chalk.red(`✖ Service already exists: ${destFile}`));
44
+ process.exit(1);
45
+ }
46
+
47
+ const vars = {
48
+ ServiceName,
49
+ module_name: moduleName,
50
+ };
51
+
52
+ writeTemplate('service.lua.tpl', destFile, vars);
53
+
54
+ console.log('');
55
+ console.log(chalk.green(`✔ Created service: ${chalk.bold(ServiceName)}`));
56
+ console.log(chalk.gray(` Path: ${path.relative(process.cwd(), destFile)}`));
57
+ console.log('');
58
+ }
59
+
60
+ module.exports = makeServiceCommand;
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const chalk = require('chalk');
6
+
7
+ const { requireServerRoot, getShivaModulesDir } = require('../../utils/server-root');
8
+ const { writeTemplate, toPascalCase, toSnakeCase, normalizeModuleName } = require('../../generators/index');
9
+
10
+ function makeTestCommand(program) {
11
+ program
12
+ .command('make:test <name>')
13
+ .description('Scaffold a test spec file inside a module')
14
+ .requiredOption('-m, --module <module>', 'Target module name')
15
+ .action((name, options) => { run(name, options); });
16
+ }
17
+
18
+ function run(rawName, options) {
19
+ const moduleName = normalizeModuleName(options.module);
20
+ const moduleShort = moduleName.replace(/^shiva-/, '');
21
+ const testName = toSnakeCase(rawName).replace(/-/g, '_');
22
+ const describeName = toPascalCase(rawName);
23
+
24
+ const serverRoot = requireServerRoot();
25
+ const modulesDir = getShivaModulesDir(serverRoot);
26
+ const moduleDir = path.join(modulesDir, moduleName);
27
+
28
+ if (!fs.existsSync(moduleDir)) {
29
+ console.error(chalk.red(`✖ Module not found: ${moduleName}`));
30
+ process.exit(1);
31
+ }
32
+
33
+ const destFile = path.join(moduleDir, 'tests', `${testName}_spec.lua`);
34
+ if (fs.existsSync(destFile)) {
35
+ console.error(chalk.red(`✖ Test already exists: ${destFile}`));
36
+ process.exit(1);
37
+ }
38
+
39
+ writeTemplate('test.lua.tpl', destFile, {
40
+ test_name: testName,
41
+ describe_name: describeName,
42
+ module_name: moduleName,
43
+ module_short: moduleShort,
44
+ });
45
+
46
+ console.log('');
47
+ console.log(chalk.green(`✔ Created test: ${chalk.bold(testName + '_spec.lua')}`));
48
+ console.log(chalk.gray(` Path: ${path.relative(process.cwd(), destFile)}`));
49
+ console.log(chalk.gray(` Run: shiva test --module ${moduleName}`));
50
+ console.log('');
51
+ }
52
+
53
+ module.exports = makeTestCommand;
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ const chalk = require('chalk');
4
+
5
+ function mcpCommand(program) {
6
+ const mcp = program
7
+ .command('mcp')
8
+ .description('MCP server commands');
9
+
10
+ mcp
11
+ .command('start')
12
+ .description('Start the Shiva MCP server (stdio transport)')
13
+ .option('-p, --port <port>', 'Port (reserved for future HTTP transport)', '3100')
14
+ .action(async (options) => {
15
+ const { startMcpServer } = require('../mcp/server');
16
+ process.stderr.write(chalk.cyan('Shiva MCP server starting on stdio...\n'));
17
+ try {
18
+ await startMcpServer(options);
19
+ } catch (err) {
20
+ process.stderr.write(chalk.red(`MCP server error: ${err.message}\n`));
21
+ process.exit(1);
22
+ }
23
+ });
24
+ }
25
+
26
+ module.exports = mcpCommand;
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const chalk = require('chalk');
6
+
7
+ const { requireServerRoot } = require('../../utils/server-root');
8
+ const { getDatabaseConfig } = require('../../utils/config-reader');
9
+ const { scanModules } = require('../../utils/lua-parser');
10
+
11
+ const MIGRATIONS_TABLE = 'shiva_migrations';
12
+
13
+ /**
14
+ * Register the `shiva migrate:rollback` command.
15
+ * @param {import('commander').Command} program
16
+ */
17
+ function migrateRollbackCommand(program) {
18
+ program
19
+ .command('migrate:rollback')
20
+ .description('Roll back the last migration batch')
21
+ .option('-s, --steps <n>', 'Number of batches to roll back', '1')
22
+ .option('--dry-run', 'Show what would roll back without executing')
23
+ .action(async (options) => {
24
+ await run(options);
25
+ });
26
+ }
27
+
28
+ async function run(options) {
29
+ const serverRoot = requireServerRoot();
30
+ const dbConfig = getDatabaseConfig(serverRoot);
31
+ const steps = Math.max(1, parseInt(options.steps, 10) || 1);
32
+
33
+ if (!dbConfig) {
34
+ console.error(chalk.red('✖ No database configuration found in shiva.json'));
35
+ process.exit(1);
36
+ }
37
+
38
+ let connection;
39
+ try {
40
+ const mysql = require('mysql2/promise');
41
+ connection = await mysql.createConnection({
42
+ host: dbConfig.host,
43
+ port: dbConfig.port || 3306,
44
+ user: dbConfig.user,
45
+ password: dbConfig.password,
46
+ database: dbConfig.database,
47
+ multipleStatements: true,
48
+ });
49
+ } catch (err) {
50
+ console.error(chalk.red('✖ Could not connect to database:'), err.message);
51
+ process.exit(1);
52
+ }
53
+
54
+ try {
55
+ const tableExists = await checkTableExists(connection, dbConfig.database);
56
+ if (!tableExists) {
57
+ console.log(chalk.gray(' No migrations table found. Nothing to roll back.'));
58
+ return;
59
+ }
60
+
61
+ const [batchRows] = await connection.execute(
62
+ `SELECT MAX(\`batch\`) AS max_batch FROM \`${MIGRATIONS_TABLE}\``
63
+ );
64
+ const maxBatch = batchRows[0].max_batch;
65
+
66
+ if (!maxBatch) {
67
+ console.log(chalk.gray(' Nothing to roll back.'));
68
+ return;
69
+ }
70
+
71
+ const minBatch = Math.max(1, maxBatch - steps + 1);
72
+ const [rows] = await connection.execute(
73
+ `SELECT \`migration\` FROM \`${MIGRATIONS_TABLE}\` WHERE \`batch\` >= ? ORDER BY \`id\` DESC`,
74
+ [minBatch]
75
+ );
76
+
77
+ if (rows.length === 0) {
78
+ console.log(chalk.gray(' Nothing to roll back.'));
79
+ return;
80
+ }
81
+
82
+ const resourcesDir = path.join(serverRoot, 'resources');
83
+ const modules = scanModules(resourcesDir);
84
+ const migrationMap = buildMigrationMap(modules);
85
+
86
+ console.log('');
87
+ console.log(chalk.bold(`Rolling back ${rows.length} migration(s) (${steps} batch(es))...\n`));
88
+
89
+ for (const row of rows) {
90
+ const migName = row.migration;
91
+
92
+ if (options.dryRun) {
93
+ console.log(chalk.yellow(` [dry-run] rollback: ${migName}`));
94
+ continue;
95
+ }
96
+
97
+ process.stdout.write(chalk.gray(` Rolling back: ${migName} ...`));
98
+
99
+ const filePath = migrationMap[migName];
100
+ if (!filePath || !fs.existsSync(filePath)) {
101
+ console.log(chalk.yellow(' skipped (file not found)'));
102
+ continue;
103
+ }
104
+
105
+ try {
106
+ const mod = require(filePath);
107
+ if (typeof mod.down === 'function') {
108
+ await mod.down({ execute: (sql) => connection.execute(sql) });
109
+ }
110
+ await connection.execute(
111
+ `DELETE FROM \`${MIGRATIONS_TABLE}\` WHERE \`migration\` = ?`,
112
+ [migName]
113
+ );
114
+ console.log(chalk.green(' done'));
115
+ } catch (err) {
116
+ console.log(chalk.red(' failed'));
117
+ console.error(chalk.red(` Error: ${err.message}`));
118
+ break;
119
+ }
120
+ }
121
+
122
+ if (!options.dryRun) {
123
+ console.log('');
124
+ console.log(chalk.green(`✔ Rolled back ${steps} batch(es).`));
125
+ }
126
+
127
+ } finally {
128
+ await connection.end();
129
+ }
130
+ }
131
+
132
+ async function checkTableExists(conn, database) {
133
+ const [rows] = await conn.execute(
134
+ `SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?`,
135
+ [database, MIGRATIONS_TABLE]
136
+ );
137
+ return rows.length > 0;
138
+ }
139
+
140
+ function buildMigrationMap(modules) {
141
+ const map = {};
142
+ for (const mod of modules) {
143
+ const migrationsDir = path.join(mod.path, 'migrations');
144
+ if (!fs.existsSync(migrationsDir)) continue;
145
+
146
+ const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.lua'));
147
+ for (const file of files) {
148
+ const key = `${mod.name}/${file.replace('.lua', '')}`;
149
+ map[key] = path.join(migrationsDir, file);
150
+ }
151
+ }
152
+ return map;
153
+ }
154
+
155
+ module.exports = migrateRollbackCommand;
@@ -0,0 +1,159 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const chalk = require('chalk');
6
+
7
+ const { requireServerRoot } = require('../../utils/server-root');
8
+ const { getDatabaseConfig } = require('../../utils/config-reader');
9
+ const { scanModules } = require('../../utils/lua-parser');
10
+
11
+ const MIGRATIONS_TABLE = 'shiva_migrations';
12
+
13
+ /**
14
+ * Register the `shiva migrate` command.
15
+ * @param {import('commander').Command} program
16
+ */
17
+ function migrateRunCommand(program) {
18
+ program
19
+ .command('migrate')
20
+ .description('Run all pending database migrations')
21
+ .option('--dry-run', 'Show what would run without executing')
22
+ .action(async (options) => {
23
+ await run(options);
24
+ });
25
+ }
26
+
27
+ async function run(options) {
28
+ const serverRoot = requireServerRoot();
29
+ const dbConfig = getDatabaseConfig(serverRoot);
30
+
31
+ if (!dbConfig) {
32
+ console.error(chalk.red('✖ No database configuration found in shiva.json'));
33
+ console.error(chalk.gray(' Add a "database" section to shiva.json with host, port, user, password, database.'));
34
+ process.exit(1);
35
+ }
36
+
37
+ let connection;
38
+ try {
39
+ const mysql = require('mysql2/promise');
40
+ connection = await mysql.createConnection({
41
+ host: dbConfig.host,
42
+ port: dbConfig.port || 3306,
43
+ user: dbConfig.user,
44
+ password: dbConfig.password,
45
+ database: dbConfig.database,
46
+ multipleStatements: true,
47
+ });
48
+ } catch (err) {
49
+ console.error(chalk.red('✖ Could not connect to database:'), err.message);
50
+ process.exit(1);
51
+ }
52
+
53
+ try {
54
+ await ensureMigrationsTable(connection);
55
+
56
+ const resourcesDir = path.join(serverRoot, 'resources');
57
+ const modules = scanModules(resourcesDir);
58
+ const allMigrations = collectMigrations(modules);
59
+
60
+ if (allMigrations.length === 0) {
61
+ console.log(chalk.gray(' No migration files found.'));
62
+ return;
63
+ }
64
+
65
+ const ran = await getRanMigrations(connection);
66
+ const pending = allMigrations.filter(m => !ran.has(m.name));
67
+
68
+ if (pending.length === 0) {
69
+ console.log(chalk.green('✔ Nothing to migrate — all up to date.'));
70
+ return;
71
+ }
72
+
73
+ console.log('');
74
+ console.log(chalk.bold(`Running ${pending.length} migration(s)...\n`));
75
+
76
+ const batch = await getNextBatch(connection);
77
+
78
+ for (const migration of pending) {
79
+ if (options.dryRun) {
80
+ console.log(chalk.yellow(` [dry-run] ${migration.name}`));
81
+ continue;
82
+ }
83
+
84
+ process.stdout.write(chalk.gray(` Migrating: ${migration.name} ...`));
85
+
86
+ try {
87
+ const mod = require(migration.path);
88
+ await mod.up({ execute: (sql) => connection.execute(sql) });
89
+ await recordMigration(connection, migration.name, batch);
90
+ console.log(chalk.green(' done'));
91
+ } catch (err) {
92
+ console.log(chalk.red(' failed'));
93
+ console.error(chalk.red(` Error: ${err.message}`));
94
+ console.error(chalk.yellow(' Migration stopped. Fix the error and re-run.'));
95
+ break;
96
+ }
97
+ }
98
+
99
+ if (!options.dryRun) {
100
+ console.log('');
101
+ console.log(chalk.green(`✔ Batch ${batch} complete.`));
102
+ }
103
+
104
+ } finally {
105
+ await connection.end();
106
+ }
107
+ }
108
+
109
+ async function ensureMigrationsTable(conn) {
110
+ await conn.execute(`
111
+ CREATE TABLE IF NOT EXISTS \`${MIGRATIONS_TABLE}\` (
112
+ \`id\` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
113
+ \`migration\` VARCHAR(255) NOT NULL UNIQUE,
114
+ \`batch\` INT UNSIGNED NOT NULL DEFAULT 1,
115
+ \`ran_at\` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
116
+ ) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
117
+ `);
118
+ }
119
+
120
+ async function getRanMigrations(conn) {
121
+ const [rows] = await conn.execute(`SELECT \`migration\` FROM \`${MIGRATIONS_TABLE}\``);
122
+ return new Set(rows.map(r => r.migration));
123
+ }
124
+
125
+ async function getNextBatch(conn) {
126
+ const [rows] = await conn.execute(`SELECT COALESCE(MAX(\`batch\`), 0) + 1 AS next_batch FROM \`${MIGRATIONS_TABLE}\``);
127
+ return rows[0].next_batch;
128
+ }
129
+
130
+ async function recordMigration(conn, name, batch) {
131
+ await conn.execute(
132
+ `INSERT INTO \`${MIGRATIONS_TABLE}\` (\`migration\`, \`batch\`) VALUES (?, ?)`,
133
+ [name, batch]
134
+ );
135
+ }
136
+
137
+ function collectMigrations(modules) {
138
+ const migrations = [];
139
+ for (const mod of modules) {
140
+ const migrationsDir = path.join(mod.path, 'migrations');
141
+ if (!fs.existsSync(migrationsDir)) continue;
142
+
143
+ const files = fs.readdirSync(migrationsDir)
144
+ .filter(f => f.endsWith('.lua'))
145
+ .sort();
146
+
147
+ for (const file of files) {
148
+ migrations.push({
149
+ name: `${mod.name}/${file.replace('.lua', '')}`,
150
+ path: path.join(migrationsDir, file),
151
+ module: mod.name,
152
+ });
153
+ }
154
+ }
155
+
156
+ return migrations.sort((a, b) => a.name.localeCompare(b.name));
157
+ }
158
+
159
+ module.exports = migrateRunCommand;