2020-11-14 01:38:56 +00:00
|
|
|
#!/usr/bin/env node
|
|
|
|
|
2020-12-04 16:30:26 +00:00
|
|
|
require('require-yaml');
|
|
|
|
require('colors');
|
|
|
|
const getopts = require('getopts');
|
|
|
|
const packageJson = require('./package.json');
|
|
|
|
const fs = require('fs-extra');
|
|
|
|
const ini = require('ini');
|
|
|
|
const path = require('path');
|
|
|
|
const mysql = require('mysql2/promise');
|
|
|
|
const nodegit = require('nodegit');
|
2021-10-25 13:38:07 +00:00
|
|
|
const camelToSnake = require('./lib').camelToSnake;
|
2020-12-04 16:30:26 +00:00
|
|
|
|
|
|
|
class MyVC {
|
|
|
|
async run(command) {
|
|
|
|
console.log(
|
|
|
|
'MyVC (MySQL Version Control)'.green,
|
|
|
|
`v${packageJson.version}`.magenta
|
|
|
|
);
|
|
|
|
|
2021-10-25 13:38:07 +00:00
|
|
|
const usage = {
|
|
|
|
description: 'Utility for database versioning',
|
|
|
|
params: {
|
|
|
|
remote: 'Name of remote to use',
|
|
|
|
workspace: 'The base directory of the project',
|
|
|
|
socket: 'Wether to connect to database via socket',
|
|
|
|
debug: 'Wether to enable debug mode',
|
|
|
|
version: 'Display the version number and exit',
|
|
|
|
help: 'Display this help message'
|
|
|
|
}
|
|
|
|
};
|
|
|
|
const baseOpts = {
|
2020-12-04 16:30:26 +00:00
|
|
|
alias: {
|
|
|
|
remote: 'r',
|
2021-10-25 13:38:07 +00:00
|
|
|
workspace: 'w'
|
|
|
|
},
|
|
|
|
boolean: {
|
2020-12-04 16:30:26 +00:00
|
|
|
socket: 's',
|
|
|
|
debug: 'd',
|
|
|
|
version: 'v',
|
|
|
|
help: 'h'
|
|
|
|
},
|
|
|
|
default: {
|
|
|
|
workspace: process.cwd()
|
|
|
|
}
|
2021-10-25 13:38:07 +00:00
|
|
|
};
|
|
|
|
const opts = this.getopts(baseOpts);
|
2020-12-04 16:30:26 +00:00
|
|
|
|
|
|
|
try {
|
2021-10-25 13:38:07 +00:00
|
|
|
const commandName = opts._[0];
|
|
|
|
if (!command && commandName) {
|
2020-12-04 16:30:26 +00:00
|
|
|
const commands = [
|
|
|
|
'init',
|
|
|
|
'pull',
|
|
|
|
'push',
|
2021-10-23 13:20:35 +00:00
|
|
|
'version',
|
2020-12-04 16:30:26 +00:00
|
|
|
'dump',
|
|
|
|
'start',
|
|
|
|
'run'
|
|
|
|
];
|
|
|
|
|
|
|
|
if (commands.indexOf(commandName) == -1)
|
|
|
|
throw new Error (`Unknown command '${commandName}'`);
|
|
|
|
|
|
|
|
const Klass = require(`./myvc-${commandName}`);
|
|
|
|
command = new Klass();
|
|
|
|
}
|
2021-10-25 13:38:07 +00:00
|
|
|
|
|
|
|
if (!command) {
|
|
|
|
this.showHelp(baseOpts, usage);
|
|
|
|
process.exit(0);
|
|
|
|
}
|
2020-12-04 16:30:26 +00:00
|
|
|
|
2021-10-25 13:38:07 +00:00
|
|
|
const commandOpts = this.getopts(command.localOpts);
|
|
|
|
Object.assign(opts, commandOpts);
|
2021-10-23 13:20:35 +00:00
|
|
|
|
2021-10-25 13:38:07 +00:00
|
|
|
const operandToOpt = command.usage.operand;
|
2021-10-23 13:20:35 +00:00
|
|
|
if (opts._.length >= 2 && operandToOpt)
|
|
|
|
opts[operandToOpt] = opts._[1];
|
2020-12-04 16:30:26 +00:00
|
|
|
|
2021-10-25 13:38:07 +00:00
|
|
|
if (opts.version)
|
|
|
|
process.exit(0);
|
|
|
|
|
|
|
|
if (opts.help) {
|
|
|
|
this.showHelp(command.localOpts, command.usage, commandName);
|
|
|
|
process.exit(0);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Check version
|
|
|
|
|
|
|
|
let depVersion;
|
|
|
|
const versionRegex = /^[^~]?([0-9]+)\.([0-9]+).([0-9]+)$/;
|
|
|
|
const wsPackageFile = path.join(opts.workspace, 'package.json');
|
|
|
|
|
|
|
|
if (await fs.pathExists(wsPackageFile)) {
|
|
|
|
const wsPackageJson = require(wsPackageFile);
|
|
|
|
try {
|
|
|
|
depVersion = wsPackageJson
|
|
|
|
.dependencies
|
|
|
|
.myvc.match(versionRegex);
|
|
|
|
} catch (e) {}
|
|
|
|
}
|
|
|
|
|
|
|
|
if (depVersion) {
|
|
|
|
const myVersion = packageJson.version.match(versionRegex);
|
|
|
|
const isSameVersion =
|
|
|
|
depVersion[1] === myVersion[1] &&
|
|
|
|
depVersion[2] === myVersion[2];
|
|
|
|
|
|
|
|
if (!isSameVersion)
|
|
|
|
throw new Error(`This version of MyVC differs from your package.json`)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Load method
|
|
|
|
|
2020-12-04 16:30:26 +00:00
|
|
|
parameter('Workspace:', opts.workspace);
|
|
|
|
parameter('Remote:', opts.remote || 'local');
|
|
|
|
|
|
|
|
await this.load(opts);
|
|
|
|
command.opts = opts;
|
|
|
|
await command.run(this, opts);
|
|
|
|
await this.unload();
|
|
|
|
} catch (err) {
|
2021-10-23 17:14:09 +00:00
|
|
|
if (err.name == 'Error' && !opts.debug) {
|
2020-12-04 16:30:26 +00:00
|
|
|
console.error('Error:'.gray, err.message.red);
|
2021-10-23 17:14:09 +00:00
|
|
|
process.exit(1);
|
|
|
|
} else
|
2020-12-04 16:30:26 +00:00
|
|
|
throw err;
|
|
|
|
}
|
|
|
|
|
|
|
|
function parameter(parameter, value) {
|
|
|
|
console.log(parameter.gray, (value || 'null').blue);
|
|
|
|
}
|
|
|
|
|
|
|
|
process.exit();
|
|
|
|
}
|
|
|
|
|
|
|
|
async load(opts) {
|
|
|
|
// Configuration file
|
|
|
|
|
|
|
|
const config = require(`${__dirname}/myvc.default.yml`);
|
|
|
|
|
|
|
|
const configFile = 'myvc.config.yml';
|
|
|
|
const configPath = path.join(opts.workspace, configFile);
|
|
|
|
if (await fs.pathExists(configPath))
|
|
|
|
Object.assign(config, require(configPath));
|
|
|
|
|
|
|
|
Object.assign(opts, config);
|
|
|
|
opts.configFile = configFile;
|
2021-10-25 13:38:07 +00:00
|
|
|
|
|
|
|
if (!opts.myvcDir)
|
|
|
|
opts.myvcDir = path.join(opts.workspace, opts.subdir || '');
|
|
|
|
|
2020-12-04 16:30:26 +00:00
|
|
|
// Database configuration
|
|
|
|
|
|
|
|
let iniDir = __dirname;
|
2021-10-25 13:38:07 +00:00
|
|
|
let iniFile = 'db.ini';
|
|
|
|
|
2020-12-04 16:30:26 +00:00
|
|
|
if (opts.remote) {
|
2021-10-25 13:38:07 +00:00
|
|
|
iniDir = `${opts.myvcDir}/remotes`;
|
|
|
|
iniFile = `${opts.remote}.ini`;
|
2020-12-04 16:30:26 +00:00
|
|
|
}
|
|
|
|
const iniPath = path.join(iniDir, iniFile);
|
|
|
|
|
|
|
|
if (!await fs.pathExists(iniPath))
|
2021-10-25 13:38:07 +00:00
|
|
|
throw new Error(`Database config file not found: ${iniPath}`);
|
2020-12-04 16:30:26 +00:00
|
|
|
|
|
|
|
const iniConfig = ini.parse(await fs.readFile(iniPath, 'utf8')).client;
|
|
|
|
const dbConfig = {
|
|
|
|
host: iniConfig.host,
|
|
|
|
port: iniConfig.port,
|
|
|
|
user: iniConfig.user,
|
|
|
|
password: iniConfig.password,
|
2020-12-05 21:50:45 +00:00
|
|
|
multipleStatements: true,
|
2020-12-04 16:30:26 +00:00
|
|
|
authPlugins: {
|
|
|
|
mysql_clear_password() {
|
|
|
|
return () => iniConfig.password + '\0';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
if (iniConfig.ssl_ca) {
|
|
|
|
dbConfig.ssl = {
|
2021-10-25 13:38:07 +00:00
|
|
|
ca: await fs.readFile(`${opts.myvcDir}/${iniConfig.ssl_ca}`),
|
2020-12-04 16:30:26 +00:00
|
|
|
rejectUnauthorized: iniConfig.ssl_verify_server_cert != undefined
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (opts.socket)
|
|
|
|
dbConfig.socketPath = '/var/run/mysqld/mysqld.sock';
|
|
|
|
|
|
|
|
Object.assign(opts, {
|
|
|
|
iniFile,
|
|
|
|
dbConfig
|
|
|
|
});
|
|
|
|
this.opts = opts;
|
|
|
|
}
|
|
|
|
|
2021-10-23 13:20:35 +00:00
|
|
|
async unload() {
|
|
|
|
if (this.conn)
|
|
|
|
await this.conn.end();
|
|
|
|
}
|
|
|
|
|
2021-10-25 13:38:07 +00:00
|
|
|
getopts(opts) {
|
|
|
|
const argv = process.argv.slice(2);
|
|
|
|
const values = getopts(argv, opts);
|
|
|
|
const cleanValues = {};
|
|
|
|
for (const opt in values)
|
|
|
|
if (opt.length > 1 || opt == '_')
|
|
|
|
cleanValues[opt] = values[opt];
|
|
|
|
return cleanValues;
|
|
|
|
}
|
|
|
|
|
2020-12-04 16:30:26 +00:00
|
|
|
async dbConnect() {
|
2021-10-25 13:38:07 +00:00
|
|
|
const {opts} = this;
|
|
|
|
|
|
|
|
if (!this.conn) {
|
|
|
|
const conn = this.conn = await this.createConnection();
|
|
|
|
|
|
|
|
const [[schema]] = await conn.query(
|
|
|
|
`SHOW DATABASES LIKE ?`, [opts.versionSchema]
|
|
|
|
);
|
|
|
|
|
|
|
|
if (!schema)
|
|
|
|
await conn.query(`CREATE DATABASE ??`, [opts.versionSchema]);
|
|
|
|
|
|
|
|
const [[res]] = await conn.query(
|
|
|
|
`SELECT COUNT(*) > 0 tableExists
|
|
|
|
FROM information_schema.tables
|
|
|
|
WHERE TABLE_SCHEMA = ?
|
|
|
|
AND TABLE_NAME = 'version'`,
|
|
|
|
[opts.versionSchema]
|
|
|
|
);
|
|
|
|
|
|
|
|
if (!res.tableExists) {
|
|
|
|
const structure = await fs.readFile(`${__dirname}/structure.sql`, 'utf8');
|
|
|
|
await conn.query(structure);
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
await conn.query(`USE ??`, [opts.versionSchema]);
|
|
|
|
}
|
|
|
|
|
2020-12-04 16:30:26 +00:00
|
|
|
return this.conn;
|
|
|
|
}
|
|
|
|
|
|
|
|
async createConnection() {
|
|
|
|
return await mysql.createConnection(this.opts.dbConfig);
|
|
|
|
}
|
|
|
|
|
|
|
|
async fetchDbVersion() {
|
|
|
|
const [[version]] = await this.conn.query(
|
|
|
|
`SELECT number, gitCommit
|
|
|
|
FROM version WHERE code = ?`,
|
2021-10-25 13:38:07 +00:00
|
|
|
[this.opts.code]
|
2020-12-04 16:30:26 +00:00
|
|
|
);
|
|
|
|
return version;
|
|
|
|
}
|
|
|
|
|
2020-12-05 21:50:45 +00:00
|
|
|
async changedRoutines(commitSha) {
|
2021-10-22 14:11:03 +00:00
|
|
|
const repo = await this.openRepo();
|
2020-12-04 16:30:26 +00:00
|
|
|
const changes = [];
|
2020-12-18 23:53:21 +00:00
|
|
|
const changesMap = new Map();
|
2020-12-05 21:50:45 +00:00
|
|
|
|
2020-12-18 23:53:21 +00:00
|
|
|
async function pushChanges(diff) {
|
2021-10-23 13:20:35 +00:00
|
|
|
if (!diff) return;
|
2020-12-05 21:50:45 +00:00
|
|
|
const patches = await diff.patches();
|
|
|
|
|
|
|
|
for (const patch of patches) {
|
|
|
|
const path = patch.newFile().path();
|
|
|
|
const match = path.match(/^routines\/(.+)\.sql$/);
|
|
|
|
if (!match) continue;
|
|
|
|
|
2020-12-18 23:53:21 +00:00
|
|
|
let change = changesMap.get(match[1]);
|
|
|
|
if (!change) {
|
|
|
|
change = {path: match[1]};
|
|
|
|
changes.push(change);
|
|
|
|
changesMap.set(match[1], change);
|
|
|
|
}
|
|
|
|
change.mark = patch.isDeleted() ? '-' : '+';
|
2020-12-05 21:50:45 +00:00
|
|
|
}
|
2020-12-04 16:30:26 +00:00
|
|
|
}
|
2020-12-18 23:53:21 +00:00
|
|
|
|
2021-10-22 14:11:03 +00:00
|
|
|
const head = await repo.getHeadCommit();
|
2020-12-18 23:53:21 +00:00
|
|
|
|
|
|
|
if (head && commitSha) {
|
|
|
|
const commit = await repo.getCommit(commitSha);
|
|
|
|
const commitTree = await commit.getTree();
|
|
|
|
|
|
|
|
const headTree = await head.getTree();
|
|
|
|
const diff = await headTree.diff(commitTree);
|
|
|
|
await pushChanges(diff);
|
|
|
|
}
|
|
|
|
|
2021-10-22 14:11:03 +00:00
|
|
|
await pushChanges(await this.getUnstaged(repo));
|
2021-10-23 13:20:35 +00:00
|
|
|
await pushChanges(await this.getStaged(repo));
|
2021-10-22 14:11:03 +00:00
|
|
|
|
|
|
|
return changes.sort((a, b) => {
|
|
|
|
if (b.mark != a.mark)
|
|
|
|
return b.mark == '-' ? 1 : -1;
|
|
|
|
return a.path.localeCompare(b.path);
|
|
|
|
|
2020-12-18 23:53:21 +00:00
|
|
|
});
|
2021-10-22 14:11:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async openRepo() {
|
|
|
|
const {opts} = this;
|
2020-12-18 23:53:21 +00:00
|
|
|
|
2021-10-22 14:11:03 +00:00
|
|
|
if (!await fs.pathExists(`${opts.workspace}/.git`))
|
|
|
|
throw new Error ('Git not initialized');
|
|
|
|
|
|
|
|
return await nodegit.Repository.open(opts.workspace);
|
|
|
|
}
|
|
|
|
|
|
|
|
async getStaged(repo) {
|
|
|
|
const head = await repo.getHeadCommit();
|
2020-12-18 23:53:21 +00:00
|
|
|
|
|
|
|
try {
|
|
|
|
const emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
|
|
|
|
const headTree = await (head
|
|
|
|
? head.getTree()
|
|
|
|
: nodegit.Tree.lookup(repo, emptyTree)
|
|
|
|
);
|
2021-10-22 14:11:03 +00:00
|
|
|
return await nodegit.Diff.treeToIndex(repo, headTree, null);
|
2020-12-18 23:53:21 +00:00
|
|
|
} catch (err) {
|
|
|
|
console.warn('Cannot fetch staged changes:', err.message);
|
|
|
|
}
|
2021-10-22 14:11:03 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async getUnstaged(repo) {
|
|
|
|
return await nodegit.Diff.indexToWorkdir(repo, null, {
|
|
|
|
flags: nodegit.Diff.OPTION.SHOW_UNTRACKED_CONTENT
|
|
|
|
| nodegit.Diff.OPTION.RECURSE_UNTRACKED_DIRS
|
|
|
|
});
|
2020-12-04 16:30:26 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async cachedChanges() {
|
2021-10-25 13:38:07 +00:00
|
|
|
const dumpDir = `${this.opts.myvcDir}/dump`;
|
2020-12-04 16:30:26 +00:00
|
|
|
const dumpChanges = `${dumpDir}/.changes`;
|
|
|
|
|
|
|
|
if (!await fs.pathExists(dumpChanges))
|
|
|
|
return null;
|
|
|
|
|
|
|
|
const readline = require('readline');
|
|
|
|
const rl = readline.createInterface({
|
|
|
|
input: fs.createReadStream(dumpChanges),
|
|
|
|
//output: process.stdout,
|
|
|
|
console: false
|
|
|
|
});
|
|
|
|
|
2020-12-05 21:50:45 +00:00
|
|
|
const changes = [];
|
2020-12-04 16:30:26 +00:00
|
|
|
for await (const line of rl) {
|
|
|
|
changes.push({
|
|
|
|
mark: line.charAt(0),
|
|
|
|
path: line.substr(1)
|
|
|
|
});
|
|
|
|
}
|
|
|
|
return changes;
|
|
|
|
}
|
2021-10-25 13:38:07 +00:00
|
|
|
|
|
|
|
showHelp(opts, usage, command) {
|
|
|
|
const prefix = `${'Usage:'.gray} [npx] myvc`;
|
|
|
|
|
|
|
|
if (command) {
|
|
|
|
let log = [prefix, command.blue];
|
|
|
|
if (usage.operand) log.push(`[<${usage.operand}>]`);
|
|
|
|
if (opts) log.push('[<options>]');
|
|
|
|
console.log(log.join(' '))
|
|
|
|
} else
|
|
|
|
console.log(`${prefix} [<options>] ${'<command>'.blue} [<args>]`);
|
|
|
|
|
|
|
|
if (usage.description)
|
|
|
|
console.log(`${'Description:'.gray} ${usage.description}`);
|
|
|
|
|
|
|
|
if (opts) {
|
|
|
|
console.log('Options:'.gray);
|
|
|
|
this.printOpts(opts, usage, 'alias');
|
|
|
|
this.printOpts(opts, usage, 'boolean');
|
|
|
|
this.printOpts(opts, usage, 'string');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
printOpts(opts, usage, group) {
|
|
|
|
const optGroup = opts[group];
|
|
|
|
if (optGroup)
|
|
|
|
for (const opt in optGroup) {
|
|
|
|
const paramDescription = usage.params[opt] || '';
|
|
|
|
let longOpt = opt;
|
|
|
|
if (group !== 'boolean') longOpt += ` <string>`;
|
|
|
|
longOpt = camelToSnake(longOpt).padEnd(22, ' ')
|
|
|
|
console.log(` -${optGroup[opt]}, --${longOpt} ${paramDescription}`);
|
|
|
|
}
|
|
|
|
}
|
2020-12-04 16:30:26 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
module.exports = MyVC;
|
|
|
|
|
|
|
|
if (require.main === module)
|
|
|
|
new MyVC().run();
|