@ontrails/config 0.2.0
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/CHANGELOG.md +366 -0
- package/README.md +262 -0
- package/package.json +37 -0
- package/src/app-config.ts +318 -0
- package/src/collect.ts +117 -0
- package/src/compose.ts +47 -0
- package/src/config-resource.ts +32 -0
- package/src/define-config.ts +126 -0
- package/src/derive/env.ts +108 -0
- package/src/derive/example.ts +222 -0
- package/src/derive/helpers.ts +158 -0
- package/src/derive/index.ts +3 -0
- package/src/derive/json-schema.ts +137 -0
- package/src/derive-fields.ts +252 -0
- package/src/derive-provenance.ts +240 -0
- package/src/doctor.ts +238 -0
- package/src/extensions.ts +51 -0
- package/src/index.ts +74 -0
- package/src/merge.ts +43 -0
- package/src/path-boundary.ts +40 -0
- package/src/ref.ts +38 -0
- package/src/registry.ts +33 -0
- package/src/resolve.ts +196 -0
- package/src/secret-heuristics.ts +13 -0
- package/src/trails/config-check.ts +95 -0
- package/src/trails/config-describe.ts +44 -0
- package/src/trails/config-init.ts +96 -0
- package/src/trails-config-file.ts +136 -0
- package/src/trails-conventions.ts +240 -0
- package/src/workspace-config-collection.ts +371 -0
- package/src/workspace-config-source.ts +532 -0
- package/src/workspace-config.ts +552 -0
- package/src/zod-utils.ts +152 -0
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config resolution engine — merges config from multiple sources through
|
|
3
|
+
* a deterministic stack: defaults → base → profile → local → env.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { z } from 'zod';
|
|
7
|
+
|
|
8
|
+
import { Result, ValidationError } from '@ontrails/core';
|
|
9
|
+
|
|
10
|
+
import { collectConfigMeta } from './collect.js';
|
|
11
|
+
import { deepMerge } from './merge.js';
|
|
12
|
+
import {
|
|
13
|
+
coerceEnvValue,
|
|
14
|
+
getSchemaAtPath,
|
|
15
|
+
isZodContainer,
|
|
16
|
+
zodDef,
|
|
17
|
+
} from './zod-utils.js';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Types
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/** Options for resolving config through the full stack. */
|
|
24
|
+
export interface DeriveConfigOptions<T extends z.ZodType> {
|
|
25
|
+
readonly schema: T;
|
|
26
|
+
readonly base?: Record<string, unknown> | undefined;
|
|
27
|
+
readonly profiles?: Record<string, Record<string, unknown>> | undefined;
|
|
28
|
+
readonly profile?: string | undefined;
|
|
29
|
+
readonly localOverrides?: Record<string, unknown> | undefined;
|
|
30
|
+
readonly env?: Record<string, string | undefined> | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Path utilities
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/** Navigate one step of a nested object, creating an intermediate if needed. */
|
|
38
|
+
const navigateOrCreate = (
|
|
39
|
+
current: Record<string, unknown>,
|
|
40
|
+
key: string
|
|
41
|
+
): Record<string, unknown> => {
|
|
42
|
+
const next = current[key];
|
|
43
|
+
if (typeof next === 'object' && next !== null && !Array.isArray(next)) {
|
|
44
|
+
return next as Record<string, unknown>;
|
|
45
|
+
}
|
|
46
|
+
const nested: Record<string, unknown> = {};
|
|
47
|
+
current[key] = nested;
|
|
48
|
+
return nested;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Ensure a nested path exists in an object, creating intermediates as needed. */
|
|
52
|
+
const ensurePath = (
|
|
53
|
+
obj: Record<string, unknown>,
|
|
54
|
+
parts: readonly string[]
|
|
55
|
+
): Record<string, unknown> => {
|
|
56
|
+
let current = obj;
|
|
57
|
+
for (const part of parts) {
|
|
58
|
+
current = navigateOrCreate(current, part);
|
|
59
|
+
}
|
|
60
|
+
return current;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Set a value at a dot-separated path in a plain object. */
|
|
64
|
+
const setAtPath = (
|
|
65
|
+
obj: Record<string, unknown>,
|
|
66
|
+
path: string,
|
|
67
|
+
value: unknown
|
|
68
|
+
): void => {
|
|
69
|
+
const parts = path.split('.');
|
|
70
|
+
const parent = ensurePath(obj, parts.slice(0, -1));
|
|
71
|
+
parent[parts.at(-1) as string] = value;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Env overlay
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/** Coerce and set a single env override into the result object. */
|
|
79
|
+
const applyOneEnvOverride = (
|
|
80
|
+
result: Record<string, unknown>,
|
|
81
|
+
schema: z.ZodType,
|
|
82
|
+
path: string,
|
|
83
|
+
envValue: string
|
|
84
|
+
): void => {
|
|
85
|
+
const fieldSchema = getSchemaAtPath(schema, path);
|
|
86
|
+
if (fieldSchema && isZodContainer(fieldSchema)) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const coerced = fieldSchema
|
|
90
|
+
? coerceEnvValue(envValue, fieldSchema)
|
|
91
|
+
: envValue;
|
|
92
|
+
setAtPath(result, path, coerced);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Resolve a single env binding: look up the var, apply if present. */
|
|
96
|
+
const resolveEnvBinding = (
|
|
97
|
+
result: Record<string, unknown>,
|
|
98
|
+
schema: z.ZodType,
|
|
99
|
+
path: string,
|
|
100
|
+
envVar: string,
|
|
101
|
+
envVars: Record<string, string | undefined>
|
|
102
|
+
): void => {
|
|
103
|
+
const envValue = envVars[envVar];
|
|
104
|
+
if (envValue !== undefined) {
|
|
105
|
+
applyOneEnvOverride(result, schema, path, envValue);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Apply env var overrides based on schema metadata. */
|
|
110
|
+
const applyEnvOverrides = (
|
|
111
|
+
merged: Record<string, unknown>,
|
|
112
|
+
schema: z.ZodType,
|
|
113
|
+
envVars: Record<string, string | undefined>
|
|
114
|
+
): Record<string, unknown> => {
|
|
115
|
+
if (zodDef(schema)['type'] !== 'object') {
|
|
116
|
+
return merged;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const meta = collectConfigMeta(
|
|
120
|
+
schema as z.ZodObject<Record<string, z.ZodType>>
|
|
121
|
+
);
|
|
122
|
+
const result = deepMerge({}, merged);
|
|
123
|
+
|
|
124
|
+
for (const [path, fieldMeta] of meta) {
|
|
125
|
+
if (fieldMeta.env) {
|
|
126
|
+
resolveEnvBinding(result, schema, path, fieldMeta.env, envVars);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return result;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Merge pipeline
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/** Apply the layered merge: base → profile → local overrides. */
|
|
138
|
+
const mergeLayers = (
|
|
139
|
+
base: Record<string, unknown> | undefined,
|
|
140
|
+
profiles: Record<string, Record<string, unknown>> | undefined,
|
|
141
|
+
profile: string | undefined,
|
|
142
|
+
localOverrides: Record<string, unknown> | undefined
|
|
143
|
+
): Record<string, unknown> => {
|
|
144
|
+
let merged: Record<string, unknown> = {};
|
|
145
|
+
if (base) {
|
|
146
|
+
merged = deepMerge(merged, base);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const selected = profile && profiles ? profiles[profile] : undefined;
|
|
150
|
+
if (selected) {
|
|
151
|
+
merged = deepMerge(merged, selected);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (localOverrides) {
|
|
155
|
+
merged = deepMerge(merged, localOverrides);
|
|
156
|
+
}
|
|
157
|
+
return merged;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** Format Zod issues into a human-readable error message. */
|
|
161
|
+
const formatValidationError = (
|
|
162
|
+
issues: readonly { path: PropertyKey[]; message: string }[]
|
|
163
|
+
): string =>
|
|
164
|
+
`Config validation failed: ${issues.map((i) => `${String(i.path.join('.'))}: ${i.message}`).join(', ')}`;
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Public API
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Derive config through the full stack: defaults → base → profile → local → env.
|
|
172
|
+
* Returns `Result.ok` with the validated config, or `Result.err` on validation failure.
|
|
173
|
+
*/
|
|
174
|
+
export const deriveConfig = <T extends z.ZodType>(
|
|
175
|
+
options: DeriveConfigOptions<T>
|
|
176
|
+
): Result<z.infer<T>, Error> => {
|
|
177
|
+
let merged = mergeLayers(
|
|
178
|
+
options.base,
|
|
179
|
+
options.profiles,
|
|
180
|
+
options.profile,
|
|
181
|
+
options.localOverrides
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
if (options.env) {
|
|
185
|
+
merged = applyEnvOverrides(merged, options.schema, options.env);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const parsed = options.schema.safeParse(merged);
|
|
189
|
+
if (parsed.success) {
|
|
190
|
+
return Result.ok(parsed.data as z.infer<T>);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return Result.err(
|
|
194
|
+
new ValidationError(formatValidationError(parsed.error.issues))
|
|
195
|
+
);
|
|
196
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heuristic detection of secret env var names.
|
|
3
|
+
*
|
|
4
|
+
* Matches common suffixes like `_SECRET`, `_TOKEN`, `_KEY`, `_PASSWORD`,
|
|
5
|
+
* and `_CREDENTIALS` so that generated `.env.example` files and provenance
|
|
6
|
+
* output can redact likely-secret values even without explicit `secret()`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const SECRET_PATTERN = /_SECRET$|_TOKEN$|_KEY$|_PASSWORD$|_CREDENTIALS$/i;
|
|
10
|
+
|
|
11
|
+
/** Return true when `envName` looks like it holds a secret value. */
|
|
12
|
+
export const isLikelySecret = (envName: string): boolean =>
|
|
13
|
+
SECRET_PATTERN.test(envName);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infrastructure trail that validates config values against a schema.
|
|
3
|
+
*
|
|
4
|
+
* Returns structured field reports indicating which fields are valid,
|
|
5
|
+
* missing, invalid, deprecated, or using defaults.
|
|
6
|
+
*/
|
|
7
|
+
import { Result, trail } from '@ontrails/core';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
|
|
10
|
+
import { configResource } from '../config-resource.js';
|
|
11
|
+
import { collectConfigMeta } from '../collect.js';
|
|
12
|
+
import { checkConfig } from '../doctor.js';
|
|
13
|
+
import { deepMerge } from '../merge.js';
|
|
14
|
+
import { isLikelySecret } from '../secret-heuristics.js';
|
|
15
|
+
|
|
16
|
+
const fieldReportSchema = z.object({
|
|
17
|
+
message: z.string(),
|
|
18
|
+
path: z.string(),
|
|
19
|
+
redacted: z.boolean().optional(),
|
|
20
|
+
status: z.enum(['valid', 'missing', 'invalid', 'deprecated', 'default']),
|
|
21
|
+
value: z.unknown().optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const outputSchema = z.object({
|
|
25
|
+
fields: z.array(fieldReportSchema),
|
|
26
|
+
valid: z.boolean(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
type ConfigCheckFieldReport = ReturnType<typeof checkConfig>['fields'][number] &
|
|
30
|
+
Readonly<{
|
|
31
|
+
redacted?: boolean;
|
|
32
|
+
}>;
|
|
33
|
+
|
|
34
|
+
/** Merge input values on top of resolved config values. */
|
|
35
|
+
const mergeValues = (
|
|
36
|
+
resolved: Record<string, unknown>,
|
|
37
|
+
overrides: Record<string, unknown>
|
|
38
|
+
): Record<string, unknown> => {
|
|
39
|
+
const hasOverrides = Object.keys(overrides).length > 0;
|
|
40
|
+
return hasOverrides ? deepMerge(resolved, overrides) : resolved;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const redactSecretFields = (
|
|
44
|
+
schema: z.ZodObject<Record<string, z.ZodType>>,
|
|
45
|
+
fields: ReturnType<typeof checkConfig>['fields']
|
|
46
|
+
): ConfigCheckFieldReport[] => {
|
|
47
|
+
const meta = collectConfigMeta(schema);
|
|
48
|
+
const redactedPaths = new Set(
|
|
49
|
+
[...meta.entries()]
|
|
50
|
+
.filter(
|
|
51
|
+
([, fieldMeta]) =>
|
|
52
|
+
fieldMeta.secret === true ||
|
|
53
|
+
(fieldMeta.env !== undefined && isLikelySecret(fieldMeta.env))
|
|
54
|
+
)
|
|
55
|
+
.map(([path]) => path)
|
|
56
|
+
);
|
|
57
|
+
return fields.map((field) => {
|
|
58
|
+
const shouldRedact = [...redactedPaths].some(
|
|
59
|
+
(path) => field.path === path || field.path.startsWith(`${path}.`)
|
|
60
|
+
);
|
|
61
|
+
if (!shouldRedact || !('value' in field) || field.value === undefined) {
|
|
62
|
+
return field;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { ...field, redacted: true, value: '[REDACTED]' };
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const configCheck = trail('config.check', {
|
|
70
|
+
examples: [
|
|
71
|
+
{
|
|
72
|
+
input: {},
|
|
73
|
+
name: 'Check current config',
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
implementation: (input, ctx) => {
|
|
77
|
+
const state = configResource.from(ctx);
|
|
78
|
+
const effective = mergeValues(state.resolved, input.values);
|
|
79
|
+
const checked = checkConfig(state.schema, effective);
|
|
80
|
+
return Result.ok({
|
|
81
|
+
fields: redactSecretFields(state.schema, checked.fields),
|
|
82
|
+
valid: checked.valid,
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
input: z.object({
|
|
86
|
+
values: z
|
|
87
|
+
.record(z.string(), z.unknown())
|
|
88
|
+
.describe('Config values to check (merged with resolved)')
|
|
89
|
+
.default({}),
|
|
90
|
+
}),
|
|
91
|
+
intent: 'read',
|
|
92
|
+
meta: { category: 'infrastructure' },
|
|
93
|
+
output: outputSchema,
|
|
94
|
+
resources: [configResource],
|
|
95
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infrastructure trail that describes all config fields in a schema.
|
|
3
|
+
*
|
|
4
|
+
* Returns a structured catalog of field definitions suitable for
|
|
5
|
+
* CLI rendering or agent inspection.
|
|
6
|
+
*/
|
|
7
|
+
import { Result, trail } from '@ontrails/core';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
|
|
10
|
+
import { configResource } from '../config-resource.js';
|
|
11
|
+
import { deriveConfigFields } from '../derive-fields.js';
|
|
12
|
+
|
|
13
|
+
const fieldSchema = z.object({
|
|
14
|
+
deprecated: z.string().optional(),
|
|
15
|
+
description: z.string().optional(),
|
|
16
|
+
env: z.string().optional(),
|
|
17
|
+
path: z.string(),
|
|
18
|
+
required: z.boolean(),
|
|
19
|
+
secret: z.boolean().optional(),
|
|
20
|
+
type: z.string(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const outputSchema = z.object({
|
|
24
|
+
fields: z.array(fieldSchema),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const configDescribe = trail('config.describe', {
|
|
28
|
+
examples: [
|
|
29
|
+
{
|
|
30
|
+
input: {},
|
|
31
|
+
name: 'Describe all config fields',
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
implementation: (_input, ctx) => {
|
|
35
|
+
const state = configResource.from(ctx);
|
|
36
|
+
const fields = deriveConfigFields(state.schema);
|
|
37
|
+
return Result.ok({ fields: [...fields] });
|
|
38
|
+
},
|
|
39
|
+
input: z.object({}),
|
|
40
|
+
intent: 'read',
|
|
41
|
+
meta: { category: 'infrastructure' },
|
|
42
|
+
output: outputSchema,
|
|
43
|
+
resources: [configResource],
|
|
44
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Infrastructure trail that generates an example config file.
|
|
3
|
+
*
|
|
4
|
+
* Produces TOML, JSON, JSONC, or YAML output from the registered
|
|
5
|
+
* config schema, with defaults shown and deprecated fields annotated.
|
|
6
|
+
*
|
|
7
|
+
* When `dir` is provided, also writes `.env.example` and `.schema.json`
|
|
8
|
+
* to the specified directory.
|
|
9
|
+
*/
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { mkdir } from 'node:fs/promises';
|
|
12
|
+
|
|
13
|
+
import { Result, trail } from '@ontrails/core';
|
|
14
|
+
import type { z } from 'zod';
|
|
15
|
+
import { z as zod } from 'zod';
|
|
16
|
+
|
|
17
|
+
import { configResource } from '../config-resource.js';
|
|
18
|
+
import {
|
|
19
|
+
deriveConfigEnvExample,
|
|
20
|
+
deriveConfigExample,
|
|
21
|
+
deriveConfigJsonSchema,
|
|
22
|
+
} from '../derive/index.js';
|
|
23
|
+
|
|
24
|
+
const formatEnum = zod.enum(['toml', 'json', 'jsonc', 'yaml']);
|
|
25
|
+
|
|
26
|
+
const outputSchema = zod.object({
|
|
27
|
+
content: zod.string(),
|
|
28
|
+
format: zod.string(),
|
|
29
|
+
writtenFiles: zod.array(zod.string()).optional(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Collect artifacts to write: [relativeName, content] pairs. */
|
|
33
|
+
const collectArtifacts = (
|
|
34
|
+
schema: z.ZodObject<Record<string, z.ZodType>>
|
|
35
|
+
): [string, string][] => {
|
|
36
|
+
const artifacts: [string, string][] = [];
|
|
37
|
+
const envContent = deriveConfigEnvExample(schema);
|
|
38
|
+
if (envContent.length > 0) {
|
|
39
|
+
artifacts.push(['.env.example', envContent]);
|
|
40
|
+
}
|
|
41
|
+
artifacts.push([
|
|
42
|
+
'.schema.json',
|
|
43
|
+
JSON.stringify(deriveConfigJsonSchema(schema), null, 2),
|
|
44
|
+
]);
|
|
45
|
+
return artifacts;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Write generated artifacts to the target directory. */
|
|
49
|
+
const writeArtifacts = async (
|
|
50
|
+
dir: string,
|
|
51
|
+
schema: z.ZodObject<Record<string, z.ZodType>>
|
|
52
|
+
): Promise<string[]> => {
|
|
53
|
+
await mkdir(dir, { recursive: true });
|
|
54
|
+
const artifacts = collectArtifacts(schema);
|
|
55
|
+
const written: string[] = [];
|
|
56
|
+
for (const [name, content] of artifacts) {
|
|
57
|
+
const fullPath = join(dir, name);
|
|
58
|
+
await Bun.write(fullPath, content);
|
|
59
|
+
written.push(fullPath);
|
|
60
|
+
}
|
|
61
|
+
return written;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const configInit = trail('config.init', {
|
|
65
|
+
examples: [
|
|
66
|
+
{
|
|
67
|
+
input: {},
|
|
68
|
+
name: 'Generate TOML example',
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
implementation: async (input, ctx) => {
|
|
72
|
+
const state = configResource.from(ctx);
|
|
73
|
+
const schema = state.schema as z.ZodObject<Record<string, z.ZodType>>;
|
|
74
|
+
const content = deriveConfigExample(schema, input.format);
|
|
75
|
+
|
|
76
|
+
if (input.dir) {
|
|
77
|
+
const writtenFiles = await writeArtifacts(input.dir, schema);
|
|
78
|
+
return Result.ok({ content, format: input.format, writtenFiles });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return Result.ok({ content, format: input.format });
|
|
82
|
+
},
|
|
83
|
+
input: zod.object({
|
|
84
|
+
dir: zod
|
|
85
|
+
.string()
|
|
86
|
+
.describe('Directory to write generated artifacts to')
|
|
87
|
+
.optional(),
|
|
88
|
+
format: formatEnum
|
|
89
|
+
.describe('Output format for the example config file')
|
|
90
|
+
.default('toml'),
|
|
91
|
+
}),
|
|
92
|
+
intent: 'write',
|
|
93
|
+
meta: { category: 'infrastructure' },
|
|
94
|
+
output: outputSchema,
|
|
95
|
+
resources: [configResource],
|
|
96
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import { NotFoundError, ValidationError } from '@ontrails/core';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
findTrailsConfigPaths,
|
|
9
|
+
findTrailsLocalConfigPaths,
|
|
10
|
+
} from './trails-conventions.js';
|
|
11
|
+
|
|
12
|
+
export interface LoadedTrailsConfigValue {
|
|
13
|
+
readonly configPath?: string | undefined;
|
|
14
|
+
readonly value?: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const MODULE_EXTENSIONS = new Set(['.ts', '.mts', '.js', '.mjs']);
|
|
18
|
+
const DATA_EXTENSIONS = new Set(['.json', '.jsonc', '.yaml', '.toml']);
|
|
19
|
+
|
|
20
|
+
const extensionFor = (filePath: string): string | undefined => {
|
|
21
|
+
for (const extension of [...MODULE_EXTENSIONS, ...DATA_EXTENSIONS]) {
|
|
22
|
+
if (filePath.endsWith(extension)) {
|
|
23
|
+
return extension;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return undefined;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const isModuleExtension = (extension: string | undefined): boolean =>
|
|
30
|
+
extension !== undefined && MODULE_EXTENSIONS.has(extension);
|
|
31
|
+
|
|
32
|
+
export const parseTrailsConfigData = (
|
|
33
|
+
filePath: string,
|
|
34
|
+
text: string
|
|
35
|
+
): unknown => {
|
|
36
|
+
const extension = extensionFor(filePath);
|
|
37
|
+
try {
|
|
38
|
+
switch (extension) {
|
|
39
|
+
case '.json': {
|
|
40
|
+
return JSON.parse(text);
|
|
41
|
+
}
|
|
42
|
+
case '.jsonc': {
|
|
43
|
+
return Bun.JSONC.parse(text);
|
|
44
|
+
}
|
|
45
|
+
case '.toml': {
|
|
46
|
+
return Bun.TOML.parse(text);
|
|
47
|
+
}
|
|
48
|
+
case '.yaml': {
|
|
49
|
+
return Bun.YAML.parse(text);
|
|
50
|
+
}
|
|
51
|
+
default: {
|
|
52
|
+
throw new ValidationError(
|
|
53
|
+
`Unsupported Trails config file: ${filePath}`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error instanceof ValidationError) {
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
throw new ValidationError(
|
|
62
|
+
`Failed to parse Trails config file: ${filePath}`,
|
|
63
|
+
{
|
|
64
|
+
cause: error instanceof Error ? error : new Error(String(error)),
|
|
65
|
+
context: { path: filePath },
|
|
66
|
+
}
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const loadTrailsConfigFileValue = async (
|
|
72
|
+
filePath: string
|
|
73
|
+
): Promise<unknown> => {
|
|
74
|
+
const extension = extensionFor(filePath);
|
|
75
|
+
if (isModuleExtension(extension)) {
|
|
76
|
+
const url = pathToFileURL(filePath);
|
|
77
|
+
url.searchParams.set('t', Date.now().toString());
|
|
78
|
+
const mod = (await import(url.href)) as Record<string, unknown>;
|
|
79
|
+
return mod['default'] ?? mod;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const text = await Bun.file(filePath).text();
|
|
83
|
+
return parseTrailsConfigData(filePath, text);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const findSingleConfigPath = (
|
|
87
|
+
paths: readonly string[],
|
|
88
|
+
label: string
|
|
89
|
+
): string | undefined => {
|
|
90
|
+
if (paths.length <= 1) {
|
|
91
|
+
return paths[0];
|
|
92
|
+
}
|
|
93
|
+
throw new ValidationError(
|
|
94
|
+
`Multiple ${label} config files found: ${paths.join(', ')}. Keep one config file per project root.`
|
|
95
|
+
);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const loadTrailsConfigValue = async ({
|
|
99
|
+
configPath,
|
|
100
|
+
rootDir,
|
|
101
|
+
}: {
|
|
102
|
+
readonly configPath?: string | undefined;
|
|
103
|
+
readonly rootDir: string;
|
|
104
|
+
}): Promise<LoadedTrailsConfigValue> => {
|
|
105
|
+
const located =
|
|
106
|
+
configPath === undefined
|
|
107
|
+
? findSingleConfigPath(findTrailsConfigPaths(rootDir), 'Trails')
|
|
108
|
+
: resolve(rootDir, configPath);
|
|
109
|
+
|
|
110
|
+
if (located === undefined) {
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
if (!existsSync(located)) {
|
|
114
|
+
throw new NotFoundError(`Trails config file not found: ${located}`, {
|
|
115
|
+
context: { path: located },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
configPath: located,
|
|
121
|
+
value: await loadTrailsConfigFileValue(located),
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const loadTrailsLocalConfigValue = async (
|
|
126
|
+
rootDir: string
|
|
127
|
+
): Promise<LoadedTrailsConfigValue> => {
|
|
128
|
+
const located = findSingleConfigPath(
|
|
129
|
+
findTrailsLocalConfigPaths(rootDir),
|
|
130
|
+
'Trails local'
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
return located === undefined
|
|
134
|
+
? {}
|
|
135
|
+
: { configPath: located, value: await loadTrailsConfigFileValue(located) };
|
|
136
|
+
};
|