@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/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
+ };