banco-sync-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.
- package/bin/index.js +269 -0
- package/lib/config.js +70 -0
- package/lib/db.js +166 -0
- package/lib/diff.js +38 -0
- package/lib/gemini.js +82 -0
- package/package.json +34 -0
package/bin/index.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import inquirer from 'inquirer';
|
|
5
|
+
import ora from 'ora';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
|
|
10
|
+
import { getConfig, saveConfig } from '../lib/config.js';
|
|
11
|
+
import { fetchSchema } from '../lib/db.js';
|
|
12
|
+
import { syncSqlWithGemini } from '../lib/gemini.js';
|
|
13
|
+
import { printDiff } from '../lib/diff.js';
|
|
14
|
+
|
|
15
|
+
const program = new Command();
|
|
16
|
+
|
|
17
|
+
program
|
|
18
|
+
.name('banco-sync-cli')
|
|
19
|
+
.description('CLI tool to sync SQL files with database schemas using Gemini API')
|
|
20
|
+
.version('1.0.0');
|
|
21
|
+
|
|
22
|
+
// Login Command
|
|
23
|
+
program
|
|
24
|
+
.command('login')
|
|
25
|
+
.description('Configure database credentials, Gemini API key, and SQL folder')
|
|
26
|
+
.action(async () => {
|
|
27
|
+
console.log(chalk.bold.cyan('\n--- Configure banco-sync-cli ---\n'));
|
|
28
|
+
|
|
29
|
+
const currentConfig = getConfig() || {};
|
|
30
|
+
const currentDb = currentConfig.dbConfig || {};
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const answers = await inquirer.prompt([
|
|
34
|
+
{
|
|
35
|
+
type: 'list',
|
|
36
|
+
name: 'dbType',
|
|
37
|
+
message: 'Select database type:',
|
|
38
|
+
choices: ['postgres', 'mysql'],
|
|
39
|
+
default: currentConfig.dbType || 'postgres'
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
type: 'input',
|
|
43
|
+
name: 'host',
|
|
44
|
+
message: 'Database host:',
|
|
45
|
+
default: answers => {
|
|
46
|
+
return currentDb.host || 'localhost';
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
type: 'input',
|
|
51
|
+
name: 'port',
|
|
52
|
+
message: 'Database port:',
|
|
53
|
+
default: (answers) => {
|
|
54
|
+
if (currentDb.port) return currentDb.port.toString();
|
|
55
|
+
return answers.dbType === 'postgres' ? '5432' : '3306';
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: 'input',
|
|
60
|
+
name: 'user',
|
|
61
|
+
message: 'Database user:',
|
|
62
|
+
default: (answers) => {
|
|
63
|
+
if (currentDb.user) return currentDb.user;
|
|
64
|
+
return answers.dbType === 'postgres' ? 'postgres' : 'root';
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
type: 'password',
|
|
69
|
+
name: 'password',
|
|
70
|
+
message: 'Database password:',
|
|
71
|
+
mask: '*',
|
|
72
|
+
default: currentDb.password || ''
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
type: 'input',
|
|
76
|
+
name: 'database',
|
|
77
|
+
message: 'Database name:',
|
|
78
|
+
default: currentDb.database || '',
|
|
79
|
+
validate: input => input.trim() !== '' ? true : 'Database name is required'
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
type: 'confirm',
|
|
83
|
+
name: 'ssl',
|
|
84
|
+
message: 'Enable SSL (rejectUnauthorized=false)?',
|
|
85
|
+
default: currentDb.ssl || false,
|
|
86
|
+
when: (answers) => answers.dbType === 'postgres'
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
type: 'password',
|
|
90
|
+
name: 'geminiApiKey',
|
|
91
|
+
message: 'Gemini API Key:',
|
|
92
|
+
mask: '*',
|
|
93
|
+
default: currentConfig.geminiApiKey || '',
|
|
94
|
+
validate: input => input.trim() !== '' ? true : 'Gemini API Key is required'
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
type: 'input',
|
|
98
|
+
name: 'sqlFolder',
|
|
99
|
+
message: 'Path to the folder with SQL files:',
|
|
100
|
+
default: currentConfig.sqlFolder || './',
|
|
101
|
+
validate: input => {
|
|
102
|
+
const resolved = path.resolve(process.cwd(), input);
|
|
103
|
+
if (!fs.existsSync(resolved)) {
|
|
104
|
+
return `Directory does not exist: ${resolved}`;
|
|
105
|
+
}
|
|
106
|
+
if (!fs.statSync(resolved).isDirectory()) {
|
|
107
|
+
return `Path is not a directory: ${resolved}`;
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
const projectConfig = {
|
|
115
|
+
dbType: answers.dbType,
|
|
116
|
+
dbConfig: {
|
|
117
|
+
host: answers.host,
|
|
118
|
+
port: parseInt(answers.port, 10),
|
|
119
|
+
user: answers.user,
|
|
120
|
+
password: answers.password,
|
|
121
|
+
database: answers.database,
|
|
122
|
+
ssl: answers.ssl || false
|
|
123
|
+
},
|
|
124
|
+
geminiApiKey: answers.geminiApiKey,
|
|
125
|
+
sqlFolder: answers.sqlFolder
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
saveConfig(projectConfig);
|
|
129
|
+
console.log(chalk.bold.green('\nConfiguration saved successfully!\n'));
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.error(chalk.red('\nConfiguration failed:'), error.message);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Sync Command
|
|
136
|
+
program
|
|
137
|
+
.command('sync')
|
|
138
|
+
.description('Sync SQL file(s) with the current database schema')
|
|
139
|
+
.action(async () => {
|
|
140
|
+
const config = getConfig();
|
|
141
|
+
if (!config) {
|
|
142
|
+
console.log(chalk.bold.red('\nNo configuration found for this folder.'));
|
|
143
|
+
console.log(`Please run the login command first:\n ${chalk.cyan('npx banco-sync-cli login')}\n`);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const absoluteSqlFolder = path.resolve(process.cwd(), config.sqlFolder);
|
|
148
|
+
if (!fs.existsSync(absoluteSqlFolder)) {
|
|
149
|
+
console.error(chalk.red(`\nSQL folder not found at: ${absoluteSqlFolder}`));
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Get all SQL files in the directory
|
|
154
|
+
let sqlFiles = [];
|
|
155
|
+
try {
|
|
156
|
+
sqlFiles = fs.readdirSync(absoluteSqlFolder)
|
|
157
|
+
.filter(file => file.toLowerCase().endsWith('.sql'));
|
|
158
|
+
} catch (err) {
|
|
159
|
+
console.error(chalk.red(`\nError reading directory: ${err.message}`));
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (sqlFiles.length === 0) {
|
|
164
|
+
console.log(chalk.yellow(`\nNo .sql files found in directory: ${absoluteSqlFolder}\n`));
|
|
165
|
+
process.exit(0);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
// 1. Ask what to sync
|
|
170
|
+
const { syncScope } = await inquirer.prompt([
|
|
171
|
+
{
|
|
172
|
+
type: 'list',
|
|
173
|
+
name: 'syncScope',
|
|
174
|
+
message: 'What would you like to sync?',
|
|
175
|
+
choices: [
|
|
176
|
+
{ name: 'All SQL files in the folder', value: 'all' },
|
|
177
|
+
{ name: 'Select a specific SQL file', value: 'single' }
|
|
178
|
+
]
|
|
179
|
+
}
|
|
180
|
+
]);
|
|
181
|
+
|
|
182
|
+
let filesToSync = [];
|
|
183
|
+
if (syncScope === 'all') {
|
|
184
|
+
filesToSync = sqlFiles;
|
|
185
|
+
} else {
|
|
186
|
+
const { selectedFile } = await inquirer.prompt([
|
|
187
|
+
{
|
|
188
|
+
type: 'list',
|
|
189
|
+
name: 'selectedFile',
|
|
190
|
+
message: 'Select the SQL file to sync:',
|
|
191
|
+
choices: sqlFiles
|
|
192
|
+
}
|
|
193
|
+
]);
|
|
194
|
+
filesToSync = [selectedFile];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 2. Connect to database and fetch schema
|
|
198
|
+
const dbSpinner = ora('Connecting to database and fetching schema...').start();
|
|
199
|
+
let schema;
|
|
200
|
+
try {
|
|
201
|
+
schema = await fetchSchema(config.dbType, config.dbConfig);
|
|
202
|
+
dbSpinner.succeed('Database schema fetched successfully!');
|
|
203
|
+
} catch (err) {
|
|
204
|
+
dbSpinner.fail(`Failed to fetch database schema: ${err.message}`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 3. Process each file
|
|
209
|
+
for (const fileName of filesToSync) {
|
|
210
|
+
const filePath = path.join(absoluteSqlFolder, fileName);
|
|
211
|
+
const originalContent = fs.readFileSync(filePath, 'utf-8');
|
|
212
|
+
|
|
213
|
+
const geminiSpinner = ora(`Analyzing and comparing ${chalk.bold(fileName)} with Gemini...`).start();
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const result = await syncSqlWithGemini(
|
|
217
|
+
config.geminiApiKey,
|
|
218
|
+
schema,
|
|
219
|
+
originalContent,
|
|
220
|
+
fileName
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
geminiSpinner.succeed(`Analysis completed for ${fileName}`);
|
|
224
|
+
|
|
225
|
+
const { updatedSql, changes } = result;
|
|
226
|
+
|
|
227
|
+
// 4. Print visual diff
|
|
228
|
+
const hasChanges = printDiff(fileName, originalContent, updatedSql);
|
|
229
|
+
|
|
230
|
+
if (!hasChanges) {
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Print changes summary
|
|
235
|
+
console.log(chalk.bold('Summary of proposed changes:'));
|
|
236
|
+
changes.forEach(change => console.log(` - ${change}`));
|
|
237
|
+
console.log();
|
|
238
|
+
|
|
239
|
+
// 5. Ask to apply changes
|
|
240
|
+
const { confirmSave } = await inquirer.prompt([
|
|
241
|
+
{
|
|
242
|
+
type: 'confirm',
|
|
243
|
+
name: 'confirmSave',
|
|
244
|
+
message: `Do you want to apply these changes to ${chalk.cyan(fileName)}?`,
|
|
245
|
+
default: true
|
|
246
|
+
}
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
if (confirmSave) {
|
|
250
|
+
fs.writeFileSync(filePath, updatedSql, 'utf-8');
|
|
251
|
+
console.log(chalk.green(`\nSaved updates to ${fileName} successfully!\n`));
|
|
252
|
+
} else {
|
|
253
|
+
console.log(chalk.yellow(`\nSkipped updates for ${fileName}.\n`));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
} catch (err) {
|
|
257
|
+
geminiSpinner.fail(`Failed to sync ${fileName}: ${err.message}`);
|
|
258
|
+
console.error(err);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
console.log(chalk.bold.green('Sync process finished!\n'));
|
|
263
|
+
|
|
264
|
+
} catch (err) {
|
|
265
|
+
console.error(chalk.red('\nSync command failed:'), err.message);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
program.parse(process.argv);
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = path.join(os.homedir(), '.banco-sync-cli');
|
|
6
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
7
|
+
|
|
8
|
+
function ensureConfigDir() {
|
|
9
|
+
if (!fs.existsSync(CONFIG_DIR)) {
|
|
10
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Loads the global configuration store.
|
|
16
|
+
* @returns {Object} The complete config store.
|
|
17
|
+
*/
|
|
18
|
+
function loadStore() {
|
|
19
|
+
ensureConfigDir();
|
|
20
|
+
if (!fs.existsSync(CONFIG_FILE)) {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const data = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
|
25
|
+
return JSON.parse(data);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error('Error reading configuration file:', error.message);
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Saves the global configuration store.
|
|
34
|
+
* @param {Object} store The complete config store.
|
|
35
|
+
*/
|
|
36
|
+
function saveStore(store) {
|
|
37
|
+
ensureConfigDir();
|
|
38
|
+
try {
|
|
39
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(store, null, 2), 'utf-8');
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error('Error writing configuration file:', error.message);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Loads configuration specifically for the current working directory.
|
|
47
|
+
* @returns {Object|null} The configuration for the current project, or null if it doesn't exist.
|
|
48
|
+
*/
|
|
49
|
+
export function getConfig() {
|
|
50
|
+
const cwd = process.cwd();
|
|
51
|
+
const store = loadStore();
|
|
52
|
+
return store[cwd] || null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Saves or updates configuration for the current working directory.
|
|
57
|
+
* @param {Object} projectConfig The config object to save.
|
|
58
|
+
*/
|
|
59
|
+
export function saveConfig(projectConfig) {
|
|
60
|
+
const cwd = process.cwd();
|
|
61
|
+
const store = loadStore();
|
|
62
|
+
|
|
63
|
+
// Merge if exists, otherwise write new
|
|
64
|
+
store[cwd] = {
|
|
65
|
+
...(store[cwd] || {}),
|
|
66
|
+
...projectConfig
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
saveStore(store);
|
|
70
|
+
}
|
package/lib/db.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import pg from 'pg';
|
|
2
|
+
import mysql from 'mysql2/promise';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fetches the database schema and returns it in a structured JSON format.
|
|
6
|
+
* @param {string} dbType 'postgres' or 'mysql'
|
|
7
|
+
* @param {Object} config Database connection credentials
|
|
8
|
+
* @returns {Promise<Object>} Unified schema description
|
|
9
|
+
*/
|
|
10
|
+
export async function fetchSchema(dbType, config) {
|
|
11
|
+
if (dbType === 'postgres') {
|
|
12
|
+
return fetchPostgresSchema(config);
|
|
13
|
+
} else if (dbType === 'mysql') {
|
|
14
|
+
return fetchMysqlSchema(config);
|
|
15
|
+
} else {
|
|
16
|
+
throw new Error(`Unsupported database type: ${dbType}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Connects to PostgreSQL and retrieves the schema.
|
|
22
|
+
*/
|
|
23
|
+
async function fetchPostgresSchema(config) {
|
|
24
|
+
const client = new pg.Client({
|
|
25
|
+
host: config.host,
|
|
26
|
+
port: parseInt(config.port, 10) || 5432,
|
|
27
|
+
user: config.user,
|
|
28
|
+
password: config.password,
|
|
29
|
+
database: config.database,
|
|
30
|
+
// Add SSL support if needed, defaulting to rejectUnauthorized false for local dev/flexible environments
|
|
31
|
+
ssl: config.ssl ? { rejectUnauthorized: false } : false
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
await client.connect();
|
|
36
|
+
|
|
37
|
+
// 1. Get columns and details
|
|
38
|
+
const columnsQuery = `
|
|
39
|
+
SELECT
|
|
40
|
+
table_name,
|
|
41
|
+
column_name,
|
|
42
|
+
data_type,
|
|
43
|
+
is_nullable,
|
|
44
|
+
column_default,
|
|
45
|
+
character_maximum_length
|
|
46
|
+
FROM
|
|
47
|
+
information_schema.columns
|
|
48
|
+
WHERE
|
|
49
|
+
table_schema = 'public'
|
|
50
|
+
ORDER BY
|
|
51
|
+
table_name, ordinal_position;
|
|
52
|
+
`;
|
|
53
|
+
const colsResult = await client.query(columnsQuery);
|
|
54
|
+
|
|
55
|
+
// 2. Get primary keys
|
|
56
|
+
const pkQuery = `
|
|
57
|
+
SELECT
|
|
58
|
+
tc.table_name,
|
|
59
|
+
kcu.column_name
|
|
60
|
+
FROM
|
|
61
|
+
information_schema.table_constraints AS tc
|
|
62
|
+
JOIN information_schema.key_column_usage AS kcu
|
|
63
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
64
|
+
AND tc.table_schema = kcu.table_schema
|
|
65
|
+
WHERE
|
|
66
|
+
tc.table_schema = 'public'
|
|
67
|
+
AND tc.constraint_type = 'PRIMARY KEY';
|
|
68
|
+
`;
|
|
69
|
+
const pkResult = await client.query(pkQuery);
|
|
70
|
+
|
|
71
|
+
// Create a set of primary keys for lookup "tableName.columnName"
|
|
72
|
+
const primaryKeys = new Set(
|
|
73
|
+
pkResult.rows.map(row => `${row.table_name}.${row.column_name}`)
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Structure the result
|
|
77
|
+
const schema = { tables: {} };
|
|
78
|
+
for (const col of colsResult.rows) {
|
|
79
|
+
const { table_name, column_name, data_type, is_nullable, column_default, character_maximum_length } = col;
|
|
80
|
+
|
|
81
|
+
if (!schema.tables[table_name]) {
|
|
82
|
+
schema.tables[table_name] = { columns: [] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
schema.tables[table_name].columns.push({
|
|
86
|
+
name: column_name,
|
|
87
|
+
type: data_type,
|
|
88
|
+
nullable: is_nullable,
|
|
89
|
+
default: column_default,
|
|
90
|
+
maxLength: character_maximum_length,
|
|
91
|
+
isPrimaryKey: primaryKeys.has(`${table_name}.${column_name}`)
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return schema;
|
|
96
|
+
} finally {
|
|
97
|
+
await client.end().catch(() => {});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Connects to MySQL and retrieves the schema.
|
|
103
|
+
*/
|
|
104
|
+
async function fetchMysqlSchema(config) {
|
|
105
|
+
const connection = await mysql.createConnection({
|
|
106
|
+
host: config.host,
|
|
107
|
+
port: parseInt(config.port, 10) || 3306,
|
|
108
|
+
user: config.user,
|
|
109
|
+
password: config.password,
|
|
110
|
+
database: config.database
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const columnsQuery = `
|
|
115
|
+
SELECT
|
|
116
|
+
table_name,
|
|
117
|
+
column_name,
|
|
118
|
+
data_type,
|
|
119
|
+
is_nullable,
|
|
120
|
+
column_default,
|
|
121
|
+
character_maximum_length,
|
|
122
|
+
column_key
|
|
123
|
+
FROM
|
|
124
|
+
information_schema.columns
|
|
125
|
+
WHERE
|
|
126
|
+
table_schema = ?
|
|
127
|
+
ORDER BY
|
|
128
|
+
table_name, ordinal_position;
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
const [rows] = await connection.execute(columnsQuery, [config.database]);
|
|
132
|
+
|
|
133
|
+
const schema = { tables: {} };
|
|
134
|
+
for (const col of rows) {
|
|
135
|
+
const {
|
|
136
|
+
TABLE_NAME,
|
|
137
|
+
COLUMN_NAME,
|
|
138
|
+
DATA_TYPE,
|
|
139
|
+
IS_NULLABLE,
|
|
140
|
+
COLUMN_DEFAULT,
|
|
141
|
+
CHARACTER_MAXIMUM_LENGTH,
|
|
142
|
+
COLUMN_KEY
|
|
143
|
+
} = col;
|
|
144
|
+
|
|
145
|
+
const tableName = TABLE_NAME;
|
|
146
|
+
const columnName = COLUMN_NAME;
|
|
147
|
+
|
|
148
|
+
if (!schema.tables[tableName]) {
|
|
149
|
+
schema.tables[tableName] = { columns: [] };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
schema.tables[tableName].columns.push({
|
|
153
|
+
name: columnName,
|
|
154
|
+
type: DATA_TYPE,
|
|
155
|
+
nullable: IS_NULLABLE,
|
|
156
|
+
default: COLUMN_DEFAULT,
|
|
157
|
+
maxLength: CHARACTER_MAXIMUM_LENGTH,
|
|
158
|
+
isPrimaryKey: COLUMN_KEY === 'PRI'
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return schema;
|
|
163
|
+
} finally {
|
|
164
|
+
await connection.end().catch(() => {});
|
|
165
|
+
}
|
|
166
|
+
}
|
package/lib/diff.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { structuredPatch } from 'diff';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Computes and prints a git-like colored diff of the modifications to the terminal.
|
|
6
|
+
* @param {string} filename Name of the file being compared
|
|
7
|
+
* @param {string} oldStr Original content of the file
|
|
8
|
+
* @param {string} newStr Updated content of the file
|
|
9
|
+
* @returns {boolean} True if differences were found and printed, false otherwise.
|
|
10
|
+
*/
|
|
11
|
+
export function printDiff(filename, oldStr, newStr) {
|
|
12
|
+
const patch = structuredPatch(filename, filename, oldStr, newStr, '', '', { context: 3 });
|
|
13
|
+
|
|
14
|
+
if (patch.hunks.length === 0) {
|
|
15
|
+
console.log(chalk.gray(`No changes detected for ${filename}.`));
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
console.log(chalk.bold(`\nDiff for ${chalk.cyan(filename)}:`));
|
|
20
|
+
console.log(chalk.gray('--------------------------------------------------'));
|
|
21
|
+
|
|
22
|
+
for (const hunk of patch.hunks) {
|
|
23
|
+
console.log(
|
|
24
|
+
chalk.cyan(`@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`)
|
|
25
|
+
);
|
|
26
|
+
for (const line of hunk.lines) {
|
|
27
|
+
if (line.startsWith('+')) {
|
|
28
|
+
console.log(chalk.green(line));
|
|
29
|
+
} else if (line.startsWith('-')) {
|
|
30
|
+
console.log(chalk.red(line));
|
|
31
|
+
} else {
|
|
32
|
+
console.log(line);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
console.log(chalk.gray('--------------------------------------------------\n'));
|
|
37
|
+
return true;
|
|
38
|
+
}
|
package/lib/gemini.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { GoogleGenerativeAI } from '@google/generative-ai';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compares an SQL file's content against the database schema using Gemini API.
|
|
5
|
+
* Updates the SQL file content to match the database schema.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} apiKey Gemini API Key
|
|
8
|
+
* @param {Object} dbSchema The database schema object
|
|
9
|
+
* @param {string} sqlContent The current SQL file content
|
|
10
|
+
* @param {string} fileName The name/path of the SQL file for context
|
|
11
|
+
* @returns {Promise<{ updatedSql: string, changes: string[] }>} Updated SQL content and list of changes
|
|
12
|
+
*/
|
|
13
|
+
export async function syncSqlWithGemini(apiKey, dbSchema, sqlContent, fileName) {
|
|
14
|
+
const genAI = new GoogleGenerativeAI(apiKey);
|
|
15
|
+
|
|
16
|
+
// Using gemini-1.5-flash as it is fast, highly accurate, and supports structured JSON outputs
|
|
17
|
+
const model = genAI.getGenerativeModel({
|
|
18
|
+
model: 'gemini-1.5-flash',
|
|
19
|
+
systemInstruction: `You are an expert SQL engineer. Your task is to compare a provided SQL file containing database table definitions (such as schema setups, migrations, or DDL scripts) against the actual database schema (provided as a JSON representation of current tables, columns, types, defaults, and constraints).
|
|
20
|
+
|
|
21
|
+
You must modify the SQL file so that the definitions of any tables, columns, and properties in the file match the actual state of the database.
|
|
22
|
+
|
|
23
|
+
Rules:
|
|
24
|
+
1. ONLY modify structures (tables, columns, data types, constraints, defaults) that are already defined or referenced in the SQL file. Do not add completely new tables to the file that are not already present there, unless the SQL file is completely empty.
|
|
25
|
+
2. If a table defined in the SQL file has columns in the actual database schema that are missing in the SQL file, add those columns to the table definition in the SQL file.
|
|
26
|
+
3. If a column in the SQL file has a different data type, nullability, or default value than the database schema, update it to match the database schema.
|
|
27
|
+
4. Keep the original style, syntax, capitalization, formatting, comments, and structure of the SQL file as much as possible. Only change the column definitions or constraints that need updating.
|
|
28
|
+
5. Return the full updated SQL code in the JSON response under 'updatedSql'.
|
|
29
|
+
6. Do not include markdown code block formatting (like \`\`\`sql) inside the JSON string return values. Return raw text.`
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const prompt = `
|
|
33
|
+
SQL File Name: ${fileName}
|
|
34
|
+
|
|
35
|
+
Current SQL File Content:
|
|
36
|
+
---
|
|
37
|
+
${sqlContent}
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
Actual Database Schema (JSON):
|
|
41
|
+
---
|
|
42
|
+
${JSON.stringify(dbSchema, null, 2)}
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
Compare the SQL file content with the actual database schema. Generate the updated SQL file contents to align it with the database schema, and list the changes made.
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
const generationConfig = {
|
|
49
|
+
responseMimeType: 'application/json',
|
|
50
|
+
responseSchema: {
|
|
51
|
+
type: 'object',
|
|
52
|
+
properties: {
|
|
53
|
+
updatedSql: {
|
|
54
|
+
type: 'string',
|
|
55
|
+
description: 'The complete, updated SQL file content with all style and formatting preserved but with structural adjustments to match the database schema.'
|
|
56
|
+
},
|
|
57
|
+
changes: {
|
|
58
|
+
type: 'array',
|
|
59
|
+
items: {
|
|
60
|
+
type: 'string'
|
|
61
|
+
},
|
|
62
|
+
description: 'A list of short sentences describing the modifications made (e.g., "Added age column to users table", "Changed email column type to varchar(255)").'
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
required: ['updatedSql', 'changes']
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const result = await model.generateContent({
|
|
71
|
+
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
|
72
|
+
generationConfig
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const response = await result.response;
|
|
76
|
+
const responseText = response.text();
|
|
77
|
+
|
|
78
|
+
return JSON.parse(responseText);
|
|
79
|
+
} catch (error) {
|
|
80
|
+
throw new Error(`Gemini API call failed: ${error.message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "banco-sync-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI tool to sync SQL files with database schemas using Gemini API",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "bin/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"banco-sync-cli": "./bin/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node bin/index.js"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"cli",
|
|
15
|
+
"database",
|
|
16
|
+
"sync",
|
|
17
|
+
"gemini",
|
|
18
|
+
"sql",
|
|
19
|
+
"postgres",
|
|
20
|
+
"mysql"
|
|
21
|
+
],
|
|
22
|
+
"author": "",
|
|
23
|
+
"license": "ISC",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@google/generative-ai": "^0.11.4",
|
|
26
|
+
"chalk": "^5.3.0",
|
|
27
|
+
"commander": "^12.1.0",
|
|
28
|
+
"diff": "^5.2.0",
|
|
29
|
+
"inquirer": "^9.2.22",
|
|
30
|
+
"mysql2": "^3.9.8",
|
|
31
|
+
"ora": "^8.0.1",
|
|
32
|
+
"pg": "^8.12.0"
|
|
33
|
+
}
|
|
34
|
+
}
|