import { fileURLToPath } from 'node:url'; import fs from 'node:fs'; import { program } from 'commander'; import { confirm } from '@inquirer/prompts'; import { dockerCommand } from 'docker-cli-js'; import mysql from 'mysql2'; import config from './config.js'; const LDD_ROOT_PATH = fileURLToPath(new URL('..', import.meta.url)); interface DockerImagesCommandResult { images: { repository: string; tag: string; 'image id': string; created: string; size: string; }[]; } const dockerCompose: typeof dockerCommand = async (command, options) => { try { return await dockerCommand( `compose --file "${LDD_ROOT_PATH}/docker/docker-compose.yml" --project-name "ldd" ${command}`, { echo: false, ...(options ?? {}) }, ); } catch (e: any) { if (e.stderr === undefined) { throw e; } console.error(`ERROR: ${e.stderr}`); process.exit(1); } }; const execQuery = (query: string, database: string = 'defaultdb') => { return new Promise((resolve) => { const connection = mysql.createConnection({ host: '127.0.0.1', port: Number(process.env.LDD_DB_PORT ?? '3306'), user: 'root', password: process.env.LDD_DB_ROOT_PASSWORD ?? 'not-secure-pwd', database, multipleStatements: true, }); connection.connect((error) => { if (error) { if (error.code === 'ECONNREFUSED') { console.error(`ERROR: Could't connect to the DB server. Did you forget to start it?`); process.exit(1); } throw error; } }); connection.query(query, (error, results) => { if (error) { console.error(`ERROR: ${error.message}`); process.exit(1); } resolve(results); }); connection.end(); }); }; program.name('ldd').description(config.packageInfo.description).version(config.packageInfo.version); program .command('start') .description('Starts your local DB server') .action(async () => { console.info('Starting local database containers...'); const requiredImages = [ `mysql:${process.env.LDD_DB_IMAGE_TAG ?? 'lts'}`, `phpmyadmin:${process.env.LDD_PMA_IMAGE_TAG ?? 'latest'}`, ]; try { const availableImagesImages = ((await dockerCommand('images', { echo: false })) as DockerImagesCommandResult).images .map((imageData) => `${imageData.repository}:${imageData.tag}`) .filter((imageName) => requiredImages.includes(imageName)); const missingImages = requiredImages.filter((requiredImage) => !availableImagesImages.includes(requiredImage)); if (missingImages.length > 0) { console.info(''); console.info('The following images will be downloaded as they are required but not available:'); missingImages.map((image) => console.info(` - ${image}`)); console.info(''); console.info('This may take some time, please wait...'); } } catch (e: any) { if (e.stderr === undefined) { throw e; } console.error(`ERROR: ${e.stderr}`); process.exit(1); } await dockerCompose('up -d'); console.info(''); console.info('Done!'); console.info(`A PhpMyAdmin instance is running on: http://127.0.0.1:${process.env.LDD_PMA_PORT ?? 8010}`); }); program .command('stop') .description('Stops your local DB server') .action(async () => { console.info('Stopping local database containers...'); await dockerCompose('down'); }); program .command('destroy') .description('Stops all containers (if running) and deletes any related volumes') .action(async () => { const confirmation = await confirm({ message: 'This action will delete all of your data and cannot be reverted. Are you sure?', default: false, }); if (confirmation !== true) { console.info('Aborting...'); process.exit(0); } console.info('Destroying local database containers...'); await dockerCompose('down -v'); }); program .command('create') .description('Creates a new database') .argument(config.dbName !== undefined ? '[db_name]' : '', 'The database name', config.dbName) .action(async (databaseName) => { const username = databaseName; const userPwd = `${databaseName}-pwd`; console.info(`Creating a new DB named "${databaseName}"...`); await execQuery( `START TRANSACTION; CREATE DATABASE \`${databaseName}\`; CREATE USER '${username}'@'%' IDENTIFIED BY '${userPwd}'; GRANT ALL ON \`${databaseName}\`.* TO '${username}'@'%'; FLUSH PRIVILEGES; COMMIT;`, ); console.info(`A new user has been created with full permissions on "${databaseName}".`); console.info(''); console.info(`Username: ${username}`); console.info(`Password: ${userPwd}`); }); program .command('drop') .description('Drops the given database and its default user (if they exist)') .argument(config.dbName !== undefined ? '[db_name]' : '', 'The database name', config.dbName) .option('-f,--force', 'Skip safety confirmation', false) .action(async (databaseName, options) => { const username = databaseName; const userPwd = `${databaseName}-pwd`; const confirmation = options.force === true || (await confirm({ message: `This action will delete your database "${databaseName}" and cannot be reverted. Are you sure?`, default: false, })); if (confirmation !== true) { console.info('Aborting...'); process.exit(0); } console.info(`Dropping DB "${databaseName}" and its default user...`); await execQuery(`DROP DATABASE IF EXISTS \`${databaseName}\`; DROP USER IF EXISTS \`${databaseName}\`;`); }); program .command('dump-all') .description('Creates a SQL dump file of all databases') .action(async () => { const now = new Date(); const month = now.getMonth().toString().padStart(2, '0'); const date = now.getDate().toString().padStart(2, '0'); const hours = now.getHours().toString().padStart(2, '0'); const minutes = now.getMinutes().toString().padStart(2, '0'); const seconds = now.getSeconds().toString().padStart(2, '0'); const dumpFileName = `db-full-dump_${now.getFullYear()}-${month}-${date}_${hours}-${minutes}-${seconds}.sql`; console.info(`Exporting all databases to "${dumpFileName}"...`); fs.writeFileSync( `./${dumpFileName}`, (await dockerCompose('exec db sh -c \'exec mysqldump --all-databases --lock-tables -uroot -p"$MYSQL_ROOT_PASSWORD"\'')) .raw, ); }); program .command('dump') .description('Creates a SQL dump file of the given database') .argument(config.dbName !== undefined ? '[db_name]' : '', 'The database name', config.dbName) .action(async (databaseName) => { const now = new Date(); const month = now.getMonth().toString().padStart(2, '0'); const date = now.getDate().toString().padStart(2, '0'); const hours = now.getHours().toString().padStart(2, '0'); const minutes = now.getMinutes().toString().padStart(2, '0'); const seconds = now.getSeconds().toString().padStart(2, '0'); const dumpFileName = `db-${databaseName}-dump_${now.getFullYear()}-${month}-${date}_${hours}-${minutes}-${seconds}.sql`; console.info(`Exporting database to "${dumpFileName}"...`); fs.writeFileSync( `./${dumpFileName}`, ( await dockerCompose( `exec db sh -c \'exec mysqldump --databases "${databaseName}" --lock-tables -uroot -p"$MYSQL_ROOT_PASSWORD"\'`, ) ).raw, ); }); program .command('import') .description('Runs all queries from the given SQL file') .argument('', 'The SQL file to import') .option('-f,--force', 'Skip safety confirmation', false) .action(async (sqlFilePath, options) => { const confirmation = options.force === true || (await confirm({ message: 'This action will execute any SQL statement found in the given file and cannot be reverted. Are you sure?', default: false, })); if (confirmation !== true) { console.info('Aborting...'); process.exit(0); } console.info(`Importing data from "${sqlFilePath}"...`); if (!sqlFilePath.endsWith('.sql') || !fs.existsSync(sqlFilePath) || !fs.statSync(sqlFilePath).isFile()) { console.error(`ERROR: Invalid SQL file`); process.exit(1); } execQuery(fs.readFileSync(sqlFilePath).toString()); console.info('Done. Remember you might have to create dedicated users in order to access new databases.'); }); program.parse();