/** * Parse dotenv-style text into a plain object. * * Rules: * - one `KEY=VALUE` per line * - blank lines and full-line comments (first non-space char is `#`) are skipped * - an optional leading `export ` is stripped * - key and value are trimmed * - a single layer of matching surrounding quotes ('...' or "...") is removed * - NO variable expansion: `$FOO`, `${FOO}`, `$(...)`, backticks stay literal * * @param {string} text * @returns {Record} */ export function parseEnv(text) { const env = {} if (typeof text !== 'string') return env for (const rawLine of text.split('\n')) { let line = rawLine.replace(/\r$/, '') // tolerate CRLF const trimmed = line.trim() if (trimmed === '' || trimmed.startsWith('#')) continue // strip an optional `export ` prefix (off the trimmed-left view) let body = line.replace(/^\s*export\s+/, '') const eq = body.indexOf('=') if (eq === -1) continue // not a KEY=VALUE line; ignore const key = body.slice(0, eq).trim() if (key === '') continue let value = body.slice(eq + 1).trim() // remove one layer of matching surrounding quotes, if present. if ( value.length >= 2 && ((value[0] === '"' && value[value.length - 1] === '"') || (value[0] === "'" && value[value.length - 1] === "'")) ) { value = value.slice(1, -1) } // NOTE: no variable expansion is performed — value is inserted literally. env[key] = value } return env } /** * Apply a DDL file to a MySQL database using mysql2/promise. * * @param {{envPath: string, ddlPath: string}} opts * @returns {Promise} */ export async function applyDDL({ envPath, ddlPath }) { const { readFileSync } = await import('node:fs') const env = parseEnv(readFileSync(envPath, 'utf8')) const ddl = readFileSync(ddlPath, 'utf8') const { host, port, user, password, database } = resolveDbConfig(env, envPath) assertSafeDbTarget({ host, database, env, label: 'apply-ddl' }) let mysql try { ;({ default: mysql } = await import('mysql2/promise')) } catch { throw new MysqlUnavailableError() } const conn = await mysql.createConnection({ host, port, user, password, database, multipleStatements: true, }) try { await conn.query(ddl) } finally { await conn.end() } } /** * Resolve mysql2 connection settings from a parsed env object. Pure (no I/O), * so it is unit-testable without mysql2 installed. * * Throws if no schema resolves — V1 has no USE/CREATE DATABASE. * * @param {Record} env * @param {string} [envPath] only used to make the error message actionable * @returns {{host:string, port:number, user:string, password:string, database:string}} */ export function resolveDbConfig(env, envPath = '.env.local') { const host = env.DB_HOST || env.MYSQL_HOST || '127.0.0.1' const port = Number(env.DB_PORT || env.MYSQL_PORT || 3306) const user = env.DB_USER || env.MYSQL_USER || 'root' const password = env.DB_PASS || env.DB_PASSWORD || env.MYSQL_PASSWORD || '' const database = env.DB_SCHEMA || env.DB_NAME || env.MYSQL_DATABASE || undefined if (!database) { throw new Error(`apply-ddl: 缺数据库名 — 请在 ${envPath} 设置 DB_SCHEMA(或 DB_NAME / MYSQL_DATABASE)`) } if (!Number.isInteger(port) || port <= 0 || port > 65535) { throw new Error(`apply-ddl: DB_PORT 非法 — ${envPath} 中端口必须是 1..65535 的整数`) } return { host, port, user, password, database } } /** * Fail closed for direct DDL application. setup-test-db.mjs has the same guard * before DROP+CREATE; apply-ddl repeats it so direct CLI use cannot hit prod. * * @param {{host:string, database:string, env?:Record, label?:string}} opts * @returns {true} */ export function assertSafeDbTarget({ host, database, env = {}, label = 'apply-ddl' }) { const extraHosts = String(env.TEST_DB_ALLOWED_HOSTS || '') .split(/[\s,]+/) .filter(Boolean) const allowedHosts = ['localhost', '127.0.0.1', '::1', ...extraHosts] if (!allowedHosts.includes(host)) { throw new Error(`${label}: 拒绝连接非白名单 host (${host});如确认是测试库,请在 .env.local 设置 TEST_DB_ALLOWED_HOSTS`) } if (!/^[A-Za-z0-9_]+$/.test(database)) { throw new Error(`${label}: DB_SCHEMA 只能包含字母、数字、下划线,当前为 ${JSON.stringify(database)}`) } const looksLikeTest = /test/i.test(database) || /_dev$/i.test(database) || /_local$/i.test(database) || /_ci$/i.test(database) if (!looksLikeTest) { throw new Error(`${label}: schema '${database}' 不像测试/开发库(需含 test 或以 _dev/_local/_ci 结尾)`) } return true } /** Distinct error type so the CLI can emit a friendly install hint. */ export class MysqlUnavailableError extends Error { constructor() { super('mysql2 is not installed') this.name = 'MysqlUnavailableError' } } // CLI entry guard (see render.mjs for pathToFileURL rationale) const { pathToFileURL } = await import('node:url') if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const [envPath, ddlPath] = process.argv.slice(2) if (!envPath || !ddlPath) { console.error('usage: node lib/apply-ddl.mjs ') process.exit(2) } try { await applyDDL({ envPath, ddlPath }) console.log(`apply-ddl: applied ${ddlPath} using ${envPath}`) } catch (e) { if (e instanceof MysqlUnavailableError) { console.error('apply-ddl: mysql2 not found. Please run `npm i mysql2` in the target project.') process.exit(1) } console.error(`apply-ddl: failed — ${e?.message || e}`) process.exit(1) } }