Commit 5babb654816cf736cac5d4c5743b94552fedaac0

Authored by zichun
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')
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 * Apply a DDL file to a MySQL database using mysql2/promise. 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 * @returns {Promise<void>} 26 * @returns {Promise<void>}
54 */ 27 */
55 -export async function applyDDL({ envPath, ddlPath }) { 28 +export async function applyDDL({ configPath, ddlPath }) {
56 const { readFileSync } = await import('node:fs') 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 const ddl = readFileSync(ddlPath, 'utf8') 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 let mysql 35 let mysql
64 try { 36 try {
@@ -89,49 +61,24 @@ export async function applyDDL({ envPath, ddlPath }) { @@ -89,49 +61,24 @@ export async function applyDDL({ envPath, ddlPath }) {
89 * Throws if no schema resolves — V1 has no USE/CREATE DATABASE. 61 * Throws if no schema resolves — V1 has no USE/CREATE DATABASE.
90 * 62 *
91 * @param {Record<string,string>} env 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 * @returns {{host:string, port:number, user:string, password:string, database:string}} 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 const host = env.DB_HOST || env.MYSQL_HOST || '127.0.0.1' 68 const host = env.DB_HOST || env.MYSQL_HOST || '127.0.0.1'
97 const port = Number(env.DB_PORT || env.MYSQL_PORT || 3306) 69 const port = Number(env.DB_PORT || env.MYSQL_PORT || 3306)
98 const user = env.DB_USER || env.MYSQL_USER || 'root' 70 const user = env.DB_USER || env.MYSQL_USER || 'root'
99 const password = env.DB_PASS || env.DB_PASSWORD || env.MYSQL_PASSWORD || '' 71 const password = env.DB_PASS || env.DB_PASSWORD || env.MYSQL_PASSWORD || ''
100 const database = env.DB_SCHEMA || env.DB_NAME || env.MYSQL_DATABASE || undefined 72 const database = env.DB_SCHEMA || env.DB_NAME || env.MYSQL_DATABASE || undefined
101 if (!database) { 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 if (!Number.isInteger(port) || port <= 0 || port > 65535) { 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 return { host, port, user, password, database } 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 /** Distinct error type so the CLI can emit a friendly install hint. */ 82 /** Distinct error type so the CLI can emit a friendly install hint. */
136 export class MysqlUnavailableError extends Error { 83 export class MysqlUnavailableError extends Error {
137 constructor() { 84 constructor() {
@@ -140,17 +87,17 @@ export class MysqlUnavailableError extends Error { @@ -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 const { pathToFileURL } = await import('node:url') 91 const { pathToFileURL } = await import('node:url')
145 if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { 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 process.exit(2) 96 process.exit(2)
150 } 97 }
151 try { 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 } catch (e) { 101 } catch (e) {
155 if (e instanceof MysqlUnavailableError) { 102 if (e instanceof MysqlUnavailableError) {
156 console.error('apply-ddl: mysql2 not found. Please run `npm i mysql2` in the target project.') 103 console.error('apply-ddl: mysql2 not found. Please run `npm i mysql2` in the target project.')
lib/apply-ddl.test.mjs
1 import { test } from 'node:test' 1 import { test } from 'node:test'
2 import assert from 'node:assert/strict' 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 // ── resolveDbConfig(M1:DB_SCHEMA 是插件契约的 schema 键)───────── 29 // ── resolveDbConfig(M1:DB_SCHEMA 是插件契约的 schema 键)─────────
@@ -74,7 +44,7 @@ test(&#39;resolveDbConfig honors DB_NAME / MYSQL_DATABASE aliases&#39;, () =&gt; { @@ -74,7 +44,7 @@ test(&#39;resolveDbConfig honors DB_NAME / MYSQL_DATABASE aliases&#39;, () =&gt; {
74 }) 44 })
75 45
76 test('resolveDbConfig fails closed when no schema key is present (M1)', () => { 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 test('resolveDbConfig applies sane defaults for host/port/user/password', () => { 50 test('resolveDbConfig applies sane defaults for host/port/user/password', () => {
@@ -86,33 +56,6 @@ test(&#39;resolveDbConfig applies sane defaults for host/port/user/password&#39;, () =&gt; @@ -86,33 +56,6 @@ test(&#39;resolveDbConfig applies sane defaults for host/port/user/password&#39;, () =&gt;
86 }) 56 })
87 57
88 test('resolveDbConfig rejects invalid ports', () => { 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,7 +23,7 @@ export function mergeGitignore(baseText, addText) {
23 return text 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 if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { 27 if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
28 const [basePath, addPath] = process.argv.slice(2) 28 const [basePath, addPath] = process.argv.slice(2)
29 const { readFileSync, writeFileSync } = await import('node:fs') 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,7 +467,7 @@ export function formatDiff(diff) {
467 return out.join('\n') 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 const isCliEntry = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href 471 const isCliEntry = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
472 if (isCliEntry) { 472 if (isCliEntry) {
473 const { readFileSync, existsSync } = await import('node:fs') 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 +})