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 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(&#39;resolveDbConfig honors DB_NAME / MYSQL_DATABASE aliases&#39;, () =&gt; {
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(&#39;resolveDbConfig applies sane defaults for host/port/user/password&#39;, () =&gt;
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 +})
... ...