#!/usr/bin/env node import fs from 'node:fs' import net from 'node:net' import { spawnSync } from 'node:child_process' import { createRequire } from 'node:module' import { devMysqlUrl } from '@drama/db' const DEFAULT_DATABASE_URL = devMysqlUrl('voko') const DEFAULT_ENV_FILE = 'apps/voko-server/.env' const DEFAULT_DATABASE = 'voko' const requireFromDb = createRequire(new URL('../../packages/db/package.json', import.meta.url)) const mysql = requireFromDb('mysql2/promise') const args = parseArgs(process.argv.slice(2)) const command = args._[0] || 'help' try { if (command === 'url') { const url = resolveBaseUrl(args) const derived = deriveDatabaseUrl(url, args.database || DEFAULT_DATABASE) console.log(args.raw ? derived : redactUrl(derived)) } else if (command === 'ensure') { const url = resolveBaseUrl(args) const database = args.database || databaseName(url) await ensureDatabase({ url, database }) } else if (command === 'up') { await up(args) } else if (command === 'status') { await status(args) } else if (command === 'stop') { runDocker(['compose', 'stop', 'mysql'], composeEnv(resolveBaseUrl(args))) } else { printHelp() } } catch (err) { console.error(`[mysql-dev] ${err instanceof Error ? err.message : String(err)}`) process.exit(1) } function parseArgs(argv) { const out = { _: [] } for (let i = 0; i < argv.length; i += 1) { const item = argv[i] if (!item.startsWith('--')) { out._.push(item) continue } const key = item.slice(2) const next = argv[i + 1] if (!next || next.startsWith('--')) { out[key] = true continue } out[key] = next i += 1 } return out } function resolveBaseUrl(args_) { return String(args_.url || readEnvDatabaseUrl(args_.env || DEFAULT_ENV_FILE) || DEFAULT_DATABASE_URL) } function readEnvDatabaseUrl(file) { if (!fs.existsSync(file)) return null const text = fs.readFileSync(file, 'utf8') const match = text.match(/^DATABASE_URL=['"]?([^'"\n]+)/m) return match ? match[1] : null } function databaseName(rawUrl) { const url = new URL(rawUrl) const name = url.pathname.replace(/^\//, '') if (!name) throw new Error(`DATABASE_URL missing database name: ${redactUrl(rawUrl)}`) return name } function deriveDatabaseUrl(rawUrl, database) { assertDatabaseName(database) const url = new URL(rawUrl) url.pathname = `/${database}` return url.toString() } async function ensureDatabase({ url, database }) { assertMysqlUrl(url) assertDatabaseName(database) const parsed = new URL(url) const conn = await mysql.createConnection({ host: parsed.hostname, port: parsed.port ? Number(parsed.port) : 3306, user: decodeURIComponent(parsed.username || ''), password: decodeURIComponent(parsed.password || ''), }) try { const [rows] = await conn.query('SHOW DATABASES LIKE ?', [database]) if (Array.isArray(rows) && rows.length > 0) { console.log(`[mysql-dev] database exists: ${database}`) return } await conn.query(`CREATE DATABASE \`${database}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`) console.log(`[mysql-dev] database created: ${database}`) } finally { await conn.end() } } async function up(args_) { const url = resolveBaseUrl(args_) const database = args_.database || databaseName(url) if (await canEnsure(url, database)) return if (await isPortOpen(url)) { throw new Error(`MySQL port is already open but credentials failed: ${redactUrl(url)}`) } runDocker(['compose', 'up', '-d', 'mysql'], composeEnv(url, database)) await waitUntilReady(url, database) } /** * docker-compose.yml 里 mysql 服务的口令 / 端口 / 初始库全部从这条 url 派生 —— * 连库的一方和建库的一方读同一个值,不会分叉。 * @param {string} rawUrl * @param {string} [database] */ function composeEnv(rawUrl, database) { const parsed = new URL(rawUrl) return { MYSQL_ROOT_PASSWORD: decodeURIComponent(parsed.password || ''), MYSQL_PORT: parsed.port || '3306', MYSQL_DATABASE: database || databaseName(rawUrl), } } async function status(args_) { const url = resolveBaseUrl(args_) const open = await isPortOpen(url) console.log(`[mysql-dev] ${redactUrl(url)} ${open ? 'listening' : 'not-listening'}`) } async function canEnsure(url, database) { try { await ensureDatabase({ url, database }) return true } catch { return false } } async function waitUntilReady(url, database) { let lastErr = null for (let i = 0; i < 30; i += 1) { try { await ensureDatabase({ url, database }) return } catch (err) { lastErr = err await new Promise((resolve) => setTimeout(resolve, 1000)) } } throw lastErr || new Error('MySQL did not become ready') } /** @param {string[]} args_ @param {Record} [composeEnv] compose 文件里 ${VAR} 的取值 */ function runDocker(args_, composeEnv = {}) { const res = spawnSync('docker', args_, { stdio: 'inherit', env: { ...process.env, ...composeEnv } }) if (res.status !== 0) throw new Error(`docker ${args_.join(' ')} failed`) } function isPortOpen(rawUrl) { const url = new URL(rawUrl) const port = url.port ? Number(url.port) : 3306 return new Promise((resolve) => { const socket = net.createConnection({ host: url.hostname, port }) socket.setTimeout(800) socket.once('connect', () => { socket.destroy() resolve(true) }) socket.once('timeout', () => { socket.destroy() resolve(false) }) socket.once('error', () => resolve(false)) }) } function assertMysqlUrl(rawUrl) { const scheme = new URL(rawUrl).protocol.replace(/:$/, '') if (scheme !== 'mysql' && scheme !== 'mysql2') { throw new Error(`Only mysql/mysql2 DATABASE_URL is supported for local dev: ${scheme}`) } } function assertDatabaseName(name) { if (!/^[A-Za-z0-9_]+$/.test(String(name || ''))) { throw new Error(`Invalid database name: ${name}`) } } function redactUrl(rawUrl) { try { const url = new URL(rawUrl) const user = url.username ? ':***@' : '' const port = url.port ? `:${url.port}` : '' return `${url.protocol}//${user}${url.hostname}${port}${url.pathname}` } catch { return '' } } function printHelp() { console.log(`Usage: node tools/scripts/mysql-dev.js up [--url DATABASE_URL] node tools/scripts/mysql-dev.js ensure [--url DATABASE_URL] [--database NAME] node tools/scripts/mysql-dev.js url [--url DATABASE_URL] [--database NAME] [--raw] node tools/scripts/mysql-dev.js status [--url DATABASE_URL] node tools/scripts/mysql-dev.js stop `) }