Commit 5babb654816cf736cac5d4c5743b94552fedaac0
1 parent
32a0adb2
lib: replace render.mjs with yaml-config; apply-ddl reads config-vars.yaml, drop DB guards
- remove render.mjs/render.test.mjs (scope-lock now writes REQ cards directly) - add yaml-config.mjs: minimal 2-level YAML reader (+ tests) - apply-ddl: parse config-vars.yaml database: section instead of .env.local dotenv; drop assertSafeDbTarget host-whitelist + schema guards (config-vars.yaml is trusted) - merge-gitignore/validate-ddl: self-contained pathToFileURL comment (no longer 'see render.mjs')
Showing
8 changed files
with
204 additions
and
210 deletions
lib/apply-ddl.mjs
| 1 | +import { parseYamlConfig } from './yaml-config.mjs' | |
| 2 | + | |
| 1 | 3 | /** |
| 2 | - * Parse dotenv-style text into a plain object. | |
| 3 | - * | |
| 4 | - * Rules: | |
| 5 | - * - one `KEY=VALUE` per line | |
| 6 | - * - blank lines and full-line comments (first non-space char is `#`) are skipped | |
| 7 | - * - an optional leading `export ` is stripped | |
| 8 | - * - key and value are trimmed | |
| 9 | - * - a single layer of matching surrounding quotes ('...' or "...") is removed | |
| 10 | - * - NO variable expansion: `$FOO`, `${FOO}`, `$(...)`, backticks stay literal | |
| 4 | + * Flatten config-vars.yaml's `database:` section into the DB_* env-shape that | |
| 5 | + * resolveDbConfig consumes. Pure; tolerates a missing section. | |
| 11 | 6 | * |
| 12 | - * @param {string} text | |
| 13 | - * @returns {Record<string, string>} | |
| 7 | + * @param {Record<string, any>} config parsed config-vars.yaml | |
| 8 | + * @returns {Record<string, string|undefined>} | |
| 14 | 9 | */ |
| 15 | -export function parseEnv(text) { | |
| 16 | - const env = {} | |
| 17 | - if (typeof text !== 'string') return env | |
| 18 | - for (const rawLine of text.split('\n')) { | |
| 19 | - let line = rawLine.replace(/\r$/, '') // tolerate CRLF | |
| 20 | - const trimmed = line.trim() | |
| 21 | - if (trimmed === '' || trimmed.startsWith('#')) continue | |
| 22 | - | |
| 23 | - // strip an optional `export ` prefix (off the trimmed-left view) | |
| 24 | - let body = line.replace(/^\s*export\s+/, '') | |
| 25 | - | |
| 26 | - const eq = body.indexOf('=') | |
| 27 | - if (eq === -1) continue // not a KEY=VALUE line; ignore | |
| 28 | - | |
| 29 | - const key = body.slice(0, eq).trim() | |
| 30 | - if (key === '') continue | |
| 31 | - | |
| 32 | - let value = body.slice(eq + 1).trim() | |
| 33 | - | |
| 34 | - // remove one layer of matching surrounding quotes, if present. | |
| 35 | - if ( | |
| 36 | - value.length >= 2 && | |
| 37 | - ((value[0] === '"' && value[value.length - 1] === '"') || | |
| 38 | - (value[0] === "'" && value[value.length - 1] === "'")) | |
| 39 | - ) { | |
| 40 | - value = value.slice(1, -1) | |
| 41 | - } | |
| 42 | - | |
| 43 | - // NOTE: no variable expansion is performed — value is inserted literally. | |
| 44 | - env[key] = value | |
| 10 | +export function dbEnvFromConfig(config) { | |
| 11 | + const db = (config && config.database) || {} | |
| 12 | + return { | |
| 13 | + DB_HOST: db.host, | |
| 14 | + DB_PORT: db.port != null ? String(db.port) : undefined, | |
| 15 | + DB_USER: db.user, | |
| 16 | + DB_PASSWORD: db.password, | |
| 17 | + DB_SCHEMA: db.schema, | |
| 45 | 18 | } |
| 46 | - return env | |
| 47 | 19 | } |
| 48 | 20 | |
| 49 | 21 | /** |
| 50 | 22 | * Apply a DDL file to a MySQL database using mysql2/promise. |
| 23 | + * DB credentials are read from config-vars.yaml's `database:` section. | |
| 51 | 24 | * |
| 52 | - * @param {{envPath: string, ddlPath: string}} opts | |
| 25 | + * @param {{configPath: string, ddlPath: string}} opts | |
| 53 | 26 | * @returns {Promise<void>} |
| 54 | 27 | */ |
| 55 | -export async function applyDDL({ envPath, ddlPath }) { | |
| 28 | +export async function applyDDL({ configPath, ddlPath }) { | |
| 56 | 29 | const { readFileSync } = await import('node:fs') |
| 57 | 30 | |
| 58 | - const env = parseEnv(readFileSync(envPath, 'utf8')) | |
| 31 | + const env = dbEnvFromConfig(parseYamlConfig(readFileSync(configPath, 'utf8'))) | |
| 59 | 32 | const ddl = readFileSync(ddlPath, 'utf8') |
| 60 | - const { host, port, user, password, database } = resolveDbConfig(env, envPath) | |
| 61 | - assertSafeDbTarget({ host, database, env, label: 'apply-ddl' }) | |
| 33 | + const { host, port, user, password, database } = resolveDbConfig(env, configPath) | |
| 62 | 34 | |
| 63 | 35 | let mysql |
| 64 | 36 | try { |
| ... | ... | @@ -89,49 +61,24 @@ export async function applyDDL({ envPath, ddlPath }) { |
| 89 | 61 | * Throws if no schema resolves — V1 has no USE/CREATE DATABASE. |
| 90 | 62 | * |
| 91 | 63 | * @param {Record<string,string>} env |
| 92 | - * @param {string} [envPath] only used to make the error message actionable | |
| 64 | + * @param {string} [cfgPath] only used to make the error message actionable | |
| 93 | 65 | * @returns {{host:string, port:number, user:string, password:string, database:string}} |
| 94 | 66 | */ |
| 95 | -export function resolveDbConfig(env, envPath = '.env.local') { | |
| 67 | +export function resolveDbConfig(env, cfgPath = 'config-vars.yaml') { | |
| 96 | 68 | const host = env.DB_HOST || env.MYSQL_HOST || '127.0.0.1' |
| 97 | 69 | const port = Number(env.DB_PORT || env.MYSQL_PORT || 3306) |
| 98 | 70 | const user = env.DB_USER || env.MYSQL_USER || 'root' |
| 99 | 71 | const password = env.DB_PASS || env.DB_PASSWORD || env.MYSQL_PASSWORD || '' |
| 100 | 72 | const database = env.DB_SCHEMA || env.DB_NAME || env.MYSQL_DATABASE || undefined |
| 101 | 73 | if (!database) { |
| 102 | - throw new Error(`apply-ddl: 缺数据库名 — 请在 ${envPath} 设置 DB_SCHEMA(或 DB_NAME / MYSQL_DATABASE)`) | |
| 74 | + throw new Error(`apply-ddl: 缺数据库名 — 请在 ${cfgPath} 的 database.schema 填写`) | |
| 103 | 75 | } |
| 104 | 76 | if (!Number.isInteger(port) || port <= 0 || port > 65535) { |
| 105 | - throw new Error(`apply-ddl: DB_PORT 非法 — ${envPath} 中端口必须是 1..65535 的整数`) | |
| 77 | + throw new Error(`apply-ddl: 端口非法 — ${cfgPath} 的 database.port 必须是 1..65535 的整数`) | |
| 106 | 78 | } |
| 107 | 79 | return { host, port, user, password, database } |
| 108 | 80 | } |
| 109 | 81 | |
| 110 | -/** | |
| 111 | - * Fail closed for direct DDL application. setup-test-db.mjs has the same guard | |
| 112 | - * before DROP+CREATE; apply-ddl repeats it so direct CLI use cannot hit prod. | |
| 113 | - * | |
| 114 | - * @param {{host:string, database:string, env?:Record<string,string>, label?:string}} opts | |
| 115 | - * @returns {true} | |
| 116 | - */ | |
| 117 | -export function assertSafeDbTarget({ host, database, env = {}, label = 'apply-ddl' }) { | |
| 118 | - const extraHosts = String(env.TEST_DB_ALLOWED_HOSTS || '') | |
| 119 | - .split(/[\s,]+/) | |
| 120 | - .filter(Boolean) | |
| 121 | - const allowedHosts = ['localhost', '127.0.0.1', '::1', ...extraHosts] | |
| 122 | - if (!allowedHosts.includes(host)) { | |
| 123 | - throw new Error(`${label}: 拒绝连接非白名单 host (${host});如确认是测试库,请在 .env.local 设置 TEST_DB_ALLOWED_HOSTS`) | |
| 124 | - } | |
| 125 | - if (!/^[A-Za-z0-9_]+$/.test(database)) { | |
| 126 | - throw new Error(`${label}: DB_SCHEMA 只能包含字母、数字、下划线,当前为 ${JSON.stringify(database)}`) | |
| 127 | - } | |
| 128 | - const looksLikeTest = /test/i.test(database) || /_dev$/i.test(database) || /_local$/i.test(database) || /_ci$/i.test(database) | |
| 129 | - if (!looksLikeTest) { | |
| 130 | - throw new Error(`${label}: schema '${database}' 不像测试/开发库(需含 test 或以 _dev/_local/_ci 结尾)`) | |
| 131 | - } | |
| 132 | - return true | |
| 133 | -} | |
| 134 | - | |
| 135 | 82 | /** Distinct error type so the CLI can emit a friendly install hint. */ |
| 136 | 83 | export class MysqlUnavailableError extends Error { |
| 137 | 84 | constructor() { |
| ... | ... | @@ -140,17 +87,17 @@ export class MysqlUnavailableError extends Error { |
| 140 | 87 | } |
| 141 | 88 | } |
| 142 | 89 | |
| 143 | -// CLI entry guard (see render.mjs for pathToFileURL rationale) | |
| 90 | +// CLI entry guard:pathToFileURL 规范化 argv[1] 以匹配 import.meta.url(路径含空格 / 非 ASCII / Windows 反斜杠时字面比较会失配) | |
| 144 | 91 | const { pathToFileURL } = await import('node:url') |
| 145 | 92 | if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { |
| 146 | - const [envPath, ddlPath] = process.argv.slice(2) | |
| 147 | - if (!envPath || !ddlPath) { | |
| 148 | - console.error('usage: node lib/apply-ddl.mjs <envPath> <ddlPath>') | |
| 93 | + const [configPath, ddlPath] = process.argv.slice(2) | |
| 94 | + if (!configPath || !ddlPath) { | |
| 95 | + console.error('usage: node lib/apply-ddl.mjs <configPath> <ddlPath>') | |
| 149 | 96 | process.exit(2) |
| 150 | 97 | } |
| 151 | 98 | try { |
| 152 | - await applyDDL({ envPath, ddlPath }) | |
| 153 | - console.log(`apply-ddl: applied ${ddlPath} using ${envPath}`) | |
| 99 | + await applyDDL({ configPath, ddlPath }) | |
| 100 | + console.log(`apply-ddl: applied ${ddlPath} using ${configPath}`) | |
| 154 | 101 | } catch (e) { |
| 155 | 102 | if (e instanceof MysqlUnavailableError) { |
| 156 | 103 | console.error('apply-ddl: mysql2 not found. Please run `npm i mysql2` in the target project.') | ... | ... |
lib/apply-ddl.test.mjs
| 1 | 1 | import { test } from 'node:test' |
| 2 | 2 | import assert from 'node:assert/strict' |
| 3 | -import { assertSafeDbTarget, parseEnv, resolveDbConfig } from './apply-ddl.mjs' | |
| 3 | +import { dbEnvFromConfig, resolveDbConfig } from './apply-ddl.mjs' | |
| 4 | 4 | |
| 5 | -test('parseEnv ignores comments, trims, keeps special chars literally', () => { | |
| 6 | - const env = parseEnv('# c\nDB_PASS=p@ss$word!\nDB_NAME = erp \n') | |
| 7 | - assert.equal(env.DB_PASS, 'p@ss$word!') | |
| 8 | - assert.equal(env.DB_NAME, 'erp') | |
| 5 | +// ── dbEnvFromConfig(config-vars.yaml database: → DB_* env-shape adapter)── | |
| 6 | +test('dbEnvFromConfig maps the database section to the DB_* shape', () => { | |
| 7 | + const env = dbEnvFromConfig({ | |
| 8 | + database: { host: 'db.local', port: 3307, user: 'u', password: 'p@ss', schema: 'erp_test' }, | |
| 9 | + }) | |
| 10 | + assert.equal(env.DB_HOST, 'db.local') | |
| 11 | + assert.equal(env.DB_PORT, '3307') // coerced to string for resolveDbConfig | |
| 12 | + assert.equal(env.DB_USER, 'u') | |
| 13 | + assert.equal(env.DB_PASSWORD, 'p@ss') | |
| 14 | + assert.equal(env.DB_SCHEMA, 'erp_test') | |
| 9 | 15 | }) |
| 10 | 16 | |
| 11 | -test('parseEnv does NOT expand variables', () => { | |
| 12 | - const env = parseEnv('A=1\nB=${A}\nC=$A\nD=$(whoami)\nE=`id`\n') | |
| 13 | - assert.equal(env.B, '${A}') | |
| 14 | - assert.equal(env.C, '$A') | |
| 15 | - assert.equal(env.D, '$(whoami)') | |
| 16 | - assert.equal(env.E, '`id`') | |
| 17 | +test('dbEnvFromConfig tolerates a missing/empty config', () => { | |
| 18 | + assert.equal(dbEnvFromConfig({}).DB_HOST, undefined) | |
| 19 | + assert.equal(dbEnvFromConfig(null).DB_SCHEMA, undefined) | |
| 17 | 20 | }) |
| 18 | 21 | |
| 19 | -test('parseEnv skips blank lines and comment lines', () => { | |
| 20 | - const env = parseEnv('\n \n# comment\nK=v\n # indented comment\n') | |
| 21 | - assert.deepEqual(Object.keys(env), ['K']) | |
| 22 | - assert.equal(env.K, 'v') | |
| 23 | -}) | |
| 24 | - | |
| 25 | -test('parseEnv strips one layer of matching quotes', () => { | |
| 26 | - const env = parseEnv(`Q1="hello world"\nQ2='a=b=c'\nQ3="$keep"\n`) | |
| 27 | - assert.equal(env.Q1, 'hello world') | |
| 28 | - assert.equal(env.Q2, 'a=b=c') | |
| 29 | - assert.equal(env.Q3, '$keep') | |
| 30 | -}) | |
| 31 | - | |
| 32 | -test('parseEnv keeps = signs inside the value', () => { | |
| 33 | - const env = parseEnv('URL=mysql://u:p@h:3306/db?x=1&y=2\n') | |
| 34 | - assert.equal(env.URL, 'mysql://u:p@h:3306/db?x=1&y=2') | |
| 35 | -}) | |
| 36 | - | |
| 37 | -test('parseEnv strips an optional leading export', () => { | |
| 38 | - const env = parseEnv('export DB_USER=root\n') | |
| 39 | - assert.equal(env.DB_USER, 'root') | |
| 40 | -}) | |
| 41 | - | |
| 42 | -test('parseEnv tolerates CRLF line endings', () => { | |
| 43 | - const env = parseEnv('A=1\r\nB=2\r\n') | |
| 44 | - assert.equal(env.A, '1') | |
| 45 | - assert.equal(env.B, '2') | |
| 46 | -}) | |
| 47 | - | |
| 48 | -test('parseEnv ignores lines without an = sign', () => { | |
| 49 | - const env = parseEnv('NOEQUALS\nK=v\n') | |
| 50 | - assert.deepEqual(Object.keys(env), ['K']) | |
| 51 | -}) | |
| 52 | - | |
| 53 | -test('parseEnv on empty / non-string input returns empty object', () => { | |
| 54 | - assert.deepEqual(parseEnv(''), {}) | |
| 55 | - assert.deepEqual(parseEnv(undefined), {}) | |
| 56 | - assert.deepEqual(parseEnv(null), {}) | |
| 22 | +test('dbEnvFromConfig → resolveDbConfig round-trips a filled database section', () => { | |
| 23 | + const c = resolveDbConfig(dbEnvFromConfig({ database: { host: 'localhost', port: 3306, schema: 'erp_dev' } })) | |
| 24 | + assert.equal(c.host, 'localhost') | |
| 25 | + assert.equal(c.port, 3306) | |
| 26 | + assert.equal(c.database, 'erp_dev') | |
| 57 | 27 | }) |
| 58 | 28 | |
| 59 | 29 | // ── resolveDbConfig(M1:DB_SCHEMA 是插件契约的 schema 键)───────── |
| ... | ... | @@ -74,7 +44,7 @@ test('resolveDbConfig honors DB_NAME / MYSQL_DATABASE aliases', () => { |
| 74 | 44 | }) |
| 75 | 45 | |
| 76 | 46 | test('resolveDbConfig fails closed when no schema key is present (M1)', () => { |
| 77 | - assert.throws(() => resolveDbConfig({ DB_USER: 'root' }, '.env.local'), /DB_SCHEMA/) | |
| 47 | + assert.throws(() => resolveDbConfig({ DB_USER: 'root' }, 'config-vars.yaml'), /database\.schema/) | |
| 78 | 48 | }) |
| 79 | 49 | |
| 80 | 50 | test('resolveDbConfig applies sane defaults for host/port/user/password', () => { |
| ... | ... | @@ -86,33 +56,6 @@ test('resolveDbConfig applies sane defaults for host/port/user/password', () => |
| 86 | 56 | }) |
| 87 | 57 | |
| 88 | 58 | test('resolveDbConfig rejects invalid ports', () => { |
| 89 | - assert.throws(() => resolveDbConfig({ DB_SCHEMA: 'erp_test', DB_PORT: 'abc' }), /DB_PORT/) | |
| 90 | - assert.throws(() => resolveDbConfig({ DB_SCHEMA: 'erp_test', DB_PORT: '70000' }), /DB_PORT/) | |
| 91 | -}) | |
| 92 | - | |
| 93 | -test('assertSafeDbTarget allows local and explicitly allowlisted test targets', () => { | |
| 94 | - assert.equal(assertSafeDbTarget({ host: 'localhost', database: 'erp_test' }), true) | |
| 95 | - assert.equal( | |
| 96 | - assertSafeDbTarget({ | |
| 97 | - host: 'mysql.dev.internal', | |
| 98 | - database: 'erp_dev', | |
| 99 | - env: { TEST_DB_ALLOWED_HOSTS: 'mysql.dev.internal' }, | |
| 100 | - }), | |
| 101 | - true | |
| 102 | - ) | |
| 103 | -}) | |
| 104 | - | |
| 105 | -test('assertSafeDbTarget rejects prod-looking or injectable targets', () => { | |
| 106 | - assert.throws( | |
| 107 | - () => assertSafeDbTarget({ host: 'prod.db.internal', database: 'erp_test' }), | |
| 108 | - /非白名单 host/ | |
| 109 | - ) | |
| 110 | - assert.throws( | |
| 111 | - () => assertSafeDbTarget({ host: 'localhost', database: 'erp_prod' }), | |
| 112 | - /不像测试\/开发库/ | |
| 113 | - ) | |
| 114 | - assert.throws( | |
| 115 | - () => assertSafeDbTarget({ host: 'localhost', database: 'erp_test`; DROP DATABASE prod; --' }), | |
| 116 | - /只能包含/ | |
| 117 | - ) | |
| 59 | + assert.throws(() => resolveDbConfig({ DB_SCHEMA: 'erp_test', DB_PORT: 'abc' }), /database\.port/) | |
| 60 | + assert.throws(() => resolveDbConfig({ DB_SCHEMA: 'erp_test', DB_PORT: '70000' }), /database\.port/) | |
| 118 | 61 | }) | ... | ... |
lib/merge-gitignore.mjs
| ... | ... | @@ -23,7 +23,7 @@ export function mergeGitignore(baseText, addText) { |
| 23 | 23 | return text |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | -const { pathToFileURL } = await import('node:url') // CLI entry guard (see render.mjs) | |
| 26 | +const { pathToFileURL } = await import('node:url') // CLI entry guard:pathToFileURL 规范化 argv[1] 以匹配 import.meta.url(路径含空格 / 非 ASCII / Windows 反斜杠时字面比较会失配) | |
| 27 | 27 | if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { |
| 28 | 28 | const [basePath, addPath] = process.argv.slice(2) |
| 29 | 29 | const { readFileSync, writeFileSync } = await import('node:fs') | ... | ... |
lib/render.mjs deleted
| 1 | -// lib/render.mjs — literal-safe template render (replaces scope-lock/render.sh) | |
| 2 | -// | |
| 3 | -// 核心要求:{{key}} 占位替换;值中含 $、{、}、}} 不被二次解释(字面插入); | |
| 4 | -// 先剥离 HTML 注释(模板引导文本);缺少变量则 throw(不静默留空)。 | |
| 5 | -export function render(template, vars) { | |
| 6 | - const withoutComments = template.replace(/<!--[\s\S]*?-->/g, '') | |
| 7 | - return withoutComments.replace(/\{\{(\w+)\}\}/g, (_, key) => { | |
| 8 | - // 用 Object.hasOwn 而非 `key in vars`:避免 {{constructor}} / {{toString}} 等 | |
| 9 | - // 沿原型链命中继承属性、静默渲染出垃圾(应按"缺变量"抛错)。 | |
| 10 | - if (!Object.hasOwn(vars, key)) throw new Error(`render: missing var "${key}"`) | |
| 11 | - return String(vars[key]) // 字面插入,不二次解释 $ 或 {} | |
| 12 | - }) | |
| 13 | -} | |
| 14 | - | |
| 15 | -// 入口判定用 pathToFileURL 规范化 process.argv[1],使其与 import.meta.url 编码一致 | |
| 16 | -// (路径含空格/非 ASCII/Windows 反斜杠时,字面 `file://${argv[1]}` 比较会失配)。 | |
| 17 | -const { pathToFileURL } = await import('node:url') | |
| 18 | -if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { | |
| 19 | - const { readFileSync, writeFileSync } = await import('node:fs') | |
| 20 | - const [tplPath, jsonPath, outPath] = process.argv.slice(2) | |
| 21 | - const tpl = readFileSync(tplPath, 'utf8') | |
| 22 | - const vars = JSON.parse(readFileSync(jsonPath, 'utf8')) | |
| 23 | - writeFileSync(outPath, render(tpl, vars)) | |
| 24 | -} |
lib/render.test.mjs deleted
| 1 | -import { test } from 'node:test' | |
| 2 | -import assert from 'node:assert/strict' | |
| 3 | -import { render } from './render.mjs' | |
| 4 | - | |
| 5 | -test('replaces placeholders', () => { | |
| 6 | - assert.equal(render('Hi {{name}}', { name: 'Al' }), 'Hi Al') | |
| 7 | -}) | |
| 8 | -test('value containing $ and braces is inserted literally', () => { | |
| 9 | - assert.equal(render('v={{x}}', { x: '${FOO} a{b}c }}' }), 'v=${FOO} a{b}c }}') | |
| 10 | -}) | |
| 11 | -test('strips HTML comments used as template guides', () => { | |
| 12 | - assert.equal(render('a<!-- note -->b', {}), 'ab') | |
| 13 | -}) | |
| 14 | -test('missing key throws (no silent blank)', () => { | |
| 15 | - assert.throws(() => render('{{missing}}', {}), /missing/) | |
| 16 | -}) | |
| 17 | -test('inherited prototype keys are treated as missing (not silently rendered)', () => { | |
| 18 | - // {{constructor}} / {{toString}} 不应沿原型链命中继承函数渲染出垃圾 | |
| 19 | - assert.throws(() => render('{{constructor}}', {}), /missing var "constructor"/) | |
| 20 | - assert.throws(() => render('{{toString}}', {}), /missing var "toString"/) | |
| 21 | - // 但 own 属性即便名为 constructor 也应正常渲染 | |
| 22 | - assert.equal(render('{{constructor}}', { constructor: 'X' }), 'X') | |
| 23 | -}) |
lib/validate-ddl.mjs
| ... | ... | @@ -467,7 +467,7 @@ export function formatDiff(diff) { |
| 467 | 467 | return out.join('\n') |
| 468 | 468 | } |
| 469 | 469 | |
| 470 | -const { pathToFileURL } = await import('node:url') // CLI entry guard (see render.mjs) | |
| 470 | +const { pathToFileURL } = await import('node:url') // CLI entry guard:pathToFileURL 规范化 argv[1] 以匹配 import.meta.url(路径含空格 / 非 ASCII / Windows 反斜杠时字面比较会失配) | |
| 471 | 471 | const isCliEntry = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href |
| 472 | 472 | if (isCliEntry) { |
| 473 | 473 | const { readFileSync, existsSync } = await import('node:fs') | ... | ... |
lib/yaml-config.mjs
0 → 100644
| 1 | +// lib/yaml-config.mjs — minimal YAML reader for config-vars.yaml. | |
| 2 | +// | |
| 3 | +// Scope is intentionally tiny: config-vars.yaml is exactly two levels deep | |
| 4 | +// (top-level section header `key:` → 2-space-indented `key: value` scalars). | |
| 5 | +// We deliberately support NO lists, NO flow style ([], {}), NO anchors and | |
| 6 | +// NO multiline — config-vars.yaml uses none of them, so a parser that handled | |
| 7 | +// them would be untested surface. Mirrors the zero-dependency, single-purpose | |
| 8 | +// spirit of the dotenv parser this replaces. | |
| 9 | +// | |
| 10 | +// Value rules (same philosophy as the old dotenv parser): | |
| 11 | +// - a single layer of matching surrounding quotes ('…' or "…") is removed, | |
| 12 | +// and anything after the closing quote (e.g. a trailing ` # comment`) is dropped | |
| 13 | +// - an unquoted value is cut at the first ` #` (space-hash) inline comment | |
| 14 | +// - a value that is empty or starts with `#` is treated as empty | |
| 15 | +// - NO variable expansion: `$FOO`, `${FOO}`, backticks stay literal | |
| 16 | +// - numbers stay strings (callers Number() them, as with dotenv) | |
| 17 | + | |
| 18 | +/** Parse one `key:`-stripped raw value into its literal string. */ | |
| 19 | +export function parseScalar(raw) { | |
| 20 | + let s = String(raw).trim() | |
| 21 | + if (s === '' || s[0] === '#') return '' | |
| 22 | + const q = s[0] | |
| 23 | + if (q === '"' || q === "'") { | |
| 24 | + const end = s.indexOf(q, 1) | |
| 25 | + if (end !== -1) return s.slice(1, end) // ignore anything after the closing quote | |
| 26 | + // no closing quote → fall through and treat the (still-quoted) text literally | |
| 27 | + } | |
| 28 | + const hash = s.indexOf(' #') | |
| 29 | + if (hash !== -1) s = s.slice(0, hash).trim() | |
| 30 | + return s | |
| 31 | +} | |
| 32 | + | |
| 33 | +/** | |
| 34 | + * Parse config-vars.yaml text into a nested plain object. | |
| 35 | + * | |
| 36 | + * Top-level `section:` with no value opens a mapping; subsequent indented | |
| 37 | + * `key: value` lines attach to it. A top-level `key: value` stays a root scalar. | |
| 38 | + * | |
| 39 | + * @param {string} text | |
| 40 | + * @returns {Record<string, any>} | |
| 41 | + */ | |
| 42 | +export function parseYamlConfig(text) { | |
| 43 | + const root = {} | |
| 44 | + if (typeof text !== 'string') return root | |
| 45 | + let section = null | |
| 46 | + for (const rawLine of text.split('\n')) { | |
| 47 | + const line = rawLine.replace(/\r$/, '') // tolerate CRLF | |
| 48 | + const trimmed = line.trim() | |
| 49 | + if (trimmed === '' || trimmed[0] === '#') continue // blank / full-line comment | |
| 50 | + | |
| 51 | + const colon = line.indexOf(':') | |
| 52 | + if (colon === -1) continue // not a key: line; ignore | |
| 53 | + | |
| 54 | + const key = line.slice(0, colon).trim() | |
| 55 | + if (key === '') continue | |
| 56 | + const indent = line.length - line.replace(/^\s+/, '').length | |
| 57 | + const value = parseScalar(line.slice(colon + 1)) | |
| 58 | + | |
| 59 | + if (indent === 0) { | |
| 60 | + if (value === '') { | |
| 61 | + section = {} | |
| 62 | + root[key] = section | |
| 63 | + } else { | |
| 64 | + root[key] = value | |
| 65 | + section = null | |
| 66 | + } | |
| 67 | + } else if (section) { | |
| 68 | + section[key] = value | |
| 69 | + } else { | |
| 70 | + root[key] = value // indented line with no open section — tolerate as root scalar | |
| 71 | + } | |
| 72 | + } | |
| 73 | + return root | |
| 74 | +} | ... | ... |
lib/yaml-config.test.mjs
0 → 100644
| 1 | +import { test } from 'node:test' | |
| 2 | +import assert from 'node:assert/strict' | |
| 3 | +import { parseScalar, parseYamlConfig } from './yaml-config.mjs' | |
| 4 | + | |
| 5 | +test('parseScalar strips one layer of matching quotes', () => { | |
| 6 | + assert.equal(parseScalar(' "hello world" '), 'hello world') | |
| 7 | + assert.equal(parseScalar("'a: b: c'"), 'a: b: c') | |
| 8 | +}) | |
| 9 | + | |
| 10 | +test('parseScalar drops a trailing inline comment after a quoted value', () => { | |
| 11 | + assert.equal(parseScalar("'' # default empty"), '') | |
| 12 | + assert.equal(parseScalar("'mysql.dev' # remote test host"), 'mysql.dev') | |
| 13 | +}) | |
| 14 | + | |
| 15 | +test('parseScalar cuts an unquoted value at a space-hash comment', () => { | |
| 16 | + assert.equal(parseScalar('8080 # default port'), '8080') | |
| 17 | + assert.equal(parseScalar('com.acme.erp'), 'com.acme.erp') | |
| 18 | +}) | |
| 19 | + | |
| 20 | +test('parseScalar treats empty / comment-only values as empty', () => { | |
| 21 | + assert.equal(parseScalar(''), '') | |
| 22 | + assert.equal(parseScalar(' '), '') | |
| 23 | + assert.equal(parseScalar('# just a comment'), '') | |
| 24 | +}) | |
| 25 | + | |
| 26 | +test('parseScalar does NOT expand variables and keeps special chars literally', () => { | |
| 27 | + assert.equal(parseScalar('${A}'), '${A}') | |
| 28 | + assert.equal(parseScalar('$(whoami)'), '$(whoami)') | |
| 29 | + assert.equal(parseScalar("'p@ss$w0rd!'"), 'p@ss$w0rd!') | |
| 30 | + // a `#` not preceded by a space stays part of an unquoted value | |
| 31 | + assert.equal(parseScalar('a#b'), 'a#b') | |
| 32 | +}) | |
| 33 | + | |
| 34 | +test('parseYamlConfig builds a two-level nested object', () => { | |
| 35 | + const cfg = parseYamlConfig( | |
| 36 | + [ | |
| 37 | + 'backend:', | |
| 38 | + ' base_package: com.acme.erp', | |
| 39 | + ' http_port: 8080', | |
| 40 | + 'database:', | |
| 41 | + ' host: 127.0.0.1', | |
| 42 | + ' schema: erp_dev', | |
| 43 | + '', | |
| 44 | + ].join('\n') | |
| 45 | + ) | |
| 46 | + assert.deepEqual(cfg.backend, { base_package: 'com.acme.erp', http_port: '8080' }) | |
| 47 | + assert.deepEqual(cfg.database, { host: '127.0.0.1', schema: 'erp_dev' }) | |
| 48 | +}) | |
| 49 | + | |
| 50 | +test('parseYamlConfig skips blank lines, full-line and indented comments', () => { | |
| 51 | + const cfg = parseYamlConfig( | |
| 52 | + ['# top comment', 'database:', ' # inline section comment', ' host: localhost', '', ' '].join('\n') | |
| 53 | + ) | |
| 54 | + assert.deepEqual(cfg.database, { host: 'localhost' }) | |
| 55 | +}) | |
| 56 | + | |
| 57 | +test('parseYamlConfig keeps a quoted empty value and ignores its trailing comment', () => { | |
| 58 | + const cfg = parseYamlConfig(['database:', " password: '' # default empty"].join('\n')) | |
| 59 | + assert.equal(cfg.database.password, '') | |
| 60 | +}) | |
| 61 | + | |
| 62 | +test('parseYamlConfig only splits on the first colon (values may contain colons)', () => { | |
| 63 | + const cfg = parseYamlConfig(['secrets:', ' jwt_secret: a:b:c'].join('\n')) | |
| 64 | + assert.equal(cfg.secrets.jwt_secret, 'a:b:c') | |
| 65 | +}) | |
| 66 | + | |
| 67 | +test('parseYamlConfig tolerates CRLF and returns {} for non-string input', () => { | |
| 68 | + const cfg = parseYamlConfig('backend:\r\n http_port: 9090\r\n') | |
| 69 | + assert.equal(cfg.backend.http_port, '9090') | |
| 70 | + assert.deepEqual(parseYamlConfig(undefined), {}) | |
| 71 | + assert.deepEqual(parseYamlConfig(null), {}) | |
| 72 | +}) | |
| 73 | + | |
| 74 | +test('parseYamlConfig supports a top-level scalar (no section)', () => { | |
| 75 | + const cfg = parseYamlConfig('project_name: Acme ERP\n') | |
| 76 | + assert.equal(cfg.project_name, 'Acme ERP') | |
| 77 | +}) | ... | ... |