@lorion-org/runtime-config-node 1.0.0-beta.0 → 1.0.0-beta.1

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.
package/src/index.ts ADDED
@@ -0,0 +1,1063 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import Ajv from 'ajv';
5
+ import type { ErrorObject, Options as AjvOptions } from 'ajv';
6
+ import {
7
+ getRuntimeConfigScope,
8
+ projectSectionedRuntimeConfig,
9
+ resolveRuntimeConfigValueFromRuntimeConfig,
10
+ projectRuntimeConfigEnvVars,
11
+ toRuntimeConfigFragment,
12
+ runtimeEnvVarsToShellAssignments,
13
+ resolveRuntimeConfigValidationMode,
14
+ shouldRegisterRuntimeConfigValidationSchema,
15
+ shouldRequireRuntimeConfigAtStartup,
16
+ type ConfigVisibility,
17
+ type GetRuntimeConfigScopeOptions,
18
+ type ProjectRuntimeConfigEnvVarsOptions,
19
+ type RuntimeConfigFragment,
20
+ type RuntimeConfigFragmentMap,
21
+ type RuntimeConfigValidationPolicyInput,
22
+ type RuntimeConfigValidationMode,
23
+ type RuntimeConfigValidationSchemaRegistry,
24
+ type RuntimeEnvVars,
25
+ type RuntimeConfigSection,
26
+ type SectionedRuntimeConfig,
27
+ } from '@lorion-org/runtime-config';
28
+
29
+ export type RuntimeConfigPaths = {
30
+ varDir: string;
31
+ runtimeConfigDir: string;
32
+ scopeDir: string;
33
+ filePath: string;
34
+ };
35
+
36
+ export type RuntimeConfigPathOptions = {
37
+ fileName?: string;
38
+ runtimeConfigDirName?: string;
39
+ };
40
+
41
+ export type RuntimeConfigSource = RuntimeConfigPathOptions & {
42
+ varDir: string;
43
+ };
44
+
45
+ export type RuntimeConfigPathPatternSource = {
46
+ paths: string[];
47
+ };
48
+
49
+ export type RuntimeConfigSourceFile = {
50
+ configPath: string;
51
+ pattern: string;
52
+ scopeId: string;
53
+ };
54
+
55
+ export type ResolveRuntimeConfigSourceOptions = RuntimeConfigPathOptions & {
56
+ defaultVarDir?: string;
57
+ env?: Record<string, string | undefined>;
58
+ envKey?: string;
59
+ varDir?: string;
60
+ };
61
+
62
+ export type RuntimeConfigEnvFileOptions = RuntimeConfigPathOptions &
63
+ ProjectRuntimeConfigEnvVarsOptions;
64
+
65
+ export type WriteFileOptions = {
66
+ createDir?: boolean;
67
+ };
68
+
69
+ export type WriteJsonFileOptions = WriteFileOptions & {
70
+ pretty?: boolean;
71
+ };
72
+
73
+ export type ReadJsonFileOptions<T> = {
74
+ defaultValue?: T;
75
+ onParseError?: (error: unknown, filePath: string) => void;
76
+ };
77
+
78
+ export type RuntimeConfigSchemaValidationTarget = {
79
+ configPath: string;
80
+ schemaPath: string;
81
+ scopeId: string;
82
+ };
83
+
84
+ export type RuntimeConfigSchemaValidationErrorFormatter = (
85
+ target: RuntimeConfigSchemaValidationTarget,
86
+ validationError: ErrorObject,
87
+ ) => Error;
88
+
89
+ export type ValidateRuntimeConfigSchemaTargetsOptions = {
90
+ ajvOptions?: AjvOptions;
91
+ formatError?: RuntimeConfigSchemaValidationErrorFormatter;
92
+ };
93
+
94
+ export type RuntimeConfigListEntry = {
95
+ contextIds: string[];
96
+ path: string;
97
+ privateKeys: string[];
98
+ publicKeys: string[];
99
+ scopeId: string;
100
+ };
101
+
102
+ export type RuntimeConfigListOptions = RuntimeConfigPathOptions & {
103
+ contextInputKey?: string;
104
+ };
105
+
106
+ export type RuntimeConfigListResult = {
107
+ fileName: string;
108
+ scopes: RuntimeConfigListEntry[];
109
+ varDir: string;
110
+ };
111
+
112
+ export type RuntimeConfigShowResult = {
113
+ config?: RuntimeConfigFragment;
114
+ exists: boolean;
115
+ fileName: string;
116
+ path: string;
117
+ scopeId: string;
118
+ varDir: string;
119
+ };
120
+
121
+ export type RuntimeConfigProjectOptions = RuntimeConfigPathOptions & {
122
+ contextInputKey?: string;
123
+ contextOutputKey?: string;
124
+ scopeIds?: string[];
125
+ };
126
+
127
+ export type RuntimeConfigProjectResult = {
128
+ fileName: string;
129
+ runtimeConfig: SectionedRuntimeConfig;
130
+ scopeIds: string[];
131
+ varDir: string;
132
+ };
133
+
134
+ export type RuntimeConfigValueQueryOptions = RuntimeConfigProjectOptions & {
135
+ contextId?: string;
136
+ visibility?: ConfigVisibility;
137
+ };
138
+
139
+ export type RuntimeConfigValueResult = {
140
+ contextId?: string;
141
+ exists: boolean;
142
+ fileName: string;
143
+ key: string;
144
+ scopeId: string;
145
+ value?: unknown;
146
+ varDir: string;
147
+ visibility: ConfigVisibility;
148
+ };
149
+
150
+ export type RuntimeConfigScopeViewOptions = RuntimeConfigProjectOptions & {
151
+ contextId?: string;
152
+ visibility?: ConfigVisibility;
153
+ };
154
+
155
+ export type RuntimeConfigScopeViewResult = {
156
+ config: RuntimeConfigSection;
157
+ contextId?: string;
158
+ fileName: string;
159
+ scopeId: string;
160
+ varDir: string;
161
+ visibility: ConfigVisibility;
162
+ };
163
+
164
+ export type RuntimeConfigSchemaTargetInput = {
165
+ cwd?: string;
166
+ policy?: RuntimeConfigValidationPolicyInput;
167
+ scopeId: string;
168
+ };
169
+
170
+ export type RuntimeConfigValidationEntry = {
171
+ configPath: string;
172
+ schemaPath: string;
173
+ scopeId: string;
174
+ };
175
+
176
+ export type RuntimeConfigValidationSkippedEntry = {
177
+ configPath?: string;
178
+ reason: 'missing-config' | 'missing-schema' | 'missing-scope-dir';
179
+ schemaPath?: string;
180
+ scopeId: string;
181
+ };
182
+
183
+ export type RuntimeConfigValidationInvalidEntry = RuntimeConfigSchemaValidationTarget & {
184
+ error: Error;
185
+ };
186
+
187
+ export type ValidateRuntimeConfigScopesOptions = RuntimeConfigPathOptions & {
188
+ formatError?: RuntimeConfigSchemaValidationErrorFormatter;
189
+ schemaFileName?: string;
190
+ };
191
+
192
+ export type ValidateRuntimeConfigPatternSourceScopesOptions = Pick<
193
+ ValidateRuntimeConfigScopesOptions,
194
+ 'formatError' | 'schemaFileName'
195
+ >;
196
+
197
+ export type RuntimeConfigValidateResult = {
198
+ fileName: string;
199
+ invalid: RuntimeConfigValidationInvalidEntry[];
200
+ skipped: RuntimeConfigValidationSkippedEntry[];
201
+ validated: RuntimeConfigValidationEntry[];
202
+ varDir: string;
203
+ };
204
+
205
+ export type ReadRuntimeConfigSchemaRegistryOptions = {
206
+ schemaFileName?: string;
207
+ };
208
+
209
+ const defaultRuntimeConfigFileName = 'runtime.config.json';
210
+ const defaultRuntimeConfigDirName = 'runtime-config';
211
+ const defaultRuntimeConfigSchemaFileName = 'runtime-config.schema.json';
212
+
213
+ function toPosixPath(filePath: string): string {
214
+ return filePath.split(path.sep).join('/');
215
+ }
216
+
217
+ function escapeRegex(value: string): string {
218
+ return value.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
219
+ }
220
+
221
+ function countWildcards(pattern: string): number {
222
+ return (pattern.match(/\*/g) ?? []).length;
223
+ }
224
+
225
+ function assertSingleWildcardPattern(pattern: string): void {
226
+ const wildcardCount = countWildcards(pattern);
227
+
228
+ if (wildcardCount !== 1) {
229
+ throw new Error(
230
+ `RuntimeConfig path pattern must contain exactly one "*" wildcard: "${pattern}"`,
231
+ );
232
+ }
233
+ }
234
+
235
+ function createGlobSegmentRegex(segment: string): RegExp {
236
+ return new RegExp(`^${segment.split('*').map(escapeRegex).join('[^/\\\\]*')}$`);
237
+ }
238
+
239
+ function splitAbsolutePattern(pattern: string): {
240
+ root: string;
241
+ segments: string[];
242
+ } {
243
+ const absolutePattern = path.resolve(pattern);
244
+ const parsed = path.parse(absolutePattern);
245
+ const relativePattern = path.relative(parsed.root, absolutePattern);
246
+
247
+ return {
248
+ root: parsed.root,
249
+ segments: relativePattern.split(/[\\/]+/).filter(Boolean),
250
+ };
251
+ }
252
+
253
+ function expandRuntimeConfigPattern(pattern: string): string[] {
254
+ assertSingleWildcardPattern(pattern);
255
+
256
+ const { root, segments } = splitAbsolutePattern(pattern);
257
+ const visit = (currentDir: string, index: number): string[] => {
258
+ const segment = segments[index];
259
+ if (!segment) return [];
260
+
261
+ const isLast = index === segments.length - 1;
262
+
263
+ if (!segment.includes('*')) {
264
+ const nextPath = path.join(currentDir, segment);
265
+
266
+ if (isLast) return existsSync(nextPath) ? [nextPath] : [];
267
+ if (!existsSync(nextPath)) return [];
268
+
269
+ return visit(nextPath, index + 1);
270
+ }
271
+
272
+ if (!existsSync(currentDir)) return [];
273
+
274
+ const matcher = createGlobSegmentRegex(segment);
275
+
276
+ return readdirSync(currentDir, { withFileTypes: true })
277
+ .filter((entry) => entry.name !== 'node_modules' && matcher.test(entry.name))
278
+ .flatMap((entry) => {
279
+ const nextPath = path.join(currentDir, entry.name);
280
+
281
+ if (isLast) return entry.isFile() ? [nextPath] : [];
282
+ return entry.isDirectory() ? visit(nextPath, index + 1) : [];
283
+ });
284
+ };
285
+
286
+ return visit(root, 0);
287
+ }
288
+
289
+ function getRuntimeConfigScopeIdFromPattern(input: {
290
+ configPath: string;
291
+ pattern: string;
292
+ }): string {
293
+ assertSingleWildcardPattern(input.pattern);
294
+
295
+ const resolvedPattern = path.resolve(input.pattern);
296
+ const wildcardIndex = resolvedPattern.indexOf('*');
297
+ const beforeWildcard = resolvedPattern.slice(0, wildcardIndex);
298
+ const afterWildcard = resolvedPattern.slice(wildcardIndex + 1);
299
+ const configPath = path.resolve(input.configPath);
300
+
301
+ if (!configPath.startsWith(beforeWildcard) || !configPath.endsWith(afterWildcard)) {
302
+ throw new Error(
303
+ `RuntimeConfig file "${input.configPath}" does not match pattern "${input.pattern}"`,
304
+ );
305
+ }
306
+
307
+ return toPosixPath(
308
+ configPath.slice(beforeWildcard.length, configPath.length - afterWildcard.length),
309
+ ).replace(/^\/+|\/+$/g, '');
310
+ }
311
+
312
+ function getRuntimeConfigPatternPublicRoot(pattern: string): string {
313
+ assertSingleWildcardPattern(pattern);
314
+
315
+ const resolvedPattern = path.resolve(pattern);
316
+ const beforeWildcard = resolvedPattern.slice(0, resolvedPattern.indexOf('*'));
317
+ const wildcardParent = beforeWildcard.replace(/[\\/]+$/, '');
318
+
319
+ return path.resolve(wildcardParent, 'public');
320
+ }
321
+
322
+ function getFileName(options: RuntimeConfigPathOptions): string {
323
+ return options.fileName ?? defaultRuntimeConfigFileName;
324
+ }
325
+
326
+ function getRuntimeConfigDirName(options: RuntimeConfigPathOptions): string {
327
+ return options.runtimeConfigDirName ?? defaultRuntimeConfigDirName;
328
+ }
329
+
330
+ function getRuntimeConfigPathOptions(source: RuntimeConfigSource): RuntimeConfigPathOptions {
331
+ return {
332
+ ...(source.fileName ? { fileName: source.fileName } : {}),
333
+ ...(source.runtimeConfigDirName ? { runtimeConfigDirName: source.runtimeConfigDirName } : {}),
334
+ };
335
+ }
336
+
337
+ function getSchemaFileName(options: ValidateRuntimeConfigScopesOptions): string {
338
+ return options.schemaFileName ?? defaultRuntimeConfigSchemaFileName;
339
+ }
340
+
341
+ function formatMissingRuntimeConfigValidationError(
342
+ skipped: RuntimeConfigValidationSkippedEntry,
343
+ ): Error {
344
+ if (skipped.reason === 'missing-schema') {
345
+ return new Error(
346
+ [
347
+ `RuntimeConfig schema file missing for scope "${skipped.scopeId}".`,
348
+ ...(skipped.schemaPath ? [`missing schema file: ${skipped.schemaPath}`] : []),
349
+ ...(skipped.configPath ? [`runtime config file: ${skipped.configPath}`] : []),
350
+ ].join(' '),
351
+ );
352
+ }
353
+
354
+ if (skipped.reason === 'missing-config') {
355
+ return new Error(
356
+ [
357
+ `RuntimeConfig file missing for scope "${skipped.scopeId}".`,
358
+ ...(skipped.configPath ? [`expected runtime config file: ${skipped.configPath}`] : []),
359
+ ...(skipped.schemaPath ? [`schema file: ${skipped.schemaPath}`] : []),
360
+ ].join(' '),
361
+ );
362
+ }
363
+
364
+ const details = [
365
+ `RuntimeConfig validation target has no schema directory for scope "${skipped.scopeId}".`,
366
+ ];
367
+
368
+ if (skipped.configPath) details.push(`runtime config file: ${skipped.configPath}`);
369
+ if (skipped.schemaPath) details.push(`schema file: ${skipped.schemaPath}`);
370
+
371
+ return new Error(details.join(' '));
372
+ }
373
+
374
+ function shouldReportMissingRuntimeConfig(mode: RuntimeConfigValidationMode): boolean {
375
+ return mode !== 'optional';
376
+ }
377
+
378
+ function stripJsonBom(text: string): string {
379
+ return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
380
+ }
381
+
382
+ export function ensureParentDir(filePath: string): void {
383
+ const dirPath = path.dirname(filePath);
384
+
385
+ if (!existsSync(dirPath)) mkdirSync(dirPath, { recursive: true });
386
+ }
387
+
388
+ export function readTextFile(filePath: string): string | undefined {
389
+ return existsSync(filePath) ? readFileSync(filePath, 'utf8') : undefined;
390
+ }
391
+
392
+ export function writeTextFile(
393
+ filePath: string,
394
+ data: string,
395
+ options: WriteFileOptions = {},
396
+ ): void {
397
+ if (options.createDir !== false) ensureParentDir(filePath);
398
+
399
+ writeFileSync(filePath, data, 'utf8');
400
+ }
401
+
402
+ export function readJsonFile<T>(
403
+ filePath: string,
404
+ options: ReadJsonFileOptions<T> = {},
405
+ ): T | undefined {
406
+ const text = readTextFile(filePath);
407
+
408
+ if (text === undefined) return undefined;
409
+
410
+ try {
411
+ return JSON.parse(stripJsonBom(text)) as T;
412
+ } catch (error) {
413
+ options.onParseError?.(error, filePath);
414
+ return options.defaultValue;
415
+ }
416
+ }
417
+
418
+ export function readRequiredJsonFile<T>(filePath: string): T {
419
+ const text = readTextFile(filePath);
420
+
421
+ if (text === undefined) {
422
+ throw new Error(`RuntimeConfig JSON file not found: "${filePath}"`);
423
+ }
424
+
425
+ try {
426
+ return JSON.parse(stripJsonBom(text)) as T;
427
+ } catch (error) {
428
+ throw new Error(`RuntimeConfig JSON parse error in "${filePath}": ${String(error)}`);
429
+ }
430
+ }
431
+
432
+ export function writeJsonFile(
433
+ filePath: string,
434
+ data: unknown,
435
+ options: WriteJsonFileOptions = {},
436
+ ): void {
437
+ const spacing = options.pretty === false ? undefined : 2;
438
+
439
+ writeTextFile(filePath, `${JSON.stringify(data, undefined, spacing)}\n`, options);
440
+ }
441
+
442
+ export function resolveRuntimeConfigPaths(
443
+ varDir: string,
444
+ scopeId: string,
445
+ options: RuntimeConfigPathOptions = {},
446
+ ): RuntimeConfigPaths {
447
+ const runtimeConfigDir: string = path.join(varDir, getRuntimeConfigDirName(options));
448
+ const scopeDir: string = path.join(runtimeConfigDir, scopeId);
449
+ const filePath: string = path.join(scopeDir, getFileName(options));
450
+
451
+ return {
452
+ varDir,
453
+ runtimeConfigDir,
454
+ scopeDir,
455
+ filePath,
456
+ };
457
+ }
458
+
459
+ export function resolveRuntimeConfigSource(
460
+ options: ResolveRuntimeConfigSourceOptions = {},
461
+ ): RuntimeConfigSource {
462
+ const envKey = options.envKey ?? 'RUNTIME_CONFIG_VAR_DIR';
463
+ const varDir = String(
464
+ options.varDir ?? options.env?.[envKey] ?? options.defaultVarDir ?? '',
465
+ ).trim();
466
+
467
+ if (!varDir) {
468
+ throw new Error(`RuntimeConfig varDir not found. Pass varDir or set ${envKey}.`);
469
+ }
470
+
471
+ return {
472
+ varDir,
473
+ ...(options.fileName ? { fileName: options.fileName } : {}),
474
+ ...(options.runtimeConfigDirName ? { runtimeConfigDirName: options.runtimeConfigDirName } : {}),
475
+ };
476
+ }
477
+
478
+ export function resolveRuntimeConfigFilePath(
479
+ varDir: string,
480
+ scopeId: string,
481
+ filename: string,
482
+ options: RuntimeConfigPathOptions = {},
483
+ ): string {
484
+ const { scopeDir } = resolveRuntimeConfigPaths(varDir, scopeId, options);
485
+
486
+ return path.join(scopeDir, filename);
487
+ }
488
+
489
+ export function resolveRuntimeConfigScopeFilePath(
490
+ source: RuntimeConfigSource,
491
+ scopeId: string,
492
+ fileName: string,
493
+ ): string {
494
+ return resolveRuntimeConfigFilePath(
495
+ source.varDir,
496
+ scopeId,
497
+ fileName,
498
+ getRuntimeConfigPathOptions(source),
499
+ );
500
+ }
501
+
502
+ export function readRuntimeConfigScopeJson<T>(
503
+ source: RuntimeConfigSource,
504
+ scopeId: string,
505
+ fileName: string,
506
+ options: ReadJsonFileOptions<T> = {},
507
+ ): T | undefined {
508
+ return readJsonFile<T>(resolveRuntimeConfigScopeFilePath(source, scopeId, fileName), options);
509
+ }
510
+
511
+ export function writeRuntimeConfigScopeJson(
512
+ source: RuntimeConfigSource,
513
+ scopeId: string,
514
+ fileName: string,
515
+ data: unknown,
516
+ options: WriteJsonFileOptions = {},
517
+ ): void {
518
+ writeJsonFile(resolveRuntimeConfigScopeFilePath(source, scopeId, fileName), data, options);
519
+ }
520
+
521
+ export function resolveRuntimeConfigPublicRootPath(source: RuntimeConfigSource): string {
522
+ return path.resolve(
523
+ source.varDir,
524
+ getRuntimeConfigDirName(getRuntimeConfigPathOptions(source)),
525
+ 'public',
526
+ );
527
+ }
528
+
529
+ export function resolveRuntimeConfigPublicFilePath(
530
+ source: RuntimeConfigSource,
531
+ relativePath: string,
532
+ ): string {
533
+ return path.resolve(resolveRuntimeConfigPublicRootPath(source), relativePath);
534
+ }
535
+
536
+ export function resolveRuntimeConfigSourceFiles(
537
+ source: RuntimeConfigPathPatternSource,
538
+ ): RuntimeConfigSourceFile[] {
539
+ return source.paths
540
+ .flatMap((pattern) =>
541
+ expandRuntimeConfigPattern(pattern).map((configPath) => ({
542
+ configPath,
543
+ pattern,
544
+ scopeId: getRuntimeConfigScopeIdFromPattern({ configPath, pattern }),
545
+ })),
546
+ )
547
+ .filter((entry, index, entries) => {
548
+ return entries.findIndex((candidate) => candidate.configPath === entry.configPath) === index;
549
+ })
550
+ .sort((left, right) => left.configPath.localeCompare(right.configPath));
551
+ }
552
+
553
+ export function loadRuntimeConfigSourceTree(
554
+ source: RuntimeConfigPathPatternSource,
555
+ ): RuntimeConfigFragmentMap {
556
+ const fragments: RuntimeConfigFragmentMap = new Map();
557
+
558
+ for (const entry of resolveRuntimeConfigSourceFiles(source)) {
559
+ const config = readJsonFile<RuntimeConfigFragment>(entry.configPath);
560
+
561
+ if (config) fragments.set(entry.scopeId, config);
562
+ }
563
+
564
+ return fragments;
565
+ }
566
+
567
+ export function resolveRuntimeConfigSourcePublicRootPath(
568
+ source: RuntimeConfigPathPatternSource,
569
+ ): string | undefined {
570
+ const pattern = source.paths[0];
571
+
572
+ return pattern ? getRuntimeConfigPatternPublicRoot(pattern) : undefined;
573
+ }
574
+
575
+ export function listRuntimeConfigScopeFiles(
576
+ varDir: string,
577
+ scopeId: string,
578
+ subdir: string,
579
+ options: RuntimeConfigPathOptions & { extension?: string } = {},
580
+ ): string[] {
581
+ const dirPath = resolveRuntimeConfigFilePath(varDir, scopeId, subdir, options);
582
+
583
+ if (!existsSync(dirPath)) return [];
584
+
585
+ return readdirSync(dirPath, { withFileTypes: true })
586
+ .filter((entry) => entry.isFile())
587
+ .map((entry) => entry.name)
588
+ .filter((name) => (options.extension ? name.endsWith(options.extension) : true));
589
+ }
590
+
591
+ export function collectRuntimeConfigFragmentFiles(
592
+ runtimeConfigDir: string,
593
+ options: RuntimeConfigPathOptions = {},
594
+ ): string[] {
595
+ const fileName = getFileName(options);
596
+ const files: string[] = [];
597
+
598
+ if (!existsSync(runtimeConfigDir)) return files;
599
+
600
+ const walk = (currentPath: string): void => {
601
+ for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
602
+ const absolutePath = path.resolve(currentPath, entry.name);
603
+
604
+ if (entry.isDirectory()) {
605
+ walk(absolutePath);
606
+ continue;
607
+ }
608
+
609
+ if (entry.isFile() && entry.name === fileName) {
610
+ files.push(absolutePath);
611
+ }
612
+ }
613
+ };
614
+
615
+ walk(runtimeConfigDir);
616
+
617
+ return files.sort((left, right) => left.localeCompare(right));
618
+ }
619
+
620
+ export function parseRuntimeConfigFragmentFiles(
621
+ filePaths: string[],
622
+ runtimeConfigDir: string,
623
+ options: RuntimeConfigPathOptions = {},
624
+ ): RuntimeConfigFragmentMap {
625
+ const fileName = getFileName(options);
626
+ const fragments: RuntimeConfigFragmentMap = new Map();
627
+
628
+ for (const filePath of filePaths) {
629
+ const relativePath = toPosixPath(path.relative(runtimeConfigDir, filePath));
630
+ const scopeId = relativePath.replace(
631
+ new RegExp(`/${fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`),
632
+ '',
633
+ );
634
+ const config = readJsonFile<RuntimeConfigFragment>(filePath);
635
+
636
+ if (config) fragments.set(scopeId, config);
637
+ }
638
+
639
+ return fragments;
640
+ }
641
+
642
+ export function loadRuntimeConfigFragment(
643
+ varDir: string,
644
+ scopeId: string,
645
+ options: RuntimeConfigPathOptions = {},
646
+ ): RuntimeConfigFragment | undefined {
647
+ const { filePath } = resolveRuntimeConfigPaths(varDir, scopeId, options);
648
+
649
+ return readJsonFile<RuntimeConfigFragment>(filePath);
650
+ }
651
+
652
+ export function writeRuntimeConfigFragment(
653
+ varDir: string,
654
+ scopeId: string,
655
+ config: RuntimeConfigFragment,
656
+ options: RuntimeConfigPathOptions & WriteJsonFileOptions = {},
657
+ ): void {
658
+ const { filePath } = resolveRuntimeConfigPaths(varDir, scopeId, options);
659
+
660
+ writeJsonFile(filePath, config, options);
661
+ }
662
+
663
+ function formatRuntimeConfigSchemaValidationError(
664
+ target: RuntimeConfigSchemaValidationTarget,
665
+ validationError: ErrorObject,
666
+ ): Error {
667
+ const jsonPath = validationError.instancePath || '/';
668
+ const ajvError = `${validationError.keyword}${validationError.message ? `: ${validationError.message}` : ''}`;
669
+
670
+ return new Error(
671
+ [
672
+ 'RuntimeConfig schema validation failed.',
673
+ `Scope: ${target.scopeId}`,
674
+ `File: ${target.configPath}`,
675
+ `JSON path: ${jsonPath}`,
676
+ `Schema error: ${ajvError}`,
677
+ ].join('\n'),
678
+ );
679
+ }
680
+
681
+ export function validateRuntimeConfigSchemaTargets(
682
+ targets: RuntimeConfigSchemaValidationTarget[],
683
+ options: ValidateRuntimeConfigSchemaTargetsOptions = {},
684
+ ): void {
685
+ const invalid = collectRuntimeConfigSchemaTargetValidationErrors(targets, options);
686
+
687
+ if (invalid[0]) {
688
+ throw invalid[0].error;
689
+ }
690
+ }
691
+
692
+ export function collectRuntimeConfigSchemaTargetValidationErrors(
693
+ targets: RuntimeConfigSchemaValidationTarget[],
694
+ options: ValidateRuntimeConfigSchemaTargetsOptions = {},
695
+ ): RuntimeConfigValidationInvalidEntry[] {
696
+ const ajv = new Ajv({
697
+ strict: false,
698
+ allErrors: false,
699
+ ...options.ajvOptions,
700
+ });
701
+ const formatError = options.formatError ?? formatRuntimeConfigSchemaValidationError;
702
+ const invalid: RuntimeConfigValidationInvalidEntry[] = [];
703
+
704
+ for (const target of targets) {
705
+ if (!existsSync(target.schemaPath) || !existsSync(target.configPath)) {
706
+ continue;
707
+ }
708
+
709
+ const schema = readRequiredJsonFile<object>(target.schemaPath);
710
+ const runtimeConfig = readRequiredJsonFile<object>(target.configPath);
711
+ const validate = ajv.compile(schema);
712
+ const isValid = validate(runtimeConfig);
713
+
714
+ if (!isValid) {
715
+ const validationError = validate.errors?.[0];
716
+ if (validationError) {
717
+ invalid.push({
718
+ ...target,
719
+ error: formatError(target, validationError),
720
+ });
721
+ continue;
722
+ }
723
+
724
+ invalid.push({
725
+ ...target,
726
+ error: new Error(
727
+ `RuntimeConfig schema validation failed for "${target.scopeId}" (${target.configPath})`,
728
+ ),
729
+ });
730
+ }
731
+ }
732
+
733
+ return invalid;
734
+ }
735
+
736
+ export function loadRuntimeConfigTree(
737
+ varDir: string,
738
+ options: RuntimeConfigPathOptions = {},
739
+ ): RuntimeConfigFragmentMap {
740
+ const runtimeConfigDir: string = path.join(varDir, getRuntimeConfigDirName(options));
741
+ const filePaths = collectRuntimeConfigFragmentFiles(runtimeConfigDir, options);
742
+
743
+ return parseRuntimeConfigFragmentFiles(filePaths, runtimeConfigDir, options);
744
+ }
745
+
746
+ export function loadRuntimeConfigEnvVars(
747
+ varDir: string,
748
+ options: RuntimeConfigEnvFileOptions = {},
749
+ ): RuntimeEnvVars {
750
+ const { fileName, runtimeConfigDirName, ...envOptions } = options;
751
+
752
+ return projectRuntimeConfigEnvVars(
753
+ loadRuntimeConfigTree(varDir, {
754
+ ...(fileName ? { fileName } : {}),
755
+ ...(runtimeConfigDirName ? { runtimeConfigDirName } : {}),
756
+ }),
757
+ envOptions,
758
+ );
759
+ }
760
+
761
+ export function loadRuntimeConfigShellAssignments(
762
+ varDir: string,
763
+ options: RuntimeConfigEnvFileOptions = {},
764
+ ): string {
765
+ return runtimeEnvVarsToShellAssignments(loadRuntimeConfigEnvVars(varDir, options));
766
+ }
767
+
768
+ export function listRuntimeConfigFragments(
769
+ varDir: string,
770
+ options: RuntimeConfigListOptions = {},
771
+ ): RuntimeConfigListResult {
772
+ const { contextInputKey, ...pathOptions } = options;
773
+ const fragments = loadRuntimeConfigTree(varDir, pathOptions);
774
+ const scopes = Array.from(fragments.entries())
775
+ .map(([scopeId, config]) => {
776
+ const fragment = contextInputKey
777
+ ? toRuntimeConfigFragment(config, { contextInputKey })
778
+ : config;
779
+
780
+ return {
781
+ contextIds: Object.keys(fragment.contexts ?? {}).sort(),
782
+ path: resolveRuntimeConfigPaths(varDir, scopeId, pathOptions).filePath,
783
+ privateKeys: Object.keys(config.private ?? {}).sort(),
784
+ publicKeys: Object.keys(config.public ?? {}).sort(),
785
+ scopeId,
786
+ };
787
+ })
788
+ .sort((left, right) => left.scopeId.localeCompare(right.scopeId));
789
+
790
+ return {
791
+ fileName: getFileName(pathOptions),
792
+ scopes,
793
+ varDir,
794
+ };
795
+ }
796
+
797
+ export function showRuntimeConfigFragment(
798
+ varDir: string,
799
+ scopeId: string,
800
+ options: RuntimeConfigPathOptions = {},
801
+ ): RuntimeConfigShowResult {
802
+ const normalizedScopeId = scopeId.trim();
803
+ const config = normalizedScopeId
804
+ ? loadRuntimeConfigFragment(varDir, normalizedScopeId, options)
805
+ : undefined;
806
+
807
+ return {
808
+ fileName: getFileName(options),
809
+ path: resolveRuntimeConfigPaths(varDir, normalizedScopeId, options).filePath,
810
+ scopeId: normalizedScopeId,
811
+ varDir,
812
+ exists: Boolean(config),
813
+ ...(config ? { config } : {}),
814
+ };
815
+ }
816
+
817
+ export function projectRuntimeConfigTree(
818
+ varDir: string,
819
+ options: RuntimeConfigProjectOptions = {},
820
+ ): RuntimeConfigProjectResult {
821
+ const fragments = loadRuntimeConfigTree(varDir, options);
822
+ const scopeIds = options.scopeIds?.length
823
+ ? options.scopeIds
824
+ : Array.from(fragments.keys()).sort();
825
+
826
+ return {
827
+ fileName: getFileName(options),
828
+ runtimeConfig: projectSectionedRuntimeConfig(fragments, {
829
+ ...(options.contextInputKey ? { contextInputKey: options.contextInputKey } : {}),
830
+ ...(options.contextOutputKey ? { contextOutputKey: options.contextOutputKey } : {}),
831
+ scopeIds,
832
+ }),
833
+ scopeIds,
834
+ varDir,
835
+ };
836
+ }
837
+
838
+ export function getRuntimeConfigValue(
839
+ varDir: string,
840
+ scopeId: string,
841
+ key: string,
842
+ options: RuntimeConfigValueQueryOptions = {},
843
+ ): RuntimeConfigValueResult {
844
+ const normalizedScopeId = scopeId.trim();
845
+ const normalizedKey = key.trim();
846
+ const visibility = options.visibility ?? 'public';
847
+ const runtimeConfig = projectRuntimeConfigTree(varDir, {
848
+ ...options,
849
+ scopeIds: normalizedScopeId ? [normalizedScopeId] : [],
850
+ }).runtimeConfig;
851
+ const value = resolveRuntimeConfigValueFromRuntimeConfig(
852
+ runtimeConfig,
853
+ normalizedScopeId,
854
+ normalizedKey,
855
+ {
856
+ ...(options.contextId ? { contextId: options.contextId } : {}),
857
+ ...(options.contextOutputKey ? { contextOutputKey: options.contextOutputKey } : {}),
858
+ visibility,
859
+ },
860
+ );
861
+
862
+ return {
863
+ fileName: getFileName(options),
864
+ key: normalizedKey,
865
+ scopeId: normalizedScopeId,
866
+ varDir,
867
+ visibility,
868
+ ...(options.contextId ? { contextId: options.contextId } : {}),
869
+ exists: value !== undefined,
870
+ ...(value !== undefined ? { value } : {}),
871
+ };
872
+ }
873
+
874
+ export function getRuntimeConfigScopeView(
875
+ varDir: string,
876
+ scopeId: string,
877
+ options: RuntimeConfigScopeViewOptions = {},
878
+ ): RuntimeConfigScopeViewResult {
879
+ const normalizedScopeId = scopeId.trim();
880
+ const visibility = options.visibility ?? 'public';
881
+ const runtimeConfig = projectRuntimeConfigTree(varDir, {
882
+ ...options,
883
+ scopeIds: normalizedScopeId ? [normalizedScopeId] : [],
884
+ }).runtimeConfig;
885
+ const scopeOptions: GetRuntimeConfigScopeOptions = {
886
+ ...(options.contextId ? { contextId: options.contextId } : {}),
887
+ ...(options.contextOutputKey ? { contextOutputKey: options.contextOutputKey } : {}),
888
+ visibility,
889
+ };
890
+
891
+ return {
892
+ config: getRuntimeConfigScope(runtimeConfig, normalizedScopeId, scopeOptions),
893
+ fileName: getFileName(options),
894
+ scopeId: normalizedScopeId,
895
+ varDir,
896
+ visibility,
897
+ ...(options.contextId ? { contextId: options.contextId } : {}),
898
+ };
899
+ }
900
+
901
+ export function validateRuntimeConfigScopes(
902
+ varDir: string,
903
+ targets: RuntimeConfigSchemaTargetInput[],
904
+ options: ValidateRuntimeConfigScopesOptions = {},
905
+ ): RuntimeConfigValidateResult {
906
+ const schemaFileName = getSchemaFileName(options);
907
+ const result: RuntimeConfigValidateResult = {
908
+ fileName: getFileName(options),
909
+ invalid: [],
910
+ skipped: [],
911
+ validated: [],
912
+ varDir,
913
+ };
914
+
915
+ for (const target of targets) {
916
+ if (!shouldRegisterRuntimeConfigValidationSchema(target.policy)) continue;
917
+
918
+ const scopeId = target.scopeId.trim();
919
+ if (!scopeId) continue;
920
+
921
+ const mode = resolveRuntimeConfigValidationMode(target.policy);
922
+ const configPath = resolveRuntimeConfigPaths(varDir, scopeId, options).filePath;
923
+ const schemaPath = target.cwd ? path.resolve(target.cwd, schemaFileName) : undefined;
924
+
925
+ if (!target.cwd || !schemaPath) {
926
+ if (shouldReportMissingRuntimeConfig(mode)) {
927
+ result.skipped.push({ configPath, reason: 'missing-scope-dir', scopeId });
928
+ }
929
+ continue;
930
+ }
931
+
932
+ if (!existsSync(configPath)) {
933
+ if (shouldReportMissingRuntimeConfig(mode)) {
934
+ result.skipped.push({ configPath, reason: 'missing-config', schemaPath, scopeId });
935
+ }
936
+ continue;
937
+ }
938
+
939
+ if (!existsSync(schemaPath)) {
940
+ result.skipped.push({ configPath, reason: 'missing-schema', schemaPath, scopeId });
941
+ continue;
942
+ }
943
+
944
+ result.validated.push({ configPath, schemaPath, scopeId });
945
+ }
946
+
947
+ result.invalid = collectRuntimeConfigSchemaTargetValidationErrors(result.validated, {
948
+ ...(options.formatError ? { formatError: options.formatError } : {}),
949
+ });
950
+
951
+ return result;
952
+ }
953
+
954
+ export function readRuntimeConfigSchemaRegistry(
955
+ targets: RuntimeConfigSchemaTargetInput[],
956
+ options: ReadRuntimeConfigSchemaRegistryOptions = {},
957
+ ): RuntimeConfigValidationSchemaRegistry {
958
+ const schemaFileName = options.schemaFileName ?? defaultRuntimeConfigSchemaFileName;
959
+ const entries = targets
960
+ .filter((target) => shouldRegisterRuntimeConfigValidationSchema(target.policy))
961
+ .flatMap((target) => {
962
+ const scopeId = target.scopeId.trim();
963
+ if (!scopeId || !target.cwd) return [];
964
+
965
+ const schemaPath = path.resolve(target.cwd, schemaFileName);
966
+ if (!existsSync(schemaPath)) return [];
967
+
968
+ return [[scopeId, readRequiredJsonFile<object>(schemaPath)] as const];
969
+ });
970
+
971
+ return Object.fromEntries(entries);
972
+ }
973
+
974
+ export function assertRequiredRuntimeConfigValidationTargets(
975
+ result: RuntimeConfigValidateResult,
976
+ targets: RuntimeConfigSchemaTargetInput[],
977
+ ): void {
978
+ const requiredScopeIds = new Set(
979
+ targets
980
+ .filter((target) => shouldRequireRuntimeConfigAtStartup(target.policy))
981
+ .map((target) => target.scopeId.trim())
982
+ .filter(Boolean),
983
+ );
984
+ const requiredFailures = [
985
+ ...result.skipped
986
+ .filter((entry) => requiredScopeIds.has(entry.scopeId))
987
+ .map(formatMissingRuntimeConfigValidationError),
988
+ ...result.invalid
989
+ .filter((entry) => requiredScopeIds.has(entry.scopeId))
990
+ .map((entry) => entry.error),
991
+ ];
992
+
993
+ if (requiredFailures.length) {
994
+ throw new Error(
995
+ [
996
+ 'RuntimeConfig startup validation failed.',
997
+ ...requiredFailures.map((error) => error.message),
998
+ ].join('\n\n'),
999
+ );
1000
+ }
1001
+ }
1002
+
1003
+ export function validateRuntimeConfigSourceScopes(
1004
+ source: RuntimeConfigPathPatternSource,
1005
+ targets: RuntimeConfigSchemaTargetInput[],
1006
+ options: ValidateRuntimeConfigPatternSourceScopesOptions = {},
1007
+ ): RuntimeConfigValidateResult {
1008
+ const schemaFileName = getSchemaFileName(options);
1009
+ const filesByScope = new Map<string, string>();
1010
+ const result: RuntimeConfigValidateResult = {
1011
+ fileName: source.paths[0] ?? '',
1012
+ invalid: [],
1013
+ skipped: [],
1014
+ validated: [],
1015
+ varDir: '',
1016
+ };
1017
+
1018
+ for (const file of resolveRuntimeConfigSourceFiles(source)) {
1019
+ filesByScope.set(file.scopeId, file.configPath);
1020
+ }
1021
+
1022
+ for (const target of targets) {
1023
+ if (!shouldRegisterRuntimeConfigValidationSchema(target.policy)) continue;
1024
+
1025
+ const scopeId = target.scopeId.trim();
1026
+ if (!scopeId) continue;
1027
+
1028
+ const mode = resolveRuntimeConfigValidationMode(target.policy);
1029
+ const configPath = filesByScope.get(scopeId);
1030
+ const schemaPath = target.cwd ? path.resolve(target.cwd, schemaFileName) : undefined;
1031
+
1032
+ if (!target.cwd || !schemaPath) {
1033
+ if (shouldReportMissingRuntimeConfig(mode)) {
1034
+ result.skipped.push({
1035
+ ...(configPath ? { configPath } : {}),
1036
+ reason: 'missing-scope-dir',
1037
+ scopeId,
1038
+ });
1039
+ }
1040
+ continue;
1041
+ }
1042
+
1043
+ if (!configPath) {
1044
+ if (shouldReportMissingRuntimeConfig(mode)) {
1045
+ result.skipped.push({ reason: 'missing-config', schemaPath, scopeId });
1046
+ }
1047
+ continue;
1048
+ }
1049
+
1050
+ if (!existsSync(schemaPath)) {
1051
+ result.skipped.push({ configPath, reason: 'missing-schema', schemaPath, scopeId });
1052
+ continue;
1053
+ }
1054
+
1055
+ result.validated.push({ configPath, schemaPath, scopeId });
1056
+ }
1057
+
1058
+ result.invalid = collectRuntimeConfigSchemaTargetValidationErrors(result.validated, {
1059
+ ...(options.formatError ? { formatError: options.formatError } : {}),
1060
+ });
1061
+
1062
+ return result;
1063
+ }