diff --git a/lib/apply-ddl.mjs b/lib/apply-ddl.mjs index fe14e34..bb556f6 100644 --- a/lib/apply-ddl.mjs +++ b/lib/apply-ddl.mjs @@ -1,64 +1,36 @@ +import { parseYamlConfig } from './yaml-config.mjs' + /** - * 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 + * Flatten config-vars.yaml's `database:` section into the DB_* env-shape that + * resolveDbConfig consumes. Pure; tolerates a missing section. * - * @param {string} text - * @returns {Record} + * @param {Record} config parsed config-vars.yaml + * @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 +export function dbEnvFromConfig(config) { + const db = (config && config.database) || {} + return { + DB_HOST: db.host, + DB_PORT: db.port != null ? String(db.port) : undefined, + DB_USER: db.user, + DB_PASSWORD: db.password, + DB_SCHEMA: db.schema, } - return env } /** * Apply a DDL file to a MySQL database using mysql2/promise. + * DB credentials are read from config-vars.yaml's `database:` section. * - * @param {{envPath: string, ddlPath: string}} opts + * @param {{configPath: string, ddlPath: string}} opts * @returns {Promise} */ -export async function applyDDL({ envPath, ddlPath }) { +export async function applyDDL({ configPath, ddlPath }) { const { readFileSync } = await import('node:fs') - const env = parseEnv(readFileSync(envPath, 'utf8')) + const env = dbEnvFromConfig(parseYamlConfig(readFileSync(configPath, 'utf8'))) const ddl = readFileSync(ddlPath, 'utf8') - const { host, port, user, password, database } = resolveDbConfig(env, envPath) - assertSafeDbTarget({ host, database, env, label: 'apply-ddl' }) + const { host, port, user, password, database } = resolveDbConfig(env, configPath) let mysql try { @@ -89,49 +61,24 @@ export async function applyDDL({ envPath, ddlPath }) { * 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 + * @param {string} [cfgPath] 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') { +export function resolveDbConfig(env, cfgPath = 'config-vars.yaml') { 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)`) + throw new Error(`apply-ddl: 缺数据库名 — 请在 ${cfgPath} 的 database.schema 填写`) } if (!Number.isInteger(port) || port <= 0 || port > 65535) { - throw new Error(`apply-ddl: DB_PORT 非法 — ${envPath} 中端口必须是 1..65535 的整数`) + throw new Error(`apply-ddl: 端口非法 — ${cfgPath} 的 database.port 必须是 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() { @@ -140,17 +87,17 @@ export class MysqlUnavailableError extends Error { } } -// CLI entry guard (see render.mjs for pathToFileURL rationale) +// CLI entry guard:pathToFileURL 规范化 argv[1] 以匹配 import.meta.url(路径含空格 / 非 ASCII / Windows 反斜杠时字面比较会失配) 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 ') + const [configPath, ddlPath] = process.argv.slice(2) + if (!configPath || !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}`) + await applyDDL({ configPath, ddlPath }) + console.log(`apply-ddl: applied ${ddlPath} using ${configPath}`) } catch (e) { if (e instanceof MysqlUnavailableError) { console.error('apply-ddl: mysql2 not found. Please run `npm i mysql2` in the target project.') diff --git a/lib/apply-ddl.test.mjs b/lib/apply-ddl.test.mjs index 7bc2b90..c7e816c 100644 --- a/lib/apply-ddl.test.mjs +++ b/lib/apply-ddl.test.mjs @@ -1,59 +1,29 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { assertSafeDbTarget, parseEnv, resolveDbConfig } from './apply-ddl.mjs' +import { dbEnvFromConfig, resolveDbConfig } from './apply-ddl.mjs' -test('parseEnv ignores comments, trims, keeps special chars literally', () => { - const env = parseEnv('# c\nDB_PASS=p@ss$word!\nDB_NAME = erp \n') - assert.equal(env.DB_PASS, 'p@ss$word!') - assert.equal(env.DB_NAME, 'erp') +// ── dbEnvFromConfig(config-vars.yaml database: → DB_* env-shape adapter)── +test('dbEnvFromConfig maps the database section to the DB_* shape', () => { + const env = dbEnvFromConfig({ + database: { host: 'db.local', port: 3307, user: 'u', password: 'p@ss', schema: 'erp_test' }, + }) + assert.equal(env.DB_HOST, 'db.local') + assert.equal(env.DB_PORT, '3307') // coerced to string for resolveDbConfig + assert.equal(env.DB_USER, 'u') + assert.equal(env.DB_PASSWORD, 'p@ss') + assert.equal(env.DB_SCHEMA, 'erp_test') }) -test('parseEnv does NOT expand variables', () => { - const env = parseEnv('A=1\nB=${A}\nC=$A\nD=$(whoami)\nE=`id`\n') - assert.equal(env.B, '${A}') - assert.equal(env.C, '$A') - assert.equal(env.D, '$(whoami)') - assert.equal(env.E, '`id`') +test('dbEnvFromConfig tolerates a missing/empty config', () => { + assert.equal(dbEnvFromConfig({}).DB_HOST, undefined) + assert.equal(dbEnvFromConfig(null).DB_SCHEMA, undefined) }) -test('parseEnv skips blank lines and comment lines', () => { - const env = parseEnv('\n \n# comment\nK=v\n # indented comment\n') - assert.deepEqual(Object.keys(env), ['K']) - assert.equal(env.K, 'v') -}) - -test('parseEnv strips one layer of matching quotes', () => { - const env = parseEnv(`Q1="hello world"\nQ2='a=b=c'\nQ3="$keep"\n`) - assert.equal(env.Q1, 'hello world') - assert.equal(env.Q2, 'a=b=c') - assert.equal(env.Q3, '$keep') -}) - -test('parseEnv keeps = signs inside the value', () => { - const env = parseEnv('URL=mysql://u:p@h:3306/db?x=1&y=2\n') - assert.equal(env.URL, 'mysql://u:p@h:3306/db?x=1&y=2') -}) - -test('parseEnv strips an optional leading export', () => { - const env = parseEnv('export DB_USER=root\n') - assert.equal(env.DB_USER, 'root') -}) - -test('parseEnv tolerates CRLF line endings', () => { - const env = parseEnv('A=1\r\nB=2\r\n') - assert.equal(env.A, '1') - assert.equal(env.B, '2') -}) - -test('parseEnv ignores lines without an = sign', () => { - const env = parseEnv('NOEQUALS\nK=v\n') - assert.deepEqual(Object.keys(env), ['K']) -}) - -test('parseEnv on empty / non-string input returns empty object', () => { - assert.deepEqual(parseEnv(''), {}) - assert.deepEqual(parseEnv(undefined), {}) - assert.deepEqual(parseEnv(null), {}) +test('dbEnvFromConfig → resolveDbConfig round-trips a filled database section', () => { + const c = resolveDbConfig(dbEnvFromConfig({ database: { host: 'localhost', port: 3306, schema: 'erp_dev' } })) + assert.equal(c.host, 'localhost') + assert.equal(c.port, 3306) + assert.equal(c.database, 'erp_dev') }) // ── resolveDbConfig(M1:DB_SCHEMA 是插件契约的 schema 键)───────── @@ -74,7 +44,7 @@ test('resolveDbConfig honors DB_NAME / MYSQL_DATABASE aliases', () => { }) test('resolveDbConfig fails closed when no schema key is present (M1)', () => { - assert.throws(() => resolveDbConfig({ DB_USER: 'root' }, '.env.local'), /DB_SCHEMA/) + assert.throws(() => resolveDbConfig({ DB_USER: 'root' }, 'config-vars.yaml'), /database\.schema/) }) test('resolveDbConfig applies sane defaults for host/port/user/password', () => { @@ -86,33 +56,6 @@ test('resolveDbConfig applies sane defaults for host/port/user/password', () => }) test('resolveDbConfig rejects invalid ports', () => { - assert.throws(() => resolveDbConfig({ DB_SCHEMA: 'erp_test', DB_PORT: 'abc' }), /DB_PORT/) - assert.throws(() => resolveDbConfig({ DB_SCHEMA: 'erp_test', DB_PORT: '70000' }), /DB_PORT/) -}) - -test('assertSafeDbTarget allows local and explicitly allowlisted test targets', () => { - assert.equal(assertSafeDbTarget({ host: 'localhost', database: 'erp_test' }), true) - assert.equal( - assertSafeDbTarget({ - host: 'mysql.dev.internal', - database: 'erp_dev', - env: { TEST_DB_ALLOWED_HOSTS: 'mysql.dev.internal' }, - }), - true - ) -}) - -test('assertSafeDbTarget rejects prod-looking or injectable targets', () => { - assert.throws( - () => assertSafeDbTarget({ host: 'prod.db.internal', database: 'erp_test' }), - /非白名单 host/ - ) - assert.throws( - () => assertSafeDbTarget({ host: 'localhost', database: 'erp_prod' }), - /不像测试\/开发库/ - ) - assert.throws( - () => assertSafeDbTarget({ host: 'localhost', database: 'erp_test`; DROP DATABASE prod; --' }), - /只能包含/ - ) + assert.throws(() => resolveDbConfig({ DB_SCHEMA: 'erp_test', DB_PORT: 'abc' }), /database\.port/) + assert.throws(() => resolveDbConfig({ DB_SCHEMA: 'erp_test', DB_PORT: '70000' }), /database\.port/) }) diff --git a/lib/merge-gitignore.mjs b/lib/merge-gitignore.mjs index 131969e..b2156aa 100644 --- a/lib/merge-gitignore.mjs +++ b/lib/merge-gitignore.mjs @@ -23,7 +23,7 @@ export function mergeGitignore(baseText, addText) { return text } -const { pathToFileURL } = await import('node:url') // CLI entry guard (see render.mjs) +const { pathToFileURL } = await import('node:url') // CLI entry guard:pathToFileURL 规范化 argv[1] 以匹配 import.meta.url(路径含空格 / 非 ASCII / Windows 反斜杠时字面比较会失配) if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const [basePath, addPath] = process.argv.slice(2) const { readFileSync, writeFileSync } = await import('node:fs') diff --git a/lib/render.mjs b/lib/render.mjs deleted file mode 100644 index 69054ea..0000000 --- a/lib/render.mjs +++ /dev/null @@ -1,24 +0,0 @@ -// lib/render.mjs — literal-safe template render (replaces scope-lock/render.sh) -// -// 核心要求:{{key}} 占位替换;值中含 $、{、}、}} 不被二次解释(字面插入); -// 先剥离 HTML 注释(模板引导文本);缺少变量则 throw(不静默留空)。 -export function render(template, vars) { - const withoutComments = template.replace(//g, '') - return withoutComments.replace(/\{\{(\w+)\}\}/g, (_, key) => { - // 用 Object.hasOwn 而非 `key in vars`:避免 {{constructor}} / {{toString}} 等 - // 沿原型链命中继承属性、静默渲染出垃圾(应按"缺变量"抛错)。 - if (!Object.hasOwn(vars, key)) throw new Error(`render: missing var "${key}"`) - return String(vars[key]) // 字面插入,不二次解释 $ 或 {} - }) -} - -// 入口判定用 pathToFileURL 规范化 process.argv[1],使其与 import.meta.url 编码一致 -// (路径含空格/非 ASCII/Windows 反斜杠时,字面 `file://${argv[1]}` 比较会失配)。 -const { pathToFileURL } = await import('node:url') -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - const { readFileSync, writeFileSync } = await import('node:fs') - const [tplPath, jsonPath, outPath] = process.argv.slice(2) - const tpl = readFileSync(tplPath, 'utf8') - const vars = JSON.parse(readFileSync(jsonPath, 'utf8')) - writeFileSync(outPath, render(tpl, vars)) -} diff --git a/lib/render.test.mjs b/lib/render.test.mjs deleted file mode 100644 index 4623d8b..0000000 --- a/lib/render.test.mjs +++ /dev/null @@ -1,23 +0,0 @@ -import { test } from 'node:test' -import assert from 'node:assert/strict' -import { render } from './render.mjs' - -test('replaces placeholders', () => { - assert.equal(render('Hi {{name}}', { name: 'Al' }), 'Hi Al') -}) -test('value containing $ and braces is inserted literally', () => { - assert.equal(render('v={{x}}', { x: '${FOO} a{b}c }}' }), 'v=${FOO} a{b}c }}') -}) -test('strips HTML comments used as template guides', () => { - assert.equal(render('ab', {}), 'ab') -}) -test('missing key throws (no silent blank)', () => { - assert.throws(() => render('{{missing}}', {}), /missing/) -}) -test('inherited prototype keys are treated as missing (not silently rendered)', () => { - // {{constructor}} / {{toString}} 不应沿原型链命中继承函数渲染出垃圾 - assert.throws(() => render('{{constructor}}', {}), /missing var "constructor"/) - assert.throws(() => render('{{toString}}', {}), /missing var "toString"/) - // 但 own 属性即便名为 constructor 也应正常渲染 - assert.equal(render('{{constructor}}', { constructor: 'X' }), 'X') -}) diff --git a/lib/validate-ddl.mjs b/lib/validate-ddl.mjs index 196200e..20f33c3 100644 --- a/lib/validate-ddl.mjs +++ b/lib/validate-ddl.mjs @@ -467,7 +467,7 @@ export function formatDiff(diff) { return out.join('\n') } -const { pathToFileURL } = await import('node:url') // CLI entry guard (see render.mjs) +const { pathToFileURL } = await import('node:url') // CLI entry guard:pathToFileURL 规范化 argv[1] 以匹配 import.meta.url(路径含空格 / 非 ASCII / Windows 反斜杠时字面比较会失配) const isCliEntry = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href if (isCliEntry) { const { readFileSync, existsSync } = await import('node:fs') diff --git a/lib/yaml-config.mjs b/lib/yaml-config.mjs new file mode 100644 index 0000000..13f31b2 --- /dev/null +++ b/lib/yaml-config.mjs @@ -0,0 +1,74 @@ +// lib/yaml-config.mjs — minimal YAML reader for config-vars.yaml. +// +// Scope is intentionally tiny: config-vars.yaml is exactly two levels deep +// (top-level section header `key:` → 2-space-indented `key: value` scalars). +// We deliberately support NO lists, NO flow style ([], {}), NO anchors and +// NO multiline — config-vars.yaml uses none of them, so a parser that handled +// them would be untested surface. Mirrors the zero-dependency, single-purpose +// spirit of the dotenv parser this replaces. +// +// Value rules (same philosophy as the old dotenv parser): +// - a single layer of matching surrounding quotes ('…' or "…") is removed, +// and anything after the closing quote (e.g. a trailing ` # comment`) is dropped +// - an unquoted value is cut at the first ` #` (space-hash) inline comment +// - a value that is empty or starts with `#` is treated as empty +// - NO variable expansion: `$FOO`, `${FOO}`, backticks stay literal +// - numbers stay strings (callers Number() them, as with dotenv) + +/** Parse one `key:`-stripped raw value into its literal string. */ +export function parseScalar(raw) { + let s = String(raw).trim() + if (s === '' || s[0] === '#') return '' + const q = s[0] + if (q === '"' || q === "'") { + const end = s.indexOf(q, 1) + if (end !== -1) return s.slice(1, end) // ignore anything after the closing quote + // no closing quote → fall through and treat the (still-quoted) text literally + } + const hash = s.indexOf(' #') + if (hash !== -1) s = s.slice(0, hash).trim() + return s +} + +/** + * Parse config-vars.yaml text into a nested plain object. + * + * Top-level `section:` with no value opens a mapping; subsequent indented + * `key: value` lines attach to it. A top-level `key: value` stays a root scalar. + * + * @param {string} text + * @returns {Record} + */ +export function parseYamlConfig(text) { + const root = {} + if (typeof text !== 'string') return root + let section = null + for (const rawLine of text.split('\n')) { + const line = rawLine.replace(/\r$/, '') // tolerate CRLF + const trimmed = line.trim() + if (trimmed === '' || trimmed[0] === '#') continue // blank / full-line comment + + const colon = line.indexOf(':') + if (colon === -1) continue // not a key: line; ignore + + const key = line.slice(0, colon).trim() + if (key === '') continue + const indent = line.length - line.replace(/^\s+/, '').length + const value = parseScalar(line.slice(colon + 1)) + + if (indent === 0) { + if (value === '') { + section = {} + root[key] = section + } else { + root[key] = value + section = null + } + } else if (section) { + section[key] = value + } else { + root[key] = value // indented line with no open section — tolerate as root scalar + } + } + return root +} diff --git a/lib/yaml-config.test.mjs b/lib/yaml-config.test.mjs new file mode 100644 index 0000000..504ba8b --- /dev/null +++ b/lib/yaml-config.test.mjs @@ -0,0 +1,77 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { parseScalar, parseYamlConfig } from './yaml-config.mjs' + +test('parseScalar strips one layer of matching quotes', () => { + assert.equal(parseScalar(' "hello world" '), 'hello world') + assert.equal(parseScalar("'a: b: c'"), 'a: b: c') +}) + +test('parseScalar drops a trailing inline comment after a quoted value', () => { + assert.equal(parseScalar("'' # default empty"), '') + assert.equal(parseScalar("'mysql.dev' # remote test host"), 'mysql.dev') +}) + +test('parseScalar cuts an unquoted value at a space-hash comment', () => { + assert.equal(parseScalar('8080 # default port'), '8080') + assert.equal(parseScalar('com.acme.erp'), 'com.acme.erp') +}) + +test('parseScalar treats empty / comment-only values as empty', () => { + assert.equal(parseScalar(''), '') + assert.equal(parseScalar(' '), '') + assert.equal(parseScalar('# just a comment'), '') +}) + +test('parseScalar does NOT expand variables and keeps special chars literally', () => { + assert.equal(parseScalar('${A}'), '${A}') + assert.equal(parseScalar('$(whoami)'), '$(whoami)') + assert.equal(parseScalar("'p@ss$w0rd!'"), 'p@ss$w0rd!') + // a `#` not preceded by a space stays part of an unquoted value + assert.equal(parseScalar('a#b'), 'a#b') +}) + +test('parseYamlConfig builds a two-level nested object', () => { + const cfg = parseYamlConfig( + [ + 'backend:', + ' base_package: com.acme.erp', + ' http_port: 8080', + 'database:', + ' host: 127.0.0.1', + ' schema: erp_dev', + '', + ].join('\n') + ) + assert.deepEqual(cfg.backend, { base_package: 'com.acme.erp', http_port: '8080' }) + assert.deepEqual(cfg.database, { host: '127.0.0.1', schema: 'erp_dev' }) +}) + +test('parseYamlConfig skips blank lines, full-line and indented comments', () => { + const cfg = parseYamlConfig( + ['# top comment', 'database:', ' # inline section comment', ' host: localhost', '', ' '].join('\n') + ) + assert.deepEqual(cfg.database, { host: 'localhost' }) +}) + +test('parseYamlConfig keeps a quoted empty value and ignores its trailing comment', () => { + const cfg = parseYamlConfig(['database:', " password: '' # default empty"].join('\n')) + assert.equal(cfg.database.password, '') +}) + +test('parseYamlConfig only splits on the first colon (values may contain colons)', () => { + const cfg = parseYamlConfig(['secrets:', ' jwt_secret: a:b:c'].join('\n')) + assert.equal(cfg.secrets.jwt_secret, 'a:b:c') +}) + +test('parseYamlConfig tolerates CRLF and returns {} for non-string input', () => { + const cfg = parseYamlConfig('backend:\r\n http_port: 9090\r\n') + assert.equal(cfg.backend.http_port, '9090') + assert.deepEqual(parseYamlConfig(undefined), {}) + assert.deepEqual(parseYamlConfig(null), {}) +}) + +test('parseYamlConfig supports a top-level scalar (no section)', () => { + const cfg = parseYamlConfig('project_name: Acme ERP\n') + assert.equal(cfg.project_name, 'Acme ERP') +})