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 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
+ }