@fuzionx/framework 0.1.43 → 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +501 -501
  2. package/bin/fx.js +12 -12
  3. package/cli/db-sync.js +100 -100
  4. package/cli/index.js +494 -494
  5. package/cli/templates/make/app/controllers/HomeController.js +14 -14
  6. package/cli/templates/make/app/routes/api.js +7 -7
  7. package/cli/templates/make/app/routes/web.js +5 -5
  8. package/cli/templates/make/app/views/default/errors/404.html +11 -11
  9. package/cli/templates/make/app/views/default/errors/500.html +14 -14
  10. package/cli/templates/make/app/views/default/layouts/main.html +22 -22
  11. package/cli/templates/make/app/views/default/pages/home.html +11 -11
  12. package/cli/templates/make/controller.js.tpl +40 -40
  13. package/cli/templates/make/event.js.tpl +8 -8
  14. package/cli/templates/make/job.js.tpl +10 -10
  15. package/cli/templates/make/middleware.js.tpl +10 -10
  16. package/cli/templates/make/model.js.tpl +15 -15
  17. package/cli/templates/make/service.js.tpl +15 -15
  18. package/cli/templates/make/task.js.tpl +15 -15
  19. package/cli/templates/make/test.js.tpl +7 -7
  20. package/cli/templates/make/worker.js.tpl +14 -14
  21. package/cli/templates/make/ws.js.tpl +18 -18
  22. package/index.js +67 -67
  23. package/lib/core/AppError.js +46 -46
  24. package/lib/core/Application.js +1006 -1006
  25. package/lib/core/AutoLoader.js +227 -227
  26. package/lib/core/Base.js +64 -64
  27. package/lib/core/Config.js +331 -331
  28. package/lib/core/Context.js +484 -484
  29. package/lib/database/ConnectionManager.js +208 -208
  30. package/lib/database/MariaModel.js +29 -29
  31. package/lib/database/Model.js +247 -247
  32. package/lib/database/ModelRegistry.js +72 -72
  33. package/lib/database/MongoModel.js +232 -232
  34. package/lib/database/Pagination.js +37 -37
  35. package/lib/database/PostgreModel.js +29 -29
  36. package/lib/database/QueryBuilder.js +172 -172
  37. package/lib/database/SQLiteModel.js +27 -27
  38. package/lib/database/SqlModel.js +257 -257
  39. package/lib/database/SqlQueryBuilder.js +332 -332
  40. package/lib/helpers/CryptoHelper.js +48 -48
  41. package/lib/helpers/FileHelper.js +61 -61
  42. package/lib/helpers/HashHelper.js +39 -39
  43. package/lib/helpers/I18nHelper.js +174 -174
  44. package/lib/helpers/Logger.js +108 -108
  45. package/lib/helpers/MediaHelper.js +84 -84
  46. package/lib/http/Controller.js +34 -34
  47. package/lib/http/ErrorHandler.js +136 -136
  48. package/lib/http/Middleware.js +43 -43
  49. package/lib/http/Router.js +109 -109
  50. package/lib/http/Validation.js +125 -125
  51. package/lib/middleware/apiAuth.js +79 -79
  52. package/lib/middleware/auth.js +42 -42
  53. package/lib/middleware/bodyParser.js +19 -19
  54. package/lib/middleware/cors.js +47 -47
  55. package/lib/middleware/csrf.js +32 -32
  56. package/lib/middleware/index.js +13 -13
  57. package/lib/middleware/session.js +27 -27
  58. package/lib/middleware/theme.js +20 -20
  59. package/lib/realtime/RoomManager.js +85 -85
  60. package/lib/realtime/WsHandler.js +107 -107
  61. package/lib/schedule/Job.js +38 -38
  62. package/lib/schedule/Queue.js +103 -103
  63. package/lib/schedule/Scheduler.js +171 -171
  64. package/lib/schedule/Task.js +39 -39
  65. package/lib/schedule/WorkerPool.js +225 -225
  66. package/lib/services/EventBus.js +94 -94
  67. package/lib/services/Service.js +261 -261
  68. package/lib/services/Storage.js +112 -112
  69. package/lib/utilities/ArrUtil.js +112 -112
  70. package/lib/utilities/DateUtil.js +98 -98
  71. package/lib/utilities/FunctionUtil.js +119 -119
  72. package/lib/utilities/NumUtil.js +75 -75
  73. package/lib/utilities/ObjectUtil.js +170 -170
  74. package/lib/utilities/PaginationUtil.js +81 -81
  75. package/lib/utilities/StrUtil.js +105 -105
  76. package/lib/utilities/index.js +18 -18
  77. package/lib/view/OpenAPI.js +231 -231
  78. package/lib/view/View.js +83 -83
  79. package/package.json +2 -2
  80. package/testing/index.js +232 -232
@@ -1,331 +1,331 @@
1
- /**
2
- * Config — 통합 설정 접근
3
- *
4
- * fuzionx.yaml의 3개 섹션(bridge, database, app)을 통합 관리.
5
- * dot-notation으로 접근: config.get('app.auth.secret')
6
- *
7
- * .env 자동 로드 + ${VAR:default} 환경변수 치환.
8
- * YAML 파일 직접 파싱 지원 (외부 의존 없이 내장 파서 사용).
9
- *
10
- * @see docs/framework/17-config.md
11
- */
12
- import { readFileSync, existsSync } from 'node:fs';
13
- import path from 'node:path';
14
-
15
- export default class Config {
16
- /**
17
- * @param {object} raw - 파싱된 YAML 객체 전체
18
- * raw.bridge — Bridge(Rust)가 파싱한 bridge: 섹션
19
- * raw.database — JS가 파싱한 database: 섹션
20
- * raw.app — JS가 파싱한 app: 섹션
21
- */
22
- constructor(raw = {}) {
23
- this.bridge = raw.bridge || {};
24
- this.database = raw.database || {};
25
- this.app = raw.app || {};
26
- this._raw = raw;
27
- this._cache = new Map();
28
- }
29
-
30
- /**
31
- * YAML 파일에서 설정 로드.
32
- * configPath가 지정된 경우 Application 생성자에서 호출.
33
- * bridge 섹션은 Rust에서 파싱하므로 JS 쪽은 database/app/themes 등을 로드.
34
- * @param {string} configPath - 절대 경로
35
- */
36
- loadYaml(configPath) {
37
- if (!existsSync(configPath)) return;
38
-
39
- try {
40
- const content = readFileSync(configPath, 'utf-8');
41
- const raw = Config.parseYaml(content);
42
- const parsed = Config.resolveEnvVars(raw);
43
-
44
- // _raw에 병합 (기존 opts.config 우선)
45
- for (const [key, value] of Object.entries(parsed)) {
46
- if (this._raw[key] === undefined) {
47
- this._raw[key] = value;
48
- }
49
- }
50
-
51
- // 섹션 별칭 갱신
52
- if (!Object.keys(this.bridge).length && parsed.bridge) this.bridge = parsed.bridge;
53
- if (!Object.keys(this.database).length && parsed.database) this.database = parsed.database;
54
- if (!Object.keys(this.app).length && parsed.app) this.app = parsed.app;
55
-
56
- // 캐시 초기화
57
- this._cache.clear();
58
- } catch (err) {
59
- console.error(`[Config] YAML 파일 로드 실패 (${configPath}):`, err.message);
60
- }
61
- }
62
-
63
- /**
64
- * 간이 YAML 파서 — 외부 의존 없이 fuzionx.yaml 구조 파싱.
65
- * 지원: 중첩 객체, 문자열, 숫자, boolean, 배열(인라인/블록), 인용 문자열.
66
- * Windows(\r\n) / Unix(\n) 줄바꿈 모두 지원.
67
- * @param {string} content
68
- * @returns {object}
69
- */
70
- static parseYaml(content) {
71
- const result = {};
72
- // Windows \r\n → \n 정규화 (정규식 $, . 호환)
73
- const lines = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
74
- // stack: { indent, obj, lastKey? }
75
- const stack = [{ indent: -1, obj: result }];
76
-
77
- for (const rawLine of lines) {
78
- // 인용부호 바깥의 # 주석만 제거 (문자열 안의 # 보존)
79
- const line = Config._stripInlineComment(rawLine);
80
- if (!line.trim()) continue;
81
-
82
- const indent = line.search(/\S/);
83
-
84
- // ── YAML 블록 배열 항목: "- value" 또는 "- key: value" ──
85
- const listMatch = line.match(/^(\s*)-\s+(.+)$/);
86
- if (listMatch) {
87
- const listIndent = listMatch[1].length;
88
-
89
- // 스택에서 현재 indent보다 깊은 레벨 제거 (배열은 유지)
90
- while (stack.length > 1) {
91
- const top = stack[stack.length - 1];
92
- if (Array.isArray(top.obj) && top.indent === listIndent) break;
93
- if (top.indent >= listIndent) {
94
- stack.pop();
95
- } else {
96
- break;
97
- }
98
- }
99
-
100
- // 부모 찾기 — 배열이면 직접 사용, 아니면 lastKey를 배열로 변환
101
- const top = stack[stack.length - 1];
102
- let arr;
103
- if (Array.isArray(top.obj)) {
104
- arr = top.obj;
105
- } else {
106
- // lastKey 탐색: 현재 top → 상위 스택 순서로 찾기
107
- // (빈 값 키 origins: → {} 가 스택에 있으면 lastKey=undefined,
108
- // 이 경우 부모의 lastKey가 "origins")
109
- let foundIdx = -1;
110
- let foundKey = null;
111
- let foundObj = null;
112
- for (let si = stack.length - 1; si >= 0; si--) {
113
- if (stack[si].lastKey !== undefined) {
114
- foundIdx = si;
115
- foundKey = stack[si].lastKey;
116
- foundObj = stack[si].obj;
117
- // 배열이 아닌 빈 객체가 이 키 아래 있으면 그게 배열로 바뀌어야 함
118
- const target = Array.isArray(foundObj)
119
- ? foundObj[foundObj.length - 1]
120
- : foundObj;
121
- if (target && typeof target[foundKey] !== undefined) {
122
- foundObj = target;
123
- }
124
- break;
125
- }
126
- }
127
- if (foundKey === null) continue;
128
- if (!Array.isArray(foundObj[foundKey])) {
129
- foundObj[foundKey] = [];
130
- }
131
- arr = foundObj[foundKey];
132
- // 기존 빈 객체 스택 제거 후 배열로 교체
133
- while (stack.length > foundIdx + 1) stack.pop();
134
- stack.push({ indent: listIndent, obj: arr });
135
- }
136
-
137
- const itemContent = listMatch[2].trim();
138
- // "- key: value" 형태인지 확인
139
- const kvMatch = itemContent.match(/^(?:(["'])([^"']+)\1|([-\w.]+))\s*:\s*(.+)$/);
140
- if (kvMatch) {
141
- const k = kvMatch[2] || kvMatch[3];
142
- const v = Config._parseYamlValue(kvMatch[4].trim());
143
- const obj = { [k]: v };
144
- arr.push(obj);
145
- stack.push({ indent: listIndent + 2, obj, lastKey: k });
146
- } else {
147
- arr.push(Config._parseYamlValue(itemContent));
148
- }
149
- continue;
150
- }
151
-
152
- // ── 키: 값 파싱 ──
153
- // 키: unquoted ([-\w.]+) 또는 quoted ("..." / '...')
154
- const match = line.match(/^(\s*)(?:(["'])([^"']+)\2|([-\w.]+))\s*:\s*(.*)$/);
155
- if (!match) continue;
156
-
157
- const key = match[3] || match[4]; // quoted key (그룹3) 또는 unquoted key (그룹4)
158
- const rawValue = match[5];
159
-
160
- // 스택에서 현재 indent보다 깊거나 같은 레벨 제거
161
- while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
162
- stack.pop();
163
- }
164
-
165
- const top = stack[stack.length - 1];
166
- // 배열 안의 마지막 객체에 키 추가 (- key: value 후속 키)
167
- const target = Array.isArray(top.obj) ? top.obj[top.obj.length - 1] : top.obj;
168
- const value = rawValue.trim();
169
-
170
- if (!value) {
171
- // 하위 객체 시작
172
- target[key] = {};
173
- stack.push({ indent, obj: target[key], lastKey: undefined });
174
- // lastKey를 부모에 설정 (배열 전환 시 필요)
175
- const parentIdx = stack.length - 2;
176
- if (parentIdx >= 0) {
177
- stack[parentIdx].lastKey = key;
178
- }
179
- } else {
180
- // 값 파싱
181
- target[key] = Config._parseYamlValue(value);
182
- top.lastKey = key;
183
- }
184
- }
185
-
186
- return result;
187
- }
188
-
189
- /**
190
- * 인라인 주석 제거 — 인용부호 안의 # 보존
191
- * @param {string} line
192
- * @returns {string}
193
- * @private
194
- */
195
- static _stripInlineComment(line) {
196
- let inSingle = false;
197
- let inDouble = false;
198
- for (let i = 0; i < line.length; i++) {
199
- const ch = line[i];
200
- if (ch === "'" && !inDouble) inSingle = !inSingle;
201
- else if (ch === '"' && !inSingle) inDouble = !inDouble;
202
- else if (ch === '#' && !inSingle && !inDouble) {
203
- return line.slice(0, i);
204
- }
205
- }
206
- return line;
207
- }
208
-
209
- /**
210
- * YAML 값 파싱 (문자열, 숫자, boolean, 배열)
211
- * @private
212
- */
213
- static _parseYamlValue(raw) {
214
- // 인용 문자열
215
- const quoted = raw.match(/^(['"])(.*)\1$/);
216
- if (quoted) return quoted[2];
217
-
218
- // boolean
219
- if (raw === 'true') return true;
220
- if (raw === 'false') return false;
221
- if (raw === 'null' || raw === '~') return null;
222
-
223
- // 인라인 배열 [a, b, c]
224
- if (raw.startsWith('[') && raw.endsWith(']')) {
225
- const inner = raw.slice(1, -1).trim();
226
- if (!inner) return [];
227
- return inner.split(',').map(s => Config._parseYamlValue(s.trim()));
228
- }
229
-
230
- // 숫자
231
- const num = Number(raw);
232
- if (!isNaN(num) && raw !== '') return num;
233
-
234
- // 기본: 문자열
235
- return raw;
236
- }
237
-
238
- /**
239
- * .env 파일 로드 (17-config.md)
240
- * 부트 시 자동 호출. 이미 설정된 환경변수는 덮어쓰지 않음.
241
- * @param {string} [baseDir='.']
242
- */
243
- loadEnv(baseDir = '.') {
244
- const envPath = path.resolve(baseDir, '.env');
245
- if (!existsSync(envPath)) return;
246
-
247
- try {
248
- const content = readFileSync(envPath, 'utf-8');
249
- for (const line of content.split('\n')) {
250
- const trimmed = line.trim();
251
- if (!trimmed || trimmed.startsWith('#')) continue;
252
- const eqIdx = trimmed.indexOf('=');
253
- if (eqIdx === -1) continue;
254
- const key = trimmed.slice(0, eqIdx).trim();
255
- let value = trimmed.slice(eqIdx + 1).trim();
256
- // .env 인용부호 제거 ("value" → value, 'value' → value)
257
- value = value.replace(/^(['"])(.*)\1$/, '$2');
258
- // 시스템 환경변수 우선 (17-config.md)
259
- if (!(key in process.env)) {
260
- process.env[key] = value;
261
- }
262
- }
263
- } catch {} // .env 읽기 실패 시 무시
264
- }
265
-
266
- /**
267
- * YAML 객체의 ${VAR} / ${VAR:default} 패턴 치환 (17-config.md)
268
- * @param {object} obj
269
- * @returns {object}
270
- */
271
- static resolveEnvVars(obj) {
272
- if (typeof obj === 'string') {
273
- return obj.replace(/\$\{(\w+)(?::([^}]*))?\}/g, (_, varName, defaultVal) => {
274
- const envVal = process.env[varName];
275
- if (envVal !== undefined) return envVal;
276
- if (defaultVal !== undefined) return defaultVal;
277
- throw new Error(`Required environment variable '${varName}' is not set`);
278
- });
279
- }
280
- if (Array.isArray(obj)) return obj.map(item => Config.resolveEnvVars(item));
281
- if (obj !== null && typeof obj === 'object') {
282
- const result = {};
283
- for (const [key, value] of Object.entries(obj)) {
284
- result[key] = Config.resolveEnvVars(value);
285
- }
286
- return result;
287
- }
288
- return obj;
289
- }
290
-
291
- /**
292
- * dot-notation으로 설정값 접근
293
- * @param {string} path - 'app.auth.secret', 'database.main.host' 등
294
- * @param {*} [defaultValue] - 키가 없을 때 반환값
295
- * @returns {*}
296
- */
297
- get(path, defaultValue = undefined) {
298
- if (this._cache.has(path)) return this._cache.get(path);
299
-
300
- const parts = path.split('.');
301
- let current = this._raw;
302
-
303
- for (const part of parts) {
304
- if (current == null || typeof current !== 'object') {
305
- return defaultValue;
306
- }
307
- current = current[part];
308
- }
309
-
310
- if (current === undefined) return defaultValue;
311
- this._cache.set(path, current);
312
- return current;
313
- }
314
-
315
- /**
316
- * 설정값 존재 여부
317
- * @param {string} path
318
- * @returns {boolean}
319
- */
320
- has(path) {
321
- return this.get(path) !== undefined;
322
- }
323
-
324
- /**
325
- * 전체 설정 반환 (읽기 전용)
326
- * @returns {object}
327
- */
328
- all() {
329
- return this._raw;
330
- }
331
- }
1
+ /**
2
+ * Config — 통합 설정 접근
3
+ *
4
+ * fuzionx.yaml의 3개 섹션(bridge, database, app)을 통합 관리.
5
+ * dot-notation으로 접근: config.get('app.auth.secret')
6
+ *
7
+ * .env 자동 로드 + ${VAR:default} 환경변수 치환.
8
+ * YAML 파일 직접 파싱 지원 (외부 의존 없이 내장 파서 사용).
9
+ *
10
+ * @see docs/framework/17-config.md
11
+ */
12
+ import { readFileSync, existsSync } from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ export default class Config {
16
+ /**
17
+ * @param {object} raw - 파싱된 YAML 객체 전체
18
+ * raw.bridge — Bridge(Rust)가 파싱한 bridge: 섹션
19
+ * raw.database — JS가 파싱한 database: 섹션
20
+ * raw.app — JS가 파싱한 app: 섹션
21
+ */
22
+ constructor(raw = {}) {
23
+ this.bridge = raw.bridge || {};
24
+ this.database = raw.database || {};
25
+ this.app = raw.app || {};
26
+ this._raw = raw;
27
+ this._cache = new Map();
28
+ }
29
+
30
+ /**
31
+ * YAML 파일에서 설정 로드.
32
+ * configPath가 지정된 경우 Application 생성자에서 호출.
33
+ * bridge 섹션은 Rust에서 파싱하므로 JS 쪽은 database/app/themes 등을 로드.
34
+ * @param {string} configPath - 절대 경로
35
+ */
36
+ loadYaml(configPath) {
37
+ if (!existsSync(configPath)) return;
38
+
39
+ try {
40
+ const content = readFileSync(configPath, 'utf-8');
41
+ const raw = Config.parseYaml(content);
42
+ const parsed = Config.resolveEnvVars(raw);
43
+
44
+ // _raw에 병합 (기존 opts.config 우선)
45
+ for (const [key, value] of Object.entries(parsed)) {
46
+ if (this._raw[key] === undefined) {
47
+ this._raw[key] = value;
48
+ }
49
+ }
50
+
51
+ // 섹션 별칭 갱신
52
+ if (!Object.keys(this.bridge).length && parsed.bridge) this.bridge = parsed.bridge;
53
+ if (!Object.keys(this.database).length && parsed.database) this.database = parsed.database;
54
+ if (!Object.keys(this.app).length && parsed.app) this.app = parsed.app;
55
+
56
+ // 캐시 초기화
57
+ this._cache.clear();
58
+ } catch (err) {
59
+ console.error(`[Config] YAML 파일 로드 실패 (${configPath}):`, err.message);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * 간이 YAML 파서 — 외부 의존 없이 fuzionx.yaml 구조 파싱.
65
+ * 지원: 중첩 객체, 문자열, 숫자, boolean, 배열(인라인/블록), 인용 문자열.
66
+ * Windows(\r\n) / Unix(\n) 줄바꿈 모두 지원.
67
+ * @param {string} content
68
+ * @returns {object}
69
+ */
70
+ static parseYaml(content) {
71
+ const result = {};
72
+ // Windows \r\n → \n 정규화 (정규식 $, . 호환)
73
+ const lines = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
74
+ // stack: { indent, obj, lastKey? }
75
+ const stack = [{ indent: -1, obj: result }];
76
+
77
+ for (const rawLine of lines) {
78
+ // 인용부호 바깥의 # 주석만 제거 (문자열 안의 # 보존)
79
+ const line = Config._stripInlineComment(rawLine);
80
+ if (!line.trim()) continue;
81
+
82
+ const indent = line.search(/\S/);
83
+
84
+ // ── YAML 블록 배열 항목: "- value" 또는 "- key: value" ──
85
+ const listMatch = line.match(/^(\s*)-\s+(.+)$/);
86
+ if (listMatch) {
87
+ const listIndent = listMatch[1].length;
88
+
89
+ // 스택에서 현재 indent보다 깊은 레벨 제거 (배열은 유지)
90
+ while (stack.length > 1) {
91
+ const top = stack[stack.length - 1];
92
+ if (Array.isArray(top.obj) && top.indent === listIndent) break;
93
+ if (top.indent >= listIndent) {
94
+ stack.pop();
95
+ } else {
96
+ break;
97
+ }
98
+ }
99
+
100
+ // 부모 찾기 — 배열이면 직접 사용, 아니면 lastKey를 배열로 변환
101
+ const top = stack[stack.length - 1];
102
+ let arr;
103
+ if (Array.isArray(top.obj)) {
104
+ arr = top.obj;
105
+ } else {
106
+ // lastKey 탐색: 현재 top → 상위 스택 순서로 찾기
107
+ // (빈 값 키 origins: → {} 가 스택에 있으면 lastKey=undefined,
108
+ // 이 경우 부모의 lastKey가 "origins")
109
+ let foundIdx = -1;
110
+ let foundKey = null;
111
+ let foundObj = null;
112
+ for (let si = stack.length - 1; si >= 0; si--) {
113
+ if (stack[si].lastKey !== undefined) {
114
+ foundIdx = si;
115
+ foundKey = stack[si].lastKey;
116
+ foundObj = stack[si].obj;
117
+ // 배열이 아닌 빈 객체가 이 키 아래 있으면 그게 배열로 바뀌어야 함
118
+ const target = Array.isArray(foundObj)
119
+ ? foundObj[foundObj.length - 1]
120
+ : foundObj;
121
+ if (target && typeof target[foundKey] !== undefined) {
122
+ foundObj = target;
123
+ }
124
+ break;
125
+ }
126
+ }
127
+ if (foundKey === null) continue;
128
+ if (!Array.isArray(foundObj[foundKey])) {
129
+ foundObj[foundKey] = [];
130
+ }
131
+ arr = foundObj[foundKey];
132
+ // 기존 빈 객체 스택 제거 후 배열로 교체
133
+ while (stack.length > foundIdx + 1) stack.pop();
134
+ stack.push({ indent: listIndent, obj: arr });
135
+ }
136
+
137
+ const itemContent = listMatch[2].trim();
138
+ // "- key: value" 형태인지 확인
139
+ const kvMatch = itemContent.match(/^(?:(["'])([^"']+)\1|([-\w.]+))\s*:\s*(.+)$/);
140
+ if (kvMatch) {
141
+ const k = kvMatch[2] || kvMatch[3];
142
+ const v = Config._parseYamlValue(kvMatch[4].trim());
143
+ const obj = { [k]: v };
144
+ arr.push(obj);
145
+ stack.push({ indent: listIndent + 2, obj, lastKey: k });
146
+ } else {
147
+ arr.push(Config._parseYamlValue(itemContent));
148
+ }
149
+ continue;
150
+ }
151
+
152
+ // ── 키: 값 파싱 ──
153
+ // 키: unquoted ([-\w.]+) 또는 quoted ("..." / '...')
154
+ const match = line.match(/^(\s*)(?:(["'])([^"']+)\2|([-\w.]+))\s*:\s*(.*)$/);
155
+ if (!match) continue;
156
+
157
+ const key = match[3] || match[4]; // quoted key (그룹3) 또는 unquoted key (그룹4)
158
+ const rawValue = match[5];
159
+
160
+ // 스택에서 현재 indent보다 깊거나 같은 레벨 제거
161
+ while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
162
+ stack.pop();
163
+ }
164
+
165
+ const top = stack[stack.length - 1];
166
+ // 배열 안의 마지막 객체에 키 추가 (- key: value 후속 키)
167
+ const target = Array.isArray(top.obj) ? top.obj[top.obj.length - 1] : top.obj;
168
+ const value = rawValue.trim();
169
+
170
+ if (!value) {
171
+ // 하위 객체 시작
172
+ target[key] = {};
173
+ stack.push({ indent, obj: target[key], lastKey: undefined });
174
+ // lastKey를 부모에 설정 (배열 전환 시 필요)
175
+ const parentIdx = stack.length - 2;
176
+ if (parentIdx >= 0) {
177
+ stack[parentIdx].lastKey = key;
178
+ }
179
+ } else {
180
+ // 값 파싱
181
+ target[key] = Config._parseYamlValue(value);
182
+ top.lastKey = key;
183
+ }
184
+ }
185
+
186
+ return result;
187
+ }
188
+
189
+ /**
190
+ * 인라인 주석 제거 — 인용부호 안의 # 보존
191
+ * @param {string} line
192
+ * @returns {string}
193
+ * @private
194
+ */
195
+ static _stripInlineComment(line) {
196
+ let inSingle = false;
197
+ let inDouble = false;
198
+ for (let i = 0; i < line.length; i++) {
199
+ const ch = line[i];
200
+ if (ch === "'" && !inDouble) inSingle = !inSingle;
201
+ else if (ch === '"' && !inSingle) inDouble = !inDouble;
202
+ else if (ch === '#' && !inSingle && !inDouble) {
203
+ return line.slice(0, i);
204
+ }
205
+ }
206
+ return line;
207
+ }
208
+
209
+ /**
210
+ * YAML 값 파싱 (문자열, 숫자, boolean, 배열)
211
+ * @private
212
+ */
213
+ static _parseYamlValue(raw) {
214
+ // 인용 문자열
215
+ const quoted = raw.match(/^(['"])(.*)\1$/);
216
+ if (quoted) return quoted[2];
217
+
218
+ // boolean
219
+ if (raw === 'true') return true;
220
+ if (raw === 'false') return false;
221
+ if (raw === 'null' || raw === '~') return null;
222
+
223
+ // 인라인 배열 [a, b, c]
224
+ if (raw.startsWith('[') && raw.endsWith(']')) {
225
+ const inner = raw.slice(1, -1).trim();
226
+ if (!inner) return [];
227
+ return inner.split(',').map(s => Config._parseYamlValue(s.trim()));
228
+ }
229
+
230
+ // 숫자
231
+ const num = Number(raw);
232
+ if (!isNaN(num) && raw !== '') return num;
233
+
234
+ // 기본: 문자열
235
+ return raw;
236
+ }
237
+
238
+ /**
239
+ * .env 파일 로드 (17-config.md)
240
+ * 부트 시 자동 호출. 이미 설정된 환경변수는 덮어쓰지 않음.
241
+ * @param {string} [baseDir='.']
242
+ */
243
+ loadEnv(baseDir = '.') {
244
+ const envPath = path.resolve(baseDir, '.env');
245
+ if (!existsSync(envPath)) return;
246
+
247
+ try {
248
+ const content = readFileSync(envPath, 'utf-8');
249
+ for (const line of content.split('\n')) {
250
+ const trimmed = line.trim();
251
+ if (!trimmed || trimmed.startsWith('#')) continue;
252
+ const eqIdx = trimmed.indexOf('=');
253
+ if (eqIdx === -1) continue;
254
+ const key = trimmed.slice(0, eqIdx).trim();
255
+ let value = trimmed.slice(eqIdx + 1).trim();
256
+ // .env 인용부호 제거 ("value" → value, 'value' → value)
257
+ value = value.replace(/^(['"])(.*)\1$/, '$2');
258
+ // 시스템 환경변수 우선 (17-config.md)
259
+ if (!(key in process.env)) {
260
+ process.env[key] = value;
261
+ }
262
+ }
263
+ } catch {} // .env 읽기 실패 시 무시
264
+ }
265
+
266
+ /**
267
+ * YAML 객체의 ${VAR} / ${VAR:default} 패턴 치환 (17-config.md)
268
+ * @param {object} obj
269
+ * @returns {object}
270
+ */
271
+ static resolveEnvVars(obj) {
272
+ if (typeof obj === 'string') {
273
+ return obj.replace(/\$\{(\w+)(?::([^}]*))?\}/g, (_, varName, defaultVal) => {
274
+ const envVal = process.env[varName];
275
+ if (envVal !== undefined) return envVal;
276
+ if (defaultVal !== undefined) return defaultVal;
277
+ throw new Error(`Required environment variable '${varName}' is not set`);
278
+ });
279
+ }
280
+ if (Array.isArray(obj)) return obj.map(item => Config.resolveEnvVars(item));
281
+ if (obj !== null && typeof obj === 'object') {
282
+ const result = {};
283
+ for (const [key, value] of Object.entries(obj)) {
284
+ result[key] = Config.resolveEnvVars(value);
285
+ }
286
+ return result;
287
+ }
288
+ return obj;
289
+ }
290
+
291
+ /**
292
+ * dot-notation으로 설정값 접근
293
+ * @param {string} path - 'app.auth.secret', 'database.main.host' 등
294
+ * @param {*} [defaultValue] - 키가 없을 때 반환값
295
+ * @returns {*}
296
+ */
297
+ get(path, defaultValue = undefined) {
298
+ if (this._cache.has(path)) return this._cache.get(path);
299
+
300
+ const parts = path.split('.');
301
+ let current = this._raw;
302
+
303
+ for (const part of parts) {
304
+ if (current == null || typeof current !== 'object') {
305
+ return defaultValue;
306
+ }
307
+ current = current[part];
308
+ }
309
+
310
+ if (current === undefined) return defaultValue;
311
+ this._cache.set(path, current);
312
+ return current;
313
+ }
314
+
315
+ /**
316
+ * 설정값 존재 여부
317
+ * @param {string} path
318
+ * @returns {boolean}
319
+ */
320
+ has(path) {
321
+ return this.get(path) !== undefined;
322
+ }
323
+
324
+ /**
325
+ * 전체 설정 반환 (읽기 전용)
326
+ * @returns {object}
327
+ */
328
+ all() {
329
+ return this._raw;
330
+ }
331
+ }